use models::users::user; use models::users::user_email_change_tickets::EmailChangeTicketStatus; use models::users::user_email_verification_codes::EmailVerificationPurpose; use models::users::{user_email_change_tickets, user_email_verification_codes, user_emails}; use sea_orm::sea_query::Expr; use sea_orm::{ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter, Set}; use serde::{Deserialize, Serialize}; use session::Session; use time::OffsetDateTime; use uuid::Uuid; use super::AuthService; use crate::errors::AuthError; use crate::share::crypto::{constant_time_eq, generate_numeric_code, sha256_b64url}; use crate::share::rate::{attempts_reset, attempts_track, rate_limit_once}; use crate::share::validate::validate_email; fn purpose_str(purpose: EmailVerificationPurpose) -> &'static str { match purpose { EmailVerificationPurpose::Signup => "signup", EmailVerificationPurpose::PasswordReset => "password_reset", EmailVerificationPurpose::AddEmail => "add_email", EmailVerificationPurpose::Reclaim => "reclaim", } } pub(crate) struct IssueCodeArtifacts { pub temp_id: Uuid, pub expires_in_secs: u64, pub code: String, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct VerifyEmailCmd { pub email: String, pub purpose: EmailVerificationPurpose, pub code: String, pub temp_id: Uuid, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct VerifyEmailResp { pub verified: bool, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ResendCodeCmd { pub email: String, pub purpose: EmailVerificationPurpose, pub pow_answer: String, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ResendCodeResp { pub sent: bool, pub temp_id: Uuid, pub expires_in_secs: u64, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ReclaimCmd { pub email: String, pub password: String, pub pow_answer: String, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ReclaimResp { pub sent: bool, pub temp_id: Uuid, pub expires_in_secs: u64, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct EmailChangeTicketDto { pub id: Uuid, pub new_email: String, pub status: String, pub created_at: OffsetDateTime, pub expires_at: OffsetDateTime, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct RequestEmailChangeCmd { pub new_email: String, pub password: String, } impl AuthService { pub(crate) async fn issue_code( &self, user_id: Uuid, email: &str, purpose: EmailVerificationPurpose, ) -> Result { let config = &self.config().auth; let lower_email = email.trim().to_lowercase(); let purpose_name = purpose_str(purpose); let rate_key = format!("auth:code_rate:{purpose_name}:{lower_email}"); rate_limit_once(self.redis(), &rate_key, config.email_code_rate_limit_secs).await?; let db = self.db(); let now = OffsetDateTime::now_utc(); let stale = user_email_verification_codes::Entity::find() .filter(user_email_verification_codes::Column::Email.eq(&lower_email)) .filter(user_email_verification_codes::Column::Purpose.eq(purpose)) .filter(user_email_verification_codes::Column::UsedAt.is_null()) .all(db.write()) .await?; for row in stale { let mut active: user_email_verification_codes::ActiveModel = row.into(); active.used_at = Set(Some(now)); let _ = active.update(db.write()).await; } let code = generate_numeric_code(config.verify_code_len); let temp_id = Uuid::now_v7(); let code_key = format!("auth:code:{purpose_name}:{temp_id}"); export::redis::set_string( self.redis(), &code_key, &code, config.verify_code_ttl_secs, ) .await?; let row = user_email_verification_codes::ActiveModel { id: Set(Uuid::now_v7()), user_id: Set(user_id), email: Set(lower_email.clone()), purpose: Set(purpose), token_hash: Set(sha256_b64url(code.as_bytes())), created_at: Set(now), expires_at: Set(now + time::Duration::seconds(config.verify_code_ttl_secs as i64)), used_at: Set(None), }; row.insert(db.write()).await?; Ok(IssueCodeArtifacts { temp_id, expires_in_secs: config.verify_code_ttl_secs, code, }) } pub async fn verify_verification_code( &self, cmd: VerifyEmailCmd, ) -> Result { let config = &self.config().auth; let purpose_name = purpose_str(cmd.purpose); let code_key = format!("auth:code:{purpose_name}:{}", cmd.temp_id); let Some(stored) = export::redis::get_string(self.redis(), &code_key).await? else { return Err(AuthError::BadRequest( "verification code expired or not found".to_string(), )); }; let attempts_key = format!("auth:code_attempts:{purpose_name}:{}", cmd.temp_id); if !attempts_track( self.redis(), &attempts_key, config.max_verify_attempts, config.verify_code_ttl_secs, ) .await? { export::redis::delete(self.redis(), &code_key).await?; return Err(AuthError::RateLimited); } let provided = cmd.code.trim().to_string(); if !constant_time_eq(stored.as_bytes(), provided.as_bytes()) { return Err(AuthError::BadRequest( "invalid verification code".to_string(), )); } export::redis::delete(self.redis(), &code_key).await?; attempts_reset(self.redis(), &attempts_key).await?; let db = self.db(); let lower_email = cmd.email.trim().to_lowercase(); let Some(row) = user_email_verification_codes::Entity::find() .filter(user_email_verification_codes::Column::Email.eq(&lower_email)) .filter(user_email_verification_codes::Column::Purpose.eq(cmd.purpose)) .filter( user_email_verification_codes::Column::TokenHash .eq(sha256_b64url(provided.as_bytes())), ) .filter(user_email_verification_codes::Column::UsedAt.is_null()) .one(db.write()) .await? else { return Err(AuthError::BadRequest( "invalid verification code".to_string(), )); }; if row.expires_at <= OffsetDateTime::now_utc() { return Err(AuthError::BadRequest( "verification code expired".to_string(), )); } let now = OffsetDateTime::now_utc(); match cmd.purpose { EmailVerificationPurpose::Signup | EmailVerificationPurpose::Reclaim => { let user_id = row.user_id; let email = row.email.clone(); let is_reclaim = cmd.purpose == EmailVerificationPurpose::Reclaim; let row = row.clone(); sea_orm::TransactionTrait::transaction(db.primary(), |txn| { Box::pin(async move { let mut active: user_email_verification_codes::ActiveModel = row.into(); active.used_at = Set(Some(now)); active.update(txn).await?; let _ = user::Entity::update_many() .col_expr(user::Column::IsActive, Expr::value(true)) .col_expr(user::Column::UpdatedAt, Expr::value(now)) .filter(user::Column::Uid.eq(user_id)) .exec(txn) .await?; let _ = user_emails::Entity::update_many() .col_expr(user_emails::Column::IsActivated, Expr::value(true)) .col_expr(user_emails::Column::ActivatedAt, Expr::value(now)) .filter(user_emails::Column::UserId.eq(user_id)) .filter(user_emails::Column::LowerEmail.eq(&email)) .exec(txn) .await?; if is_reclaim { let _ = user::Entity::update_many() .col_expr( user::Column::PasswdVersion, Expr::cust("COALESCE(passwd_version, 0) + 1"), ) .col_expr(user::Column::UpdatedAt, Expr::value(now)) .filter(user::Column::Uid.eq(user_id)) .exec(txn) .await?; } Ok::<(), DbErr>(()) }) }) .await?; } EmailVerificationPurpose::PasswordReset => { let mut active: user_email_verification_codes::ActiveModel = row.into(); active.used_at = Set(Some(now)); active.update(db.write()).await?; } EmailVerificationPurpose::AddEmail => { return Err(AuthError::BadRequest( "add_email verification is handled by the user service".to_string(), )); } } Ok(VerifyEmailResp { verified: true }) } pub async fn resend_code( &self, session: &Session, cmd: ResendCodeCmd, ) -> Result { self.verify_pow(session, &cmd.pow_answer)?; let lower_email = cmd.email.trim().to_lowercase(); let Some(email_row) = user_emails::Entity::find() .filter(user_emails::Column::LowerEmail.eq(&lower_email)) .filter(user_emails::Column::DeletedAt.is_null()) .one(self.db().read()) .await? else { return Ok(ResendCodeResp { sent: true, temp_id: Uuid::now_v7(), expires_in_secs: self.config().auth.verify_code_ttl_secs, }); }; let artifacts = self .issue_code(email_row.user_id, &lower_email, cmd.purpose) .await?; let subject = match cmd.purpose { EmailVerificationPurpose::Signup => "Verify your account", EmailVerificationPurpose::PasswordReset => "Reset your password", EmailVerificationPurpose::AddEmail => "Add your email", EmailVerificationPurpose::Reclaim => "Recover your account", }; let minutes = self.config().auth.verify_code_ttl_secs / 60; let link = build_verify_link( &self.config().web.web_base_url, &lower_email, &artifacts.temp_id.to_string(), &artifacts.code, cmd.purpose, ); let html = crate::templates::mail_verify_code(&artifacts.code, minutes, subject, link.as_deref()); let body = format!("Your verification code is: {}", artifacts.code); let sent = self .send_email(email_row.email.as_str(), subject, &body, html.0.as_str()) .await .map(|_| true) .unwrap_or_else(|error| { tracing::warn!(%error, "failed to send verification email"); false }); Ok(ResendCodeResp { sent, temp_id: artifacts.temp_id, expires_in_secs: artifacts.expires_in_secs, }) } pub async fn reclaim( &self, session: &Session, cmd: ReclaimCmd, ) -> Result { self.verify_pow(session, &cmd.pow_answer)?; let lower_email = cmd.email.trim().to_lowercase(); let placeholder = || ReclaimResp { sent: true, temp_id: Uuid::now_v7(), expires_in_secs: self.config().auth.verify_code_ttl_secs, }; let Some((email_row, Some(user))) = user_emails::Entity::find() .filter(user_emails::Column::LowerEmail.eq(&lower_email)) .filter(user_emails::Column::DeletedAt.is_null()) .find_also_related(user::Entity) .one(self.db().read()) .await? else { return Ok(placeholder()); }; if user.is_active { return Ok(placeholder()); } let stored = user .passwd_hash .as_deref() .ok_or(AuthError::InvalidCredentials)?; if !crate::share::crypto::verify_password(&cmd.password, stored) { return Err(AuthError::InvalidCredentials); } let artifacts = self .issue_code(user.uid, &lower_email, EmailVerificationPurpose::Reclaim) .await?; let minutes = self.config().auth.verify_code_ttl_secs / 60; let link = build_verify_link( &self.config().web.web_base_url, &lower_email, &artifacts.temp_id.to_string(), &artifacts.code, EmailVerificationPurpose::Reclaim, ); let html = crate::templates::mail_verify_code( &artifacts.code, minutes, "Recover your account", link.as_deref(), ); let body = format!("Recover your account with code: {}", artifacts.code); let sent = self .send_email( email_row.email.as_str(), "Recover your account", &body, html.0.as_str(), ) .await .map(|_| true) .unwrap_or_else(|error| { tracing::warn!(%error, "failed to send reclaim email"); false }); Ok(ReclaimResp { sent, temp_id: artifacts.temp_id, expires_in_secs: artifacts.expires_in_secs, }) } pub async fn request_email_change( &self, uid: Uuid, cmd: RequestEmailChangeCmd, ) -> Result { let config = &self.config().auth; let new_email = validate_email(&cmd.new_email)?; let db = self.db(); let now = OffsetDateTime::now_utc(); let Some(user) = user::Entity::find_by_id(uid).one(db.read()).await? else { return Err(AuthError::NotFound); }; let stored = user .passwd_hash .as_deref() .ok_or_else(|| AuthError::BadRequest("account has no password".to_string()))?; if !crate::share::crypto::verify_password(&cmd.password, stored) { return Err(AuthError::InvalidCredentials); } let current = user_emails::Entity::find() .filter(user_emails::Column::UserId.eq(uid)) .filter(user_emails::Column::IsPrimary.eq(true)) .one(db.read()) .await?; let current_email = current .as_ref() .map(|row| row.lower_email.clone()) .unwrap_or_default(); if current_email == new_email { return Err(AuthError::BadRequest( "new email is the same as the current email".to_string(), )); } let taken = user_emails::Entity::find() .filter(user_emails::Column::LowerEmail.eq(&new_email)) .filter(user_emails::Column::UserId.ne(uid)) .one(db.read()) .await?; if taken.is_some() { return Err(AuthError::Conflict("email is already in use".to_string())); } let rate_key = format!("auth:code_rate:email_change:{new_email}"); rate_limit_once(self.redis(), &rate_key, config.email_code_rate_limit_secs).await?; let stale = user_email_change_tickets::Entity::find() .filter(user_email_change_tickets::Column::UserId.eq(uid)) .filter(user_email_change_tickets::Column::Status.eq(EmailChangeTicketStatus::Pending)) .all(db.write()) .await?; for ticket in stale { let mut active: user_email_change_tickets::ActiveModel = ticket.into(); active.status = Set(EmailChangeTicketStatus::Cancelled); let _ = active.update(db.write()).await; } let code = generate_numeric_code(config.verify_code_len); let ticket_id = Uuid::now_v7(); let ticket = user_email_change_tickets::ActiveModel { id: Set(ticket_id), user_id: Set(uid), token_hash: Set(sha256_b64url(code.as_bytes())), new_email: Set(new_email.clone()), original_email: Set(current.map(|row| row.email)), status: Set(EmailChangeTicketStatus::Pending), created_at: Set(now), expires_at: Set(now + time::Duration::seconds(config.verify_code_ttl_secs as i64)), used_at: Set(None), }; ticket.insert(db.write()).await?; let expires_at = now + time::Duration::seconds(config.verify_code_ttl_secs as i64); let base_url = &self.config().web.web_base_url; let html = crate::templates::mail_email_change_confirm( &new_email, &ticket_id.to_string(), &code, base_url, ); let body = format!("Your email change code is: {code}"); if let Err(error) = self .send_email(&new_email, "Confirm your new email", &body, html.0.as_str()) .await { tracing::warn!(%error, "failed to send email change code"); } Ok(EmailChangeTicketDto { id: ticket_id, new_email, status: "pending".to_string(), created_at: now, expires_at, }) } pub async fn confirm_email_change( &self, uid: Uuid, ticket_id: Uuid, code: &str, ) -> Result<(), AuthError> { let config = &self.config().auth; let attempts_key = format!("auth:email_change_attempts:{ticket_id}"); if !attempts_track( self.redis(), &attempts_key, config.max_verify_attempts, config.verify_code_ttl_secs, ) .await? { return Err(AuthError::RateLimited); } let db = self.db(); let now = OffsetDateTime::now_utc(); let Some(ticket) = user_email_change_tickets::Entity::find() .filter(user_email_change_tickets::Column::Id.eq(ticket_id)) .filter(user_email_change_tickets::Column::UserId.eq(uid)) .filter(user_email_change_tickets::Column::Status.eq(EmailChangeTicketStatus::Pending)) .one(db.write()) .await? else { return Err(AuthError::BadRequest( "email change ticket not found".to_string(), )); }; if ticket.expires_at <= now { return Err(AuthError::BadRequest( "email change ticket expired".to_string(), )); } let code_hash = sha256_b64url(code.trim().as_bytes()); if !constant_time_eq(code_hash.as_bytes(), ticket.token_hash.as_bytes()) { return Err(AuthError::BadRequest( "invalid email change code".to_string(), )); } attempts_reset(self.redis(), &attempts_key).await?; let conflicting = user_emails::Entity::find() .filter(user_emails::Column::LowerEmail.eq(ticket.new_email.to_lowercase())) .filter(user_emails::Column::UserId.ne(uid)) .one(db.write()) .await?; if conflicting.is_some() { return Err(AuthError::Conflict("email is already in use".to_string())); } let new_email = ticket.new_email.clone(); let new_lower = new_email.to_lowercase(); sea_orm::TransactionTrait::transaction(db.primary(), |txn| { Box::pin(async move { let _ = user_emails::Entity::update_many() .col_expr(user_emails::Column::IsPrimary, Expr::value(false)) .filter(user_emails::Column::UserId.eq(uid)) .filter(user_emails::Column::IsPrimary.eq(true)) .exec(txn) .await?; let existing = user_emails::Entity::find() .filter(user_emails::Column::UserId.eq(uid)) .filter(user_emails::Column::LowerEmail.eq(&new_lower)) .one(txn) .await?; if let Some(row) = existing { let mut active: user_emails::ActiveModel = row.into(); active.is_activated = Set(true); active.is_primary = Set(true); active.activated_at = Set(Some(now)); active.updated_at = Set(now); active.update(txn).await?; } else { let row = user_emails::ActiveModel { id: Set(Uuid::now_v7()), user_id: Set(uid), email: Set(new_email.clone()), lower_email: Set(new_lower.clone()), is_activated: Set(true), is_primary: Set(true), created_at: Set(now), updated_at: Set(now), activated_at: Set(Some(now)), deleted_at: Set(None), }; row.insert(txn).await?; } let mut active: user_email_change_tickets::ActiveModel = ticket.into(); active.status = Set(EmailChangeTicketStatus::Completed); active.used_at = Set(Some(now)); active.update(txn).await?; Ok::<(), DbErr>(()) }) }) .await?; Ok(()) } pub async fn cancel_email_change(&self, uid: Uuid, ticket_id: Uuid) -> Result<(), AuthError> { let db = self.db(); let Some(ticket) = user_email_change_tickets::Entity::find() .filter(user_email_change_tickets::Column::Id.eq(ticket_id)) .filter(user_email_change_tickets::Column::UserId.eq(uid)) .filter(user_email_change_tickets::Column::Status.eq(EmailChangeTicketStatus::Pending)) .one(db.write()) .await? else { return Err(AuthError::NotFound); }; let mut active: user_email_change_tickets::ActiveModel = ticket.into(); active.status = Set(EmailChangeTicketStatus::Cancelled); active.update(db.write()).await?; Ok(()) } } /// Verification links must carry the one-time `code` and the `purpose` slug: /// the web page confirms by submitting both, and defaults `purpose` to /// `signup` when it is absent, which would reject non-signup flows. pub(super) fn build_verify_link( base_url: &str, email: &str, temp_id: &str, code: &str, purpose: EmailVerificationPurpose, ) -> Option { if base_url.is_empty() { return None; } let base = base_url.trim_end_matches('/'); Some(format!( "{base}/auth/verify-email?email={}&temp_id={}&code={}&purpose={}", percent_encode(email), percent_encode(temp_id), percent_encode(code), purpose_str(purpose), )) } fn percent_encode(input: &str) -> String { let mut out = String::with_capacity(input.len()); for &b in input.as_bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { out.push(b as char); } _ => { out.push('%'); out.push_str(&format!("{b:02X}")); } } } out }