gitdataai/lib/service/auth/email.rs
2026-05-30 01:38:40 +08:00

198 lines
6.8 KiB
Rust

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<String>,
}
#[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<EmailResponse, AppError> {
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(&params.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::<PendingEmailChange>(&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)
}
}