use argon2::{Argon2, PasswordHash, password_hash::PasswordVerifier}; use db::sqlx; use email::EmailMessage; use model::users::{UserEmailModel, user_pass::UserPasswordModel}; use serde::{Deserialize, Serialize}; use session::Session; use crate::{AppService, error::AppError}; #[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)] struct PendingEmailChange { user_uid: uuid::Uuid, new_email: String, } impl AppService { const EMAIL_CHANGE_PREFIX: &'static str = "auth:email_change:"; pub async fn auth_get_email( &self, ctx: &Session, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let email = sqlx::query_as::<_, UserEmailModel>( "SELECT \"user\", email, created_at, active, last_use_login, updated_at \ FROM user_email WHERE \"user\" = $1 AND active = true", ) .bind(user_uid) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; 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 password = self.auth_rsa_decode(ctx, params.password).await?; let user_password = sqlx::query_as::<_, UserPasswordModel>( "SELECT \"user\", hash, salt, is_active, reason, created_at, updated_at \ FROM user_password WHERE \"user\" = $1 AND is_active = true", ) .bind(user_uid) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .ok_or(AppError::UserNotFound)?; let hash = PasswordHash::new(&user_password.hash) .map_err(|_| AppError::UserNotFound)?; Argon2::default() .verify_password(password.as_bytes(), &hash) .map_err(|_| AppError::InvalidPassword)?; let existing = sqlx::query_as::<_, UserEmailModel>( "SELECT \"user\", email, created_at, active, last_use_login, updated_at \ FROM user_email WHERE email = $1 AND active = true", ) .bind(¶ms.new_email) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; if existing.is_some() { return Err(AppError::EmailExists); } let token = Self::generate_email_change_token(); let cache_key = format!("{}{}", Self::EMAIL_CHANGE_PREFIX, token); self.cache .set( &cache_key, &PendingEmailChange { user_uid, new_email: params.new_email.clone(), }, ) .await .map_err(|e| AppError::InternalServerError(e.to_string()))?; let domain = self .config .main_domain() .map_err(|_| AppError::DoMainNotSet)?; let verify_link = format!("{}/auth/verify-email?token={}", domain, token); self.email .send(EmailMessage { to: params.new_email.clone(), subject: "Confirm Email Change".to_string(), body: format!( "You requested to change your GitDataAI email address.\n\n\ Confirm the change here:\n\n{}\n\n\ If you did not request this change, ignore this email.", verify_link ), }) .await .map_err(|e| { tracing::error!(error = %e, new_email = %params.new_email, "Failed to queue email change verification"); AppError::InternalServerError(e.to_string()) })?; tracing::info!(new_email = %params.new_email, user_uid = %user_uid, "Email change verification queued"); 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".to_string(), )); } let cache_key = format!("{}{}", Self::EMAIL_CHANGE_PREFIX, params.token); let pending = self .cache .get::(&cache_key) .await .map_err(|e| AppError::InternalServerError(e.to_string()))? .ok_or(AppError::NotFound( "invalid or expired email verification token".to_string(), ))?; let existing = sqlx::query_as::<_, UserEmailModel>( "SELECT \"user\", email, created_at, active, last_use_login, updated_at \ FROM user_email WHERE email = $1 AND active = true", ) .bind(&pending.new_email) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; if existing.is_some() { return Err(AppError::EmailExists); } let now = chrono::Utc::now(); let mut txn = self.db.begin().await.map_err(|_| AppError::TxnError)?; sqlx::query("UPDATE user_email SET active = false, updated_at = $1 WHERE \"user\" = $2") .bind(now) .bind(pending.user_uid) .execute(&mut **txn.inner_mut()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; sqlx::query( "INSERT INTO user_email (\"user\", email, created_at, active, last_use_login, updated_at) \ VALUES ($1, $2, $3, true, NULL, $3)", ) .bind(pending.user_uid) .bind(&pending.new_email) .bind(now) .execute(&mut **txn.inner_mut()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; txn.commit().await.map_err(|_| AppError::TxnError)?; let _ = self.cache.remove(&cache_key).await; tracing::info!(new_email = %pending.new_email, user_uid = %pending.user_uid, "Email changed successfully"); Ok(()) } fn generate_email_change_token() -> String { use rand::{RngExt, distr::Alphanumeric}; #[allow(deprecated)] let mut rng = rand::rng(); let token: String = (0..64).map(|_| rng.sample(Alphanumeric) as char).collect(); format!("emc_{}", token) } }