use models::users::{user, user_emails}; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set}; use serde::{Deserialize, Serialize}; use session::Session; use time::OffsetDateTime; use uuid::Uuid; use super::AuthService; use crate::errors::AuthError; use crate::share::crypto::{ constant_time_eq, hash_password, random_token, sha256_b64url, validate_password, }; use crate::share::rate::rate_limit_once; #[derive(Serialize, Deserialize)] struct StoredResetToken { token_hash: String, email: String, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ForgotPasswordCmd { pub email: String, pub pow_answer: String, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ForgotPasswordResp { pub sent: bool, pub temp_id: Uuid, pub expires_in_secs: u64, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ResetPasswordCmd { pub email: String, pub token: String, pub new_password: String, pub temp_id: Uuid, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ChangePasswordCmd { pub old_password: String, pub new_password: String, } impl AuthService { pub async fn forgot_password( &self, session: &Session, cmd: ForgotPasswordCmd, ) -> Result { self.verify_pow(session, &cmd.pow_answer)?; let config = &self.config().auth; let lower_email = cmd.email.trim().to_lowercase(); let rate_key = format!("auth:code_rate:forgot_pwd:{lower_email}"); rate_limit_once(self.redis(), &rate_key, config.email_code_rate_limit_secs).await?; let temp_id = Uuid::now_v7(); let Some(email_row) = user_emails::Entity::find() .filter(user_emails::Column::LowerEmail.eq(&lower_email)) .filter(user_emails::Column::DeletedAt.is_null()) .one(self.db().read()) .await? else { return Ok(ForgotPasswordResp { sent: true, temp_id, expires_in_secs: config.reset_token_ttl_secs, }); }; let token = random_token(config.reset_token_bytes); let reset_key = format!("auth:reset:{temp_id}"); let stored = StoredResetToken { token_hash: sha256_b64url(token.as_bytes()), email: lower_email.clone(), }; export::redis::set_string( self.redis(), &reset_key, &serde_json::to_string(&stored) .map_err(|error| AuthError::Internal(format!("encode reset token: {error}")))?, config.reset_token_ttl_secs, ) .await?; let base_url = &self.config().web.web_base_url; let html = crate::templates::mail_reset_link(&lower_email, &token, &temp_id.to_string(), base_url); let body = format!("Use this reset token within the next minutes: {token}"); let sent = self .send_email( email_row.email.as_str(), "Reset your password", &body, html.0.as_str(), ) .await .map(|_| true) .unwrap_or_else(|error| { tracing::warn!(%error, "failed to send reset email"); false }); Ok(ForgotPasswordResp { sent, temp_id, expires_in_secs: config.reset_token_ttl_secs, }) } pub async fn reset_password(&self, cmd: ResetPasswordCmd) -> Result<(), AuthError> { let config = &self.config().auth; validate_password(&cmd.new_password, &config.password_policy)?; let reset_key = format!("auth:reset:{}", cmd.temp_id); let Some(raw) = export::redis::getdel_string(self.redis(), &reset_key).await? else { return Err(AuthError::BadRequest( "reset token expired or not found".to_string(), )); }; let stored: StoredResetToken = serde_json::from_str(&raw) .map_err(|error| AuthError::Internal(format!("decode reset token: {error}")))?; let provided = cmd.token.trim(); let lower_email = cmd.email.trim().to_lowercase(); let valid = constant_time_eq( stored.token_hash.as_bytes(), sha256_b64url(provided.as_bytes()).as_bytes(), ) && stored.email == lower_email; if !valid { return Err(AuthError::BadRequest("invalid reset token".to_string())); } let db = self.db(); let Some((_email_row, Some(user))) = user_emails::Entity::find() .filter(user_emails::Column::LowerEmail.eq(&lower_email)) .filter(user_emails::Column::DeletedAt.is_null()) .find_also_related(user::Entity) .one(db.write()) .await? else { return Err(AuthError::BadRequest("invalid reset token".to_string())); }; let password_hash = hash_password(&cmd.new_password)?; let now = OffsetDateTime::now_utc(); let next_version = user.passwd_version.unwrap_or(0) + 1; let mut active = user.into_active_model(); active.passwd_hash = Set(Some(password_hash)); active.passwd_version = Set(Some(next_version)); active.password_last_changed_at = Set(Some(now)); active.updated_at = Set(now); active.update(db.write()).await?; Ok(()) } pub async fn change_password( &self, session: &Session, uid: Uuid, cmd: ChangePasswordCmd, ) -> Result<(), AuthError> { let config = &self.config().auth; validate_password(&cmd.new_password, &config.password_policy)?; let db = self.db(); let Some(user) = user::Entity::find_by_id(uid).one(db.write()).await? else { return Err(AuthError::NotFound); }; let stored = user .passwd_hash .as_deref() .ok_or_else(|| AuthError::BadRequest("account has no password".to_string()))?; if !crate::share::crypto::verify_password(&cmd.old_password, stored) { return Err(AuthError::InvalidCredentials); } let password_hash = hash_password(&cmd.new_password)?; let now = OffsetDateTime::now_utc(); let next_version = user.passwd_version.unwrap_or(0) + 1; let mut active = user.into_active_model(); active.passwd_hash = Set(Some(password_hash)); active.passwd_version = Set(Some(next_version)); active.password_last_changed_at = Set(Some(now)); active.updated_at = Set(now); active.update(db.write()).await?; session::share::session::apply_pwd_version(session, next_version); Ok(()) } }