feat(email_verified): store email verification on a user

This commit is contained in:
Edouard Vanbelle
2026-06-02 23:55:50 +02:00
parent 8fc9a50681
commit 6aba7cbbbf
7 changed files with 157 additions and 15 deletions
@@ -0,0 +1,51 @@
-- ════════════════════════════════════════════════════════════════════════════
-- Email-verified signal (PR 23)
-- ════════════════════════════════════════════════════════════════════════════
-- Tracks when the user demonstrated control of their email address.
--
-- NULL — unverified. Classic password-only signup whose user
-- never clicked any magic-link, or admin-created user
-- who hasn't logged in via magic-link.
-- non-NULL — timestamp of the FIRST proof of control. Stamped on:
-- * successful magic-link redemption (invitation OR
-- login-via-email — clicking the link IS the proof).
-- * OIDC JIT-provisioning when the IdP's claim
-- `email_verified` was true. The OIDC callback already
-- refuses to proceed without that claim, so the
-- timestamp is set unconditionally at JIT creation.
-- * Retroactive OIDC upgrade: existing user whose
-- email_verified_at is NULL but whose next OIDC login
-- carries a verified claim gets the stamp at that
-- login.
--
-- PR 23 introduces the signal only — no policy gates yet. Future
-- env (e.g. OXICLOUD_REQUIRE_EMAIL_VERIFICATION) will block uploads /
-- shares / etc. for unverified users.
--
-- Backfill rules:
-- * OIDC-linked users — the IdP already vetted the email at
-- provisioning time. Use last_login_at if set (typical), else
-- created_at as the verification timestamp.
-- * External users who have logged in at least once — they must have
-- clicked their invitation link to land last_login_at. Use the
-- last login time as a conservative lower bound on when the
-- verification proof happened.
-- * Everyone else stays NULL — including OIDC-less external users
-- who got invited but never clicked (the magic-link is still
-- sitting in their inbox), and classic password users who never
-- went through a magic-link flow.
ALTER TABLE auth.users
ADD COLUMN email_verified_at TIMESTAMPTZ NULL;
UPDATE auth.users
SET email_verified_at = COALESCE(last_login_at, created_at)
WHERE oidc_subject IS NOT NULL
OR (is_external = TRUE AND last_login_at IS NOT NULL);
COMMENT ON COLUMN auth.users.email_verified_at IS
'When the user demonstrated control of their email address.
NULL = unverified. Set on successful magic-link redemption OR
OIDC JIT with email_verified=true claim. Idempotent — the first
verification timestamp is preserved. PR 23 ships the signal;
future policy PRs gate features on it.';
+8
View File
@@ -40,6 +40,13 @@ pub struct UserDto {
/// `given_name`.
#[serde(skip_serializing_if = "Option::is_none")]
pub family_name: Option<String>,
/// When the user first demonstrated control of their email (PR 23).
/// `None` = unverified (omitted from JSON). Stamped on the first
/// successful magic-link redemption or OIDC JIT with verified
/// claim. Idempotent — the original timestamp is preserved on
/// subsequent verifications.
#[serde(skip_serializing_if = "Option::is_none")]
pub email_verified_at: Option<DateTime<Utc>>,
}
impl From<User> for UserDto {
@@ -61,6 +68,7 @@ impl From<User> for UserDto {
is_external: user.is_external(),
given_name: user.given_name().map(str::to_string),
family_name: user.family_name().map(str::to_string),
email_verified_at: user.email_verified_at(),
}
}
}
@@ -794,6 +794,11 @@ impl AuthApplicationService {
lc.dispatch_login(&user).await;
}
user.register_login();
// PR 23: clicking the magic-link IS proof of email control —
// stamp the verification (idempotent, preserves the first
// timestamp). Applies to both invitation and login-via-email
// tokens.
user.mark_email_verified();
self.user_storage.update_user(user.clone()).await?;
let access_token = self.token_service.generate_access_token(&user)?;
@@ -827,11 +832,13 @@ impl AuthApplicationService {
expires_in: self.token_service.refresh_token_expiry_secs(),
};
Ok(MagicLinkRedeemResult::Allowed(Box::new(MagicLinkRedemption {
auth,
resource_kind: mlt.resource_kind(),
resource_id: mlt.resource_id(),
})))
Ok(MagicLinkRedeemResult::Allowed(Box::new(
MagicLinkRedemption {
auth,
resource_kind: mlt.resource_kind(),
resource_id: mlt.resource_id(),
},
)))
}
/// Verifies username/password credentials without creating a session.
@@ -1887,6 +1894,12 @@ impl AuthApplicationService {
}
existing_user.register_login();
existing_user.set_image(claims.picture.clone());
// PR 23: retroactive email verification for OIDC users
// who predate the column. The OIDC callback already
// enforced `claims.email_verified == true` upstream, so
// any user reaching this branch has a verified email
// by the IdP's word; stamping is safe and idempotent.
existing_user.mark_email_verified();
self.user_storage.update_user(existing_user.clone()).await?;
existing_user
}
@@ -1983,6 +1996,11 @@ impl AuthApplicationService {
new_user.set_image(claims.picture.clone());
new_user.set_given_name(claims.given_name.clone());
new_user.set_family_name(claims.family_name.clone());
// PR 23: the OIDC callback rejected any caller upstream
// whose `email_verified` claim wasn't true, so users
// reaching this branch have an IdP-vetted email. Stamp
// the verification at JIT-create time.
new_user.mark_email_verified();
let created_user = self.user_storage.create_user(new_user).await?;
+44
View File
@@ -61,6 +61,17 @@ pub struct User {
/// standard claim `family_name` at JIT provisioning, or via the
/// profile-edit endpoint. External users start with `None`.
family_name: Option<String>,
/// When the user demonstrated control of their email address (PR 23).
/// `None` = unverified. `Some(ts)` = timestamp of the first proof,
/// preserved across subsequent verifications.
///
/// Set on successful magic-link redemption (invitation OR
/// login-via-email — clicking the link proves the inbox is theirs)
/// or on OIDC JIT with `email_verified=true` claim. Classic password
/// signups stay `None` until the user goes through a magic-link
/// flow. PR 23 ships the signal only — future policy PRs gate
/// features (uploads, shares, etc.) on this column.
email_verified_at: Option<DateTime<Utc>>,
}
impl User {
@@ -146,6 +157,10 @@ impl User {
is_external,
given_name: None,
family_name: None,
// PR 23: unverified at creation. Stamped on the first
// magic-link redemption or OIDC JIT (where the IdP has
// already confirmed the email).
email_verified_at: None,
})
}
@@ -187,6 +202,7 @@ impl User {
is_external: false,
given_name: None,
family_name: None,
email_verified_at: None,
}
}
@@ -209,6 +225,7 @@ impl User {
is_external: bool,
given_name: Option<String>,
family_name: Option<String>,
email_verified_at: Option<DateTime<Utc>>,
) -> Self {
Self {
id,
@@ -228,6 +245,7 @@ impl User {
is_external,
given_name,
family_name,
email_verified_at,
}
}
@@ -331,6 +349,32 @@ impl User {
self.family_name.as_deref()
}
/// When the user first demonstrated control of their email (PR 23).
/// `None` = unverified. See `mark_email_verified` for the trigger
/// points (magic-link redemption, OIDC JIT with verified claim).
pub fn email_verified_at(&self) -> Option<DateTime<Utc>> {
self.email_verified_at
}
/// `true` iff the user has demonstrated control of their email.
/// Convenience wrapper over `email_verified_at().is_some()`.
pub fn is_email_verified(&self) -> bool {
self.email_verified_at.is_some()
}
/// Stamp the first proof-of-email-control timestamp. **Idempotent**:
/// if `email_verified_at` is already `Some`, this is a no-op so
/// re-verifications preserve the original time. Call from the
/// magic-link redemption path and from OIDC JIT when the IdP
/// confirms the email.
pub fn mark_email_verified(&mut self) {
if self.email_verified_at.is_none() {
let now = Utc::now();
self.email_verified_at = Some(now);
self.updated_at = now;
}
}
pub fn set_image(&mut self, image: Option<String>) {
self.image = image;
self.updated_at = Utc::now();
@@ -96,10 +96,10 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, is_external,
given_name, family_name
given_name, family_name, email_verified_at
) VALUES (
$1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11,
$12, $13, $14, $15, $16
$12, $13, $14, $15, $16, $17
)
RETURNING *
"#,
@@ -120,6 +120,7 @@ impl UserRepository for UserPgRepository {
.bind(user_clone.is_external())
.bind(user_clone.given_name())
.bind(user_clone.family_name())
.bind(user_clone.email_verified_at())
.execute(&mut **tx)
.await
.map_err(Self::map_sqlx_error)?;
@@ -144,7 +145,7 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name
given_name, family_name, email_verified_at
FROM auth.users
WHERE id = $1
"#,
@@ -179,6 +180,7 @@ impl UserRepository for UserPgRepository {
row.get("is_external"),
row.get("given_name"),
row.get("family_name"),
row.get("email_verified_at"),
))
}
@@ -191,7 +193,7 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name
given_name, family_name, email_verified_at
FROM auth.users
WHERE username = $1
"#,
@@ -226,6 +228,7 @@ impl UserRepository for UserPgRepository {
row.get("is_external"),
row.get("given_name"),
row.get("family_name"),
row.get("email_verified_at"),
))
}
@@ -238,7 +241,7 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name
given_name, family_name, email_verified_at
FROM auth.users
WHERE email = $1
"#,
@@ -273,6 +276,7 @@ impl UserRepository for UserPgRepository {
row.get("is_external"),
row.get("given_name"),
row.get("family_name"),
row.get("email_verified_at"),
))
}
@@ -299,7 +303,8 @@ impl UserRepository for UserPgRepository {
active = $10,
image = $11,
given_name = $12,
family_name = $13
family_name = $13,
email_verified_at = $14
WHERE id = $1
"#,
)
@@ -316,6 +321,7 @@ impl UserRepository for UserPgRepository {
.bind(user_clone.image())
.bind(user_clone.given_name())
.bind(user_clone.family_name())
.bind(user_clone.email_verified_at())
.execute(&mut **tx)
.await
.map_err(Self::map_sqlx_error)?;
@@ -388,7 +394,7 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name
given_name, family_name, email_verified_at
FROM auth.users
WHERE ($3 OR is_external = FALSE)
ORDER BY created_at DESC
@@ -430,6 +436,7 @@ impl UserRepository for UserPgRepository {
row.get("is_external"),
row.get("given_name"),
row.get("family_name"),
row.get("email_verified_at"),
)
})
.collect();
@@ -451,7 +458,7 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name
given_name, family_name, email_verified_at
FROM auth.users
WHERE (username ILIKE $1 OR email ILIKE $1)
AND ($3 OR is_external = FALSE)
@@ -493,6 +500,7 @@ impl UserRepository for UserPgRepository {
row.get("is_external"),
row.get("given_name"),
row.get("family_name"),
row.get("email_verified_at"),
)
})
.collect();
@@ -580,7 +588,7 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name
given_name, family_name, email_verified_at
FROM auth.users
WHERE role::text = $1
ORDER BY created_at DESC
@@ -619,6 +627,7 @@ impl UserRepository for UserPgRepository {
row.get("is_external"),
row.get("given_name"),
row.get("family_name"),
row.get("email_verified_at"),
)
})
.collect();
@@ -655,7 +664,7 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name
given_name, family_name, email_verified_at
FROM auth.users
WHERE oidc_provider = $1 AND oidc_subject = $2
"#,
@@ -690,6 +699,7 @@ impl UserRepository for UserPgRepository {
row.get("is_external"),
row.get("given_name"),
row.get("family_name"),
row.get("email_verified_at"),
))
}
+6
View File
@@ -224,6 +224,9 @@ jsonpath "$.id" == "{{bob_user_id}}"
jsonpath "$.is_external" == true
jsonpath "$.email" == "bob@externalcompany.com"
jsonpath "$.username" not exists
# PR 23 — bob redeemed his invitation magic-link in Step 8, so his
# email_verified_at was stamped at that time and stays set.
jsonpath "$.email_verified_at" exists
# 11d — bob CAN look up Alice (his granter) — shared-grant relationship
# lets the external recipient resolve the sharer's display name +
@@ -235,6 +238,9 @@ HTTP 200
[Asserts]
jsonpath "$.id" == "{{alice_user_id}}"
jsonpath "$.is_external" == false
# PR 23 — alice is the admin set up via classic password registration
# and has never clicked a magic-link, so her email is unverified.
jsonpath "$.email_verified_at" not exists
# 11e — bob CANNOT enumerate unrelated users. A random UUID returns 404
# (anti-enumeration; same response as "user doesn't exist").
+5
View File
@@ -137,6 +137,11 @@ HTTP 200
jsonpath "$.email" == "pr18-emailonly@example.com"
jsonpath "$.is_external" == false
jsonpath "$.username" not exists
# PR 23 — the user redeemed the welcome magic-link in Step 5b, so
# email_verified_at is stamped (the click IS the proof of inbox
# control, regardless of whether the redemption went through the
# direct or cross-browser-confirm path).
jsonpath "$.email_verified_at" exists
[Captures]
pr18_user_id: jsonpath "$.id"