gitdataai/libs/service/auth/email.rs
2026-04-15 09:08:09 +08:00

186 lines
6.5 KiB
Rust

use crate::AppService;
use crate::error::AppError;
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use models::users::{user_email, user_email_change, user_password};
use sea_orm::*;
use serde::{Deserialize, Serialize};
use session::Session;
use uuid::Uuid;
#[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<String>,
}
impl AppService {
/// Get the current email address for the authenticated user.
pub async fn auth_get_email(&self, ctx: &Session) -> Result<EmailResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let email = user_email::Entity::find()
.filter(user_email::Column::User.eq(user_uid))
.one(&self.db)
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
Ok(EmailResponse {
email: email.map(|e| e.email),
})
}
/// Request an email change: validates password, stores a pending token,
/// and sends a verification email to the new address.
pub async fn auth_email_change_request(
&self,
ctx: &Session,
params: EmailChangeRequest,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
// Verify password
let password = self.auth_rsa_decode(ctx, params.password).await?;
let user_password = user_password::Entity::find()
.filter(user_password::Column::User.eq(user_uid))
.one(&self.db)
.await
.ok()
.flatten()
.ok_or(AppError::UserNotFound)?;
let hash =
PasswordHash::new(&user_password.password_hash).map_err(|_| AppError::UserNotFound)?;
Argon2::default()
.verify_password(password.as_bytes(), &hash)
.map_err(|_| AppError::InvalidPassword)?;
// Check new email is not already taken
let existing = user_email::Entity::find()
.filter(user_email::Column::Email.eq(&params.new_email))
.one(&self.db)
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
if existing.is_some() {
return Err(AppError::EmailExists);
}
// Generate token and store pending change
let token = self.generate_reset_token();
let expires_at = chrono::Utc::now() + chrono::Duration::hours(24);
let _ = user_email_change::Entity::delete_many()
.filter(user_email_change::Column::UserUid.eq(user_uid))
.filter(user_email_change::Column::Used.eq(false))
.exec(&self.db)
.await;
user_email_change::ActiveModel {
token: Set(token.clone()),
user_uid: Set(user_uid),
new_email: Set(params.new_email.clone()),
expires_at: Set(expires_at),
used: Set(false),
created_at: Set(chrono::Utc::now()),
}
.insert(&self.db)
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
// Queue verification email via Redis Stream
let domain = self
.config
.main_domain()
.map_err(|_| AppError::DoMainNotSet)?;
let verify_link = format!("https://{}/auth/verify-email?token={}", domain, token);
let envelope = queue::EmailEnvelope {
id: Uuid::new_v4(),
to: params.new_email.clone(),
subject: "Confirm Email Change".to_string(),
body: format!(
"You have requested to change your email address.\n\n\
Please click the link below to confirm:\n\n{}\n\n\
This link will expire in 24 hours.\n\n\
If you did not request this change, please ignore this email.",
verify_link
),
created_at: chrono::Utc::now(),
};
self.queue_producer
.publish_email(envelope)
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
slog::info!(self.logs, "Email change verification queued"; "new_email" => %params.new_email, "user_uid" => %user_uid);
Ok(())
}
/// Verify an email change token and apply the new email.
pub async fn auth_email_verify(&self, params: EmailVerifyRequest) -> Result<(), AppError> {
let change = user_email_change::Entity::find()
.filter(user_email_change::Column::Token.eq(&params.token))
.filter(user_email_change::Column::Used.eq(false))
.one(&self.db)
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?
.ok_or(AppError::NotFound("Invalid or expired token".to_string()))?;
// Check expiry
if change.expires_at < chrono::Utc::now() {
return Err(AppError::NotFound("Token has expired".to_string()));
}
// Update or insert the new email in user_email
let existing_email = user_email::Entity::find()
.filter(user_email::Column::User.eq(change.user_uid))
.one(&self.db)
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
match existing_email {
Some(email_model) => {
let mut active: user_email::ActiveModel = email_model.into();
active.email = Set(change.new_email.clone());
active.update(&self.db).await
}
None => {
user_email::ActiveModel {
user: Set(change.user_uid),
email: Set(change.new_email.clone()),
created_at: Set(chrono::Utc::now()),
}
.insert(&self.db)
.await
}
}
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
// Mark token as used
let new_email = change.new_email.clone();
let user_uid = change.user_uid;
let mut used_change: user_email_change::ActiveModel = change.into();
used_change.used = Set(true);
used_change
.update(&self.db)
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
slog::info!(self.logs, "Email changed successfully"; "new_email" => %new_email, "user_uid" => %user_uid);
Ok(())
}
}