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