189 lines
6.4 KiB
Rust
189 lines
6.4 KiB
Rust
use crate::AppService;
|
|
use crate::error::AppError;
|
|
use argon2::password_hash::{Salt, SaltString};
|
|
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
|
use models::users::{user_activity_log, user_password, user_password_reset};
|
|
use rand::RngExt;
|
|
use sea_orm::*;
|
|
use serde::{Deserialize, Serialize};
|
|
use session::Session;
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
pub struct ChangePasswordParams {
|
|
pub old_password: String,
|
|
pub new_password: String,
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
pub struct ResetPasswordParams {
|
|
pub email: String,
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
pub struct ConfirmResetPasswordParams {
|
|
pub token: String,
|
|
pub new_password: String,
|
|
}
|
|
|
|
impl AppService {
|
|
pub async fn auth_change_password(
|
|
&self,
|
|
context: &Session,
|
|
params: ChangePasswordParams,
|
|
) -> Result<(), AppError> {
|
|
let user_uid = context.user().ok_or(AppError::Unauthorized)?;
|
|
|
|
let old_password = self.auth_rsa_decode(context, params.old_password).await?;
|
|
let new_password = self.auth_rsa_decode(context, params.new_password).await?;
|
|
|
|
Self::validate_password_strength(&new_password)?;
|
|
|
|
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 password_hash =
|
|
PasswordHash::new(&user_password.password_hash).map_err(|_| AppError::UserNotFound)?;
|
|
|
|
Argon2::default()
|
|
.verify_password(old_password.as_bytes(), &password_hash)
|
|
.map_err(|_| AppError::UserNotFound)?;
|
|
|
|
let salt = SaltString::generate(&mut rsa::rand_core::OsRng::default());
|
|
let new_password_hash = Argon2::default()
|
|
.hash_password(new_password.as_bytes(), Salt::from_b64(&*salt.to_string())?)
|
|
.map_err(|_| AppError::UserNotFound)?
|
|
.to_string();
|
|
|
|
let mut active_password: user_password::ActiveModel = user_password.into();
|
|
active_password.password_hash = Set(new_password_hash);
|
|
active_password.password_salt = Set(Some(salt.to_string()));
|
|
|
|
active_password
|
|
.update(&self.db)
|
|
.await
|
|
.map_err(|_| AppError::UserNotFound)?;
|
|
|
|
slog::info!(self.logs, "Password changed"; "user_uid" => %user_uid, "ip" => context.ip_address());
|
|
let _ = user_activity_log::ActiveModel {
|
|
user_uid: Set(Option::from(user_uid)),
|
|
action: Set("password_change".to_string()),
|
|
ip_address: Set(context.ip_address()),
|
|
user_agent: Set(context.user_agent()),
|
|
details: Set(serde_json::json!({
|
|
"method": "change_password"
|
|
})),
|
|
created_at: Set(chrono::Utc::now()),
|
|
..Default::default()
|
|
}
|
|
.insert(&self.db)
|
|
.await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn auth_request_password_reset(
|
|
&self,
|
|
params: ResetPasswordParams,
|
|
) -> Result<(), AppError> {
|
|
let user = self.utils_find_user_by_email(params.email.clone()).await?;
|
|
|
|
let token = self.generate_reset_token();
|
|
|
|
let expires_at = chrono::Utc::now() + chrono::Duration::hours(1);
|
|
|
|
let _ = user_password_reset::Entity::delete_many()
|
|
.filter(user_password_reset::Column::UserUid.eq(user.uid))
|
|
.filter(user_password_reset::Column::Used.eq(false))
|
|
.exec(&self.db)
|
|
.await;
|
|
|
|
let reset_token = user_password_reset::ActiveModel {
|
|
token: Set(token.clone()),
|
|
user_uid: Set(user.uid),
|
|
expires_at: Set(expires_at),
|
|
used: Set(false),
|
|
created_at: Set(chrono::Utc::now()),
|
|
};
|
|
|
|
reset_token
|
|
.insert(&self.db)
|
|
.await
|
|
.map_err(|_| AppError::UserNotFound)?;
|
|
|
|
let domain = self
|
|
.config
|
|
.main_domain()
|
|
.map_err(|_| AppError::DoMainNotSet)?;
|
|
|
|
let email_address = params.email.clone();
|
|
let reset_link = format!("https://{}/auth/reset-password?token={}", domain, token);
|
|
|
|
let envelope = queue::EmailEnvelope {
|
|
id: Uuid::new_v4(),
|
|
to: email_address.clone(),
|
|
subject: "Password Reset Request".to_string(),
|
|
body: format!(
|
|
"Hello {},\n\n\
|
|
You have requested to reset your password. Please click the link below to reset your password:\n\n\
|
|
{}\n\n\
|
|
This link will expire in 1 hour.\n\n\
|
|
If you did not request this password reset, please ignore this email.\n\n\
|
|
Best regards,\n\
|
|
GitDataAI Team",
|
|
user.username, reset_link
|
|
),
|
|
created_at: chrono::Utc::now(),
|
|
};
|
|
|
|
self.queue_producer
|
|
.publish_email(envelope)
|
|
.await
|
|
.map_err(|_| AppError::UserNotFound)?;
|
|
|
|
slog::info!(self.logs, "Password reset email queued"; "email" => email_address);
|
|
Ok(())
|
|
}
|
|
pub fn validate_password_strength(password: &str) -> Result<(), AppError> {
|
|
if password.len() < 8 {
|
|
return Err(AppError::UserNotFound);
|
|
}
|
|
|
|
let has_uppercase = password.chars().any(|c| c.is_uppercase());
|
|
let has_lowercase = password.chars().any(|c| c.is_lowercase());
|
|
let has_digit = password.chars().any(|c| c.is_numeric());
|
|
|
|
if !has_uppercase || !has_lowercase || !has_digit {
|
|
return Err(AppError::UserNotFound);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn generate_reset_token(&self) -> String {
|
|
use rand::distr::Alphanumeric;
|
|
#[allow(deprecated)]
|
|
let mut rng = rand::rng();
|
|
let token: String = (0..64).map(|_| rng.sample(Alphanumeric) as char).collect();
|
|
format!("rst_{}", token)
|
|
}
|
|
|
|
pub async fn auth_cleanup_expired_reset_tokens(&self) -> Result<u64, AppError> {
|
|
let now = chrono::Local::now().naive_local();
|
|
|
|
let result = user_password_reset::Entity::delete_many()
|
|
.filter(user_password_reset::Column::ExpiresAt.lt(now))
|
|
.exec(&self.db)
|
|
.await
|
|
.map_err(|_| AppError::UserNotFound)?;
|
|
|
|
slog::info!(self.logs, "Expired password reset tokens cleaned up"; "count" => result.rows_affected);
|
|
Ok(result.rows_affected)
|
|
}
|
|
}
|