use argon2::{Argon2, PasswordHash, password_hash::PasswordVerifier}; use serde::{Deserialize, Serialize}; use sqlx::Row; use std::time::Duration; use crate::error::AppError; use crate::models::users::UserMail; use crate::pb::email::{EmailAddress, SendEmailRequest}; use crate::service::AuthService; use crate::session::Session; #[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] pub struct EmailChangeRequest { pub new_email: String, pub password: String, } #[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] pub struct EmailVerifyRequest { pub token: String, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct EmailResponse { pub email: Option, } #[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] struct PendingEmailChange { user_uid: uuid::Uuid, new_email: String, } impl AuthService { const EMAIL_CHANGE_PREFIX: &'static str = "auth:email_change:"; const EMAIL_CHANGE_TTL_SECS: u64 = 60 * 60; const EMAIL_CHANGE_COOLDOWN_SECS: u64 = 60; pub async fn auth_get_email(&self, ctx: &Session) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let email = sqlx::query_as::<_, UserMail>( "SELECT id, user_id, email, is_primary, is_verified, \ verification_token_hash, verified_at, created_at, updated_at \ FROM user_mail WHERE user_id = $1 AND is_verified = true", ) .bind(user_uid) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)?; Ok(EmailResponse { email: email.map(|e| e.email), }) } pub async fn auth_email_change_request( &self, ctx: &Session, params: EmailChangeRequest, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let new_email = params.new_email.trim().to_lowercase(); if new_email.is_empty() { return Err(AppError::BadRequest("email is required".into())); } // Rate limiting: check cooldown let cooldown_key = format!("{}cooldown:{}", Self::EMAIL_CHANGE_PREFIX, user_uid); if self.ctx.cache.exists(&cooldown_key).await { return Err(AppError::BadRequest( "email change request was sent recently; please try again later".into(), )); } let password = self.auth_rsa_decode(ctx, params.password).await?; let row = sqlx::query("SELECT password_hash FROM user_password WHERE user_id = $1") .bind(user_uid) .fetch_optional(self.ctx.db.writer()) .await .map_err(AppError::Database)? .ok_or(AppError::UserNotFound)?; let hash: String = row.try_get("password_hash").map_err(AppError::Database)?; let password_hash = PasswordHash::new(&hash).map_err(|_| AppError::UserNotFound)?; Argon2::default() .verify_password(password.as_bytes(), &password_hash) .map_err(|_| AppError::InvalidPassword)?; let existing = sqlx::query_as::<_, UserMail>( "SELECT id, user_id, email, is_primary, is_verified, \ verification_token_hash, verified_at, created_at, updated_at \ FROM user_mail WHERE lower(email) = lower($1) AND is_verified = true", ) .bind(&new_email) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)?; if existing.is_some() { return Err(AppError::EmailExists); } let token = super::generate_token("emc"); let cache_key = format!("{}{}", Self::EMAIL_CHANGE_PREFIX, token); self.ctx .cache .set( &cache_key, &PendingEmailChange { user_uid, new_email: new_email.clone(), }, Some(Duration::from_secs(Self::EMAIL_CHANGE_TTL_SECS)), ) .await .map_err(|e| AppError::InternalServerError(e.to_string()))?; let domain = self.ctx.config.main_domain()?; let verify_link = format!("{}/auth/verify-email?token={}", domain, token); let mut mail = self .ctx .registry .get_email_client() .ok_or(AppError::Config("mail service not available".into()))?; mail.send_email(tonic::Request::new(SendEmailRequest { to: vec![EmailAddress { email: new_email.clone(), name: String::new(), }], subject: "Confirm Email Change".into(), text_body: format!( "You requested to change your email address.\n\n\ Confirm the change here:\n\n{}\n\n\ If you did not request this change, ignore this email.", verify_link ), ..Default::default() })) .await .map_err(|e| { tracing::error!(error = %e, new_email = %new_email, "Failed to send email change verification"); AppError::InternalServerError(e.to_string()) })?; // Set cooldown after successful email send let cooldown_key = format!("{}cooldown:{}", Self::EMAIL_CHANGE_PREFIX, user_uid); if let Err(e) = self.ctx.cache.set( &cooldown_key, &true, Some(Duration::from_secs(Self::EMAIL_CHANGE_COOLDOWN_SECS)), ).await { tracing::warn!(error = %e, "Failed to set email change cooldown"); } tracing::info!(new_email = %new_email, user_uid = %user_uid, "Email change verification sent"); Ok(()) } pub async fn auth_email_verify(&self, params: EmailVerifyRequest) -> Result<(), AppError> { if params.token.is_empty() { return Err(AppError::BadRequest( "missing email verification token".into(), )); } let cache_key = format!("{}{}", Self::EMAIL_CHANGE_PREFIX, params.token); let pending = self.ctx .cache .get::(&cache_key) .await .ok_or(AppError::NotFound( "invalid or expired email verification token".into(), ))?; let existing = sqlx::query_as::<_, UserMail>( "SELECT id, user_id, email, is_primary, is_verified, \ verification_token_hash, verified_at, created_at, updated_at \ FROM user_mail WHERE lower(email) = lower($1) AND is_verified = true", ) .bind(&pending.new_email) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)?; if existing.is_some() { return Err(AppError::EmailExists); } let now = chrono::Utc::now(); let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; sqlx::query("UPDATE user_mail SET is_verified = false, updated_at = $1 WHERE user_id = $2") .bind(now) .bind(pending.user_uid) .execute(&mut *txn) .await .map_err(AppError::Database)?; sqlx::query( "INSERT INTO user_mail (id, user_id, email, is_primary, is_verified, created_at, updated_at) \ VALUES ($1, $2, $3, true, true, $4, $4)", ) .bind(uuid::Uuid::now_v7()) .bind(pending.user_uid) .bind(&pending.new_email) .bind(now) .execute(&mut *txn) .await .map_err(AppError::Database)?; txn.commit().await.map_err(|_| AppError::TxnError)?; let _ = self.ctx.cache.delete(&cache_key).await; tracing::info!(new_email = %pending.new_email, user_uid = %pending.user_uid, "Email changed"); Ok(()) } }