use base64::Engine; use rand::Rng; use time::OffsetDateTime; use uuid::Uuid; use cache::redis::{RedisCache, RedisError}; use db::conn::sea_orm::{ ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set, TransactionTrait, }; use db::user::user_email_verification_codes::{ ActiveModel as CodeActiveModel, EmailVerificationPurpose, Entity as CodeEntity, }; use db::user::user_emails::{ActiveModel as EmailActiveModel, Entity as EmailEntity}; use db::user::users::{ActiveModel as UserActiveModel, Entity as UserEntity}; use crate::config::AuthConfig; use crate::error::AuthError; use crate::features::TempKey; use crate::metrics::{self, EmailVerifyOutcome, record_email_code_verify}; use super::types::{IssueCodeArtifacts, IssueCodeCmd, VerifyCodeCmd, VerifyCodeResult}; fn redis_key(purpose: EmailVerificationPurpose, uuid: &Uuid) -> String { format!("auth:code:{}:{}", purpose_to_str(purpose), uuid.simple()) } fn purpose_to_str(p: EmailVerificationPurpose) -> &'static str { match p { EmailVerificationPurpose::Signup => "signup", EmailVerificationPurpose::PasswordReset => "password_reset", EmailVerificationPurpose::EmailChange => "email_change", } } pub(crate) fn cookie_name(prefix: &str, purpose: EmailVerificationPurpose, email: &str) -> String { let lower = email.to_ascii_lowercase(); let safe: String = lower .chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) .collect(); format!("{}{}_{}", prefix, purpose_to_str(purpose), safe) } pub fn sha256_hex(data: &[u8]) -> String { use sha2::Digest; let digest = sha2::Sha256::digest(data); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest) } pub fn generate_numeric_code(digits: usize) -> String { let mut buf = vec![0u8; digits]; rand::rng().fill_bytes(&mut buf); let mut out = String::with_capacity(digits); for b in buf.iter() { let v = if *b < 250 { *b % 10 } else { loop { let mut byte = [0u8; 1]; rand::rng().fill_bytes(&mut byte); if byte[0] < 250 { break byte[0] % 10; } } }; out.push((b'0' + v) as char); } out } fn map_redis(e: RedisError) -> AuthError { AuthError::Internal(format!("redis: {e}")) } fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; } let mut diff: u8 = 0; for (x, y) in a.iter().zip(b.iter()) { diff |= x ^ y; } diff == 0 } pub async fn issue_code( cmd: IssueCodeCmd, db: &DatabaseConnection, kv: &RedisCache, config: &AuthConfig, ) -> Result { let _timer = metrics::Timer::start(metrics::FEATURE_EMAIL_CODE_ISSUE); let result = issue_code_inner(cmd, db, kv, config).await; metrics::record_outcome(metrics::FEATURE_EMAIL_CODE_ISSUE, &result); result } async fn issue_code_inner( cmd: IssueCodeCmd, db: &DatabaseConnection, kv: &RedisCache, config: &AuthConfig, ) -> Result { let rate_key = format!( "auth:code_rate:{}:{}", purpose_to_str(cmd.purpose), cmd.email.trim().to_ascii_lowercase() ); if kv.get_bytes(&rate_key).await.map_err(map_redis)?.is_some() { return Err(AuthError::RateLimited( "please wait before requesting another code".into(), )); } kv.set_bytes(&rate_key, b"1".to_vec(), config.email_code_rate_limit_secs) .await .map_err(map_redis)?; let stale_mark = CodeActiveModel { used_at: Set(Some(OffsetDateTime::now_utc())), ..Default::default() }; let _ = CodeEntity::update_many() .set(stale_mark) .filter(CodeEntity::COLUMN.email.eq(cmd.email.clone())) .filter(CodeEntity::COLUMN.purpose.eq(cmd.purpose)) .filter(CodeEntity::COLUMN.used_at.is_null()) .exec(db) .await; let code = generate_numeric_code(config.verify_code_len); let temp_id = Uuid::new_v4(); let ttl = config.verify_code_ttl_secs; let now = OffsetDateTime::now_utc(); let expires_at = now + time::Duration::seconds(ttl as i64); let key = redis_key(cmd.purpose, &temp_id); kv.set_bytes(&key, code.as_bytes().to_vec(), ttl) .await .map_err(map_redis)?; let token_hash = sha256_hex(code.as_bytes()); let row: CodeActiveModel = CodeActiveModel::builder() .set_id(Uuid::new_v4()) .set_user_id(cmd.user_id) .set_email(cmd.email.clone()) .set_purpose(cmd.purpose) .set_token_hash(token_hash) .set_created_at(now) .set_expires_at(expires_at) .set_used_at(None) .into(); let inserted = row.insert(db).await?; let temp_key = TempKey { name: cookie_name(&config.cookie_name_prefix, cmd.purpose, &cmd.email), value: temp_id.to_string(), ttl_seconds: ttl, secure: config.cookie_secure, http_only: true, same_site: config.cookie_same_site.clone(), domain: config.cookie_domain.clone(), path: Some("/".to_string()), }; Ok(IssueCodeArtifacts { temp_key, code, code_row_id: inserted.id, expires_at, }) } pub async fn verify_code( cmd: VerifyCodeCmd, db: &DatabaseConnection, kv: &RedisCache, config: &AuthConfig, ) -> Result { let _timer = metrics::Timer::start(metrics::FEATURE_EMAIL_CODE_VERIFY); let result = verify_code_inner(cmd, db, kv, config).await; let outcome = match &result { Ok(_) => EmailVerifyOutcome::Success, Err(AuthError::BadRequest(msg)) if msg.contains("expired") => EmailVerifyOutcome::Expired, Err(AuthError::BadRequest(msg)) if msg.contains("not found") => { EmailVerifyOutcome::NotFound } Err(AuthError::BadRequest(_)) => EmailVerifyOutcome::Mismatch, _ => EmailVerifyOutcome::Mismatch, }; record_email_code_verify(outcome); metrics::record_outcome(metrics::FEATURE_EMAIL_CODE_VERIFY, &result); result } async fn verify_code_inner( cmd: VerifyCodeCmd, db: &DatabaseConnection, kv: &RedisCache, config: &AuthConfig, ) -> Result { let temp_id = Uuid::parse_str(&cmd.temp_key_value)?; let key = redis_key(cmd.purpose, &temp_id); let attempts_key = format!( "auth:code_attempts:{}:{}", purpose_to_str(cmd.purpose), temp_id.simple() ); let attempts = kv .incr_with_ttl(&attempts_key, config.verify_code_ttl_secs) .await .map_err(map_redis)?; if attempts > config.max_verify_attempts as u64 { kv.delete(&key).await.map_err(map_redis)?; return Err(AuthError::RateLimited( "too many verification attempts; request a new code".into(), )); } let stored = kv .get_bytes(&key) .await .map_err(map_redis)? .ok_or_else(|| AuthError::BadRequest("verification code expired or not found".into()))?; if !constant_time_eq(stored.as_slice(), cmd.code.as_bytes()) { return Err(AuthError::BadRequest("invalid verification code".into())); } kv.delete(&key).await.map_err(map_redis)?; let token_hash = sha256_hex(cmd.code.as_bytes()); let user_id = match cmd.user_id { Some(uid) => uid, None => EmailEntity::find() .filter(EmailEntity::COLUMN.lower_email.eq(cmd.email.trim().to_ascii_lowercase())) .filter(EmailEntity::COLUMN.deleted_at.is_null()) .one(db) .await? .ok_or_else(|| AuthError::BadRequest("verification code not found".into()))? .user_id, }; let row = CodeEntity::find() .filter(CodeEntity::COLUMN.email.eq(cmd.email.clone())) .filter(CodeEntity::COLUMN.purpose.eq(cmd.purpose)) .filter(CodeEntity::COLUMN.token_hash.eq(token_hash)) .filter(CodeEntity::COLUMN.user_id.eq(user_id)) .filter(CodeEntity::COLUMN.used_at.is_null()) .one(db) .await? .ok_or_else(|| AuthError::BadRequest("verification code not found".into()))?; if row.expires_at < OffsetDateTime::now_utc() { return Err(AuthError::BadRequest("verification code expired".into())); } let now = OffsetDateTime::now_utc(); let result = if cmd.purpose == EmailVerificationPurpose::Signup { let txn = db.begin().await?; let mut am: CodeActiveModel = row.into(); am.used_at = Set(Some(now)); let updated = am.update(&txn).await?; let _ = UserEntity::update_many() .set(UserActiveModel { is_active: Set(true), updated_at: Set(now), ..Default::default() }) .filter(UserEntity::COLUMN.uid.eq(user_id)) .exec(&txn) .await?; let _ = EmailEntity::update_many() .set(EmailActiveModel { is_activated: Set(true), activated_at: Set(Some(now)), updated_at: Set(now), ..Default::default() }) .filter(EmailEntity::COLUMN.user_id.eq(user_id)) .filter(EmailEntity::COLUMN.email.eq(cmd.email.clone())) .exec(&txn) .await?; txn.commit().await?; VerifyCodeResult { code_row_id: updated.id, } } else { let mut am: CodeActiveModel = row.into(); am.used_at = Set(Some(now)); let updated = am.update(db).await?; VerifyCodeResult { code_row_id: updated.id, } }; kv.delete(&attempts_key).await.map_err(map_redis)?; Ok(result) }