186 lines
6.5 KiB
Rust
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(¶ms.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(¶ms.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(())
|
|
}
|
|
}
|