use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};
use sqlx::Row;
use super::{LOCAL_PROVIDER, LocalPasswordAuthenticator, db_error, opaque_token::OpaqueToken};
use crate::{
audit::logger::{AuditEventType, SecretType, get_audit_logger},
constant_time::ConstantTimeOps,
error::{AuthError, Result},
};
pub const EMAIL_VERIFICATION_TOKEN_TTL_SECS: i64 = 3600;
const TOKEN_KIND: &str = "email verification";
pub const EMAIL_VERIFICATION_SCHEMA_SQL: &str = r"
CREATE SCHEMA IF NOT EXISTS core;
CREATE TABLE IF NOT EXISTS core.tb_email_verification_token (
pk_email_verification_token BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
id UUID NOT NULL DEFAULT gen_random_uuid(),
fk_user BIGINT NOT NULL REFERENCES core.tb_user (pk_user) ON DELETE CASCADE,
user_id TEXT NOT NULL,
email TEXT NOT NULL,
selector TEXT NOT NULL,
verifier_hash BYTEA NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
tenant_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (selector)
);
CREATE INDEX IF NOT EXISTS idx_email_verification_token_fk_user
ON core.tb_email_verification_token (fk_user);
-- RLS deny-by-default (mirrors core.tb_user / core.tb_auth_identity / tb_password_reset_token).
ALTER TABLE core.tb_email_verification_token ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_email_verification_token_tenant_read ON core.tb_email_verification_token;
CREATE POLICY p_email_verification_token_tenant_read ON core.tb_email_verification_token
FOR SELECT USING (tenant_id = NULLIF(current_setting('fraiseql.tenant_id', true), '')::uuid);
DROP POLICY IF EXISTS p_email_verification_token_insert ON core.tb_email_verification_token;
CREATE POLICY p_email_verification_token_insert ON core.tb_email_verification_token
FOR INSERT WITH CHECK (true);
-- Least-privilege baseline: never world-readable. RLS is defence-in-depth on top.
REVOKE ALL ON core.tb_email_verification_token FROM PUBLIC;
";
#[async_trait]
pub trait VerificationEmailSender: Send + Sync {
async fn send_verification_link(&self, to: &str, token: &str) -> Result<()>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EmailVerified {
pub user_id: String,
pub email: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PromotionDecision {
Promote,
AlreadyVerified,
RefuseClaimedByAnotherAccount,
RefuseAccountHasDifferentEmail,
}
#[must_use]
pub fn decide_promotion(
account_email: Option<&str>,
proved_email: &str,
claimed_by_other_account: bool,
) -> PromotionDecision {
if claimed_by_other_account {
return PromotionDecision::RefuseClaimedByAnotherAccount;
}
match account_email {
None => PromotionDecision::Promote,
Some(existing) if existing == proved_email => PromotionDecision::AlreadyVerified,
Some(_) => PromotionDecision::RefuseAccountHasDifferentEmail,
}
}
fn invalid_verification_token() -> AuthError {
AuthError::InvalidToken {
reason: "invalid, expired, or already-used email verification token".to_string(),
}
}
impl LocalPasswordAuthenticator {
#[must_use]
pub fn with_verification_email_sender(
mut self,
sender: Arc<dyn VerificationEmailSender>,
) -> Self {
self.verification_sender = Some(sender);
self
}
pub async fn start_email_verification(&self, user_id: &str) -> Result<()> {
let logger = get_audit_logger();
let row = sqlx::query(
"SELECT u.pk_user, u.email AS verified_email, i.provider_id AS claimed_email \
FROM core.tb_user u \
JOIN core.tb_auth_identity i ON i.fk_user = u.pk_user \
WHERE u.user_id = $1 AND i.provider = $2",
)
.bind(user_id)
.bind(LOCAL_PROVIDER)
.fetch_optional(&self.db)
.await
.map_err(|e| db_error("lookup local identity for verification", &e))?;
let Some(row) = row else {
logger.log_failure(
AuditEventType::AuthFailure,
SecretType::SessionToken,
Some(user_id.to_string()),
"email_verification_start",
"no_local_identity",
);
return Ok(());
};
let fk_user: i64 = row.get("pk_user");
let verified_email: Option<String> = row.get("verified_email");
let claimed_email: String = row.get("claimed_email");
if verified_email.as_deref() == Some(claimed_email.as_str()) {
logger.log_success(
AuditEventType::AuthSuccess,
SecretType::SessionToken,
Some(user_id.to_string()),
"email_verification_start:already_verified",
);
return Ok(());
}
let token = OpaqueToken::generate();
let expires_at = Utc::now() + Duration::seconds(EMAIL_VERIFICATION_TOKEN_TTL_SECS);
sqlx::query(
"INSERT INTO core.tb_email_verification_token \
(fk_user, user_id, email, selector, verifier_hash, expires_at) \
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(fk_user)
.bind(user_id)
.bind(&claimed_email)
.bind(token.selector_b64())
.bind(token.verifier_hash())
.bind(expires_at)
.execute(&self.db)
.await
.map_err(|e| db_error("insert verification token", &e))?;
if let Some(sender) = self.verification_sender.clone() {
let to = claimed_email;
let token_str = token.to_token_string();
tokio::spawn(async move {
if let Err(e) = sender.send_verification_link(&to, &token_str).await {
tracing::warn!(
"email_verification_start: verification email dispatch failed: {e}"
);
}
});
} else {
tracing::warn!(
"email_verification_start: token issued but no VerificationEmailSender is \
configured; the verification link was not delivered"
);
}
logger.log_success(
AuditEventType::AuthSuccess,
SecretType::SessionToken,
Some(user_id.to_string()),
"email_verification_start",
);
Ok(())
}
pub async fn confirm_email_verification(
&self,
user_id: &str,
token: &str,
) -> Result<EmailVerified> {
let logger = get_audit_logger();
let Ok(parsed) = OpaqueToken::parse(TOKEN_KIND, token) else {
logger.log_failure(
AuditEventType::AuthFailure,
SecretType::SessionToken,
Some(user_id.to_string()),
"email_verification_confirm",
"malformed_token",
);
return Err(invalid_verification_token());
};
let row = sqlx::query(
"SELECT pk_email_verification_token AS pk, fk_user, user_id, email, verifier_hash, \
expires_at, used_at \
FROM core.tb_email_verification_token WHERE selector = $1",
)
.bind(&parsed.selector)
.fetch_optional(&self.db)
.await
.map_err(|e| db_error("lookup verification token", &e))?;
let Some(row) = row else {
logger.log_failure(
AuditEventType::AuthFailure,
SecretType::SessionToken,
Some(user_id.to_string()),
"email_verification_confirm",
"unknown_selector",
);
return Err(invalid_verification_token());
};
let pk: i64 = row.get("pk");
let fk_user: i64 = row.get("fk_user");
let token_user_id: String = row.get("user_id");
let email: String = row.get("email");
let stored_hash: Vec<u8> = row.get("verifier_hash");
let expires_at: DateTime<Utc> = row.get("expires_at");
let used_at: Option<DateTime<Utc>> = row.get("used_at");
if !ConstantTimeOps::compare(&stored_hash, &parsed.verifier_hash) {
logger.log_failure(
AuditEventType::AuthFailure,
SecretType::SessionToken,
Some(user_id.to_string()),
"email_verification_confirm",
"bad_verifier",
);
return Err(invalid_verification_token());
}
if token_user_id != user_id {
logger.log_failure(
AuditEventType::AuthFailure,
SecretType::SessionToken,
Some(user_id.to_string()),
"email_verification_confirm",
"wrong_subject",
);
return Err(invalid_verification_token());
}
if used_at.is_some() {
logger.log_failure(
AuditEventType::AuthFailure,
SecretType::SessionToken,
Some(user_id.to_string()),
"email_verification_confirm",
"used",
);
return Err(invalid_verification_token());
}
if expires_at <= Utc::now() {
logger.log_failure(
AuditEventType::AuthFailure,
SecretType::SessionToken,
Some(user_id.to_string()),
"email_verification_confirm",
"expired",
);
return Err(invalid_verification_token());
}
let mut tx = self
.db
.begin()
.await
.map_err(|e| db_error("begin verification transaction", &e))?;
let consumed = sqlx::query(
"UPDATE core.tb_email_verification_token SET used_at = now() \
WHERE pk_email_verification_token = $1 AND used_at IS NULL AND expires_at > now()",
)
.bind(pk)
.execute(&mut *tx)
.await
.map_err(|e| db_error("consume verification token", &e))?;
if consumed.rows_affected() == 0 {
tx.rollback().await.map_err(|e| db_error("rollback verification", &e))?;
logger.log_failure(
AuditEventType::AuthFailure,
SecretType::SessionToken,
Some(user_id.to_string()),
"email_verification_confirm",
"race",
);
return Err(invalid_verification_token());
}
let account_email: Option<String> =
sqlx::query("SELECT email FROM core.tb_user WHERE pk_user = $1 FOR UPDATE")
.bind(fk_user)
.fetch_optional(&mut *tx)
.await
.map_err(|e| db_error("lock user row", &e))?
.ok_or_else(|| AuthError::Internal {
message: "verification token resolved to a missing user row".to_string(),
})?
.get("email");
let claimed_by_other = sqlx::query(
"SELECT 1 AS claimed FROM core.tb_user o \
WHERE o.email = $1 AND o.pk_user <> $2 \
AND o.tenant_id IS NOT DISTINCT FROM \
(SELECT u.tenant_id FROM core.tb_user u WHERE u.pk_user = $2)",
)
.bind(&email)
.bind(fk_user)
.fetch_optional(&mut *tx)
.await
.map_err(|e| db_error("check email ownership", &e))?
.is_some();
let decision = decide_promotion(account_email.as_deref(), &email, claimed_by_other);
match decision {
PromotionDecision::Promote => {
sqlx::query(
"UPDATE core.tb_user SET email = $1, updated_at = now() WHERE pk_user = $2",
)
.bind(&email)
.bind(fk_user)
.execute(&mut *tx)
.await
.map_err(|e| db_error("promote verified email", &e))?;
},
PromotionDecision::AlreadyVerified => {},
PromotionDecision::RefuseClaimedByAnotherAccount
| PromotionDecision::RefuseAccountHasDifferentEmail => {
tx.commit().await.map_err(|e| db_error("commit verification refusal", &e))?;
logger.log_failure(
AuditEventType::AuthFailure,
SecretType::SessionToken,
Some(user_id.to_string()),
"email_verification_confirm",
if decision == PromotionDecision::RefuseClaimedByAnotherAccount {
"email_claimed_by_another_account"
} else {
"account_has_a_different_verified_email"
},
);
return Err(AuthError::EmailClaimedByAnotherAccount);
},
}
sqlx::query(
"UPDATE core.tb_email_verification_token SET used_at = now() \
WHERE fk_user = $1 AND used_at IS NULL",
)
.bind(fk_user)
.execute(&mut *tx)
.await
.map_err(|e| db_error("invalidate sibling verification tokens", &e))?;
tx.commit().await.map_err(|e| db_error("commit verification transaction", &e))?;
logger.log_success(
AuditEventType::AuthSuccess,
SecretType::SessionToken,
Some(user_id.to_string()),
"email_verification_confirm",
);
Ok(EmailVerified {
user_id: user_id.to_string(),
email,
})
}
}
#[allow(clippy::unwrap_used)] #[cfg(test)]
mod tests;