198 lines
6.8 KiB
Rust
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(¶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::<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)
|
|
}
|
|
}
|