feat(email_verified): store email verification on a user
This commit is contained in:
@@ -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.';
|
||||
@@ -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 {
|
||||
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?;
|
||||
|
||||
|
||||
@@ -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"),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user