use base64::Engine; use time::OffsetDateTime; use uuid::Uuid; use db::conn::sea_orm::{ ActiveModelTrait, EntityTrait, QueryFilter, Set, TransactionTrait, }; use db::user::user_emails::Entity as EmailEntity; use db::user::user_password_reset_tokens::{ ActiveModel as TokenActiveModel, Entity as TokenEntity, }; use db::user::users::{ActiveModel as UserActiveModel, Entity as UserEntity}; use cache::redis::RedisError; use jwt::session as jwt_session; use crate::error::AuthError; use crate::features::AuthService; use crate::features::password_hash; use super::{ResetPasswordCmd, ResetPasswordResp}; fn map_redis(e: RedisError) -> AuthError { AuthError::Internal(format!("redis: {e}")) } fn sha256_hex(data: &[u8]) -> String { use sha2::Digest; let digest = sha2::Sha256::digest(data); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest) } fn redis_key(temp_id: &Uuid) -> String { format!("auth:reset:{}", temp_id.simple()) } fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; } let mut diff: u8 = 0; for (x, y) in a.iter().zip(b.iter()) { diff |= x ^ y; } diff == 0 } /// Reset a user's password using a token previously delivered via the /// forgot-password flow. On success, every active session for the user is /// revoked so the new credentials take effect immediately. pub async fn reset_password( cmd: ResetPasswordCmd, svc: &AuthService, ) -> Result { password_hash::validate_password( password_hash::ValidatePasswordRequest { password: &cmd.new_password, }, &svc.config.password_policy, )?; let db = svc.db.writer(); let temp_id = Uuid::parse_str(&cmd.temp_key_value)?; let key = redis_key(&temp_id); let stored = svc .kv .get_bytes(&key) .await .map_err(map_redis)? .ok_or_else(|| AuthError::BadRequest("reset token expired or not found".into()))?; if !constant_time_eq(stored.as_slice(), cmd.token.as_bytes()) { return Err(AuthError::BadRequest("invalid reset token".into())); } svc.kv.delete(&key).await.map_err(map_redis)?; let token_hash = sha256_hex(cmd.token.as_bytes()); let row = TokenEntity::find() .filter(TokenEntity::COLUMN.token_hash.eq(token_hash)) .filter(TokenEntity::COLUMN.used_at.is_null()) .one(&db) .await? .ok_or_else(|| AuthError::BadRequest("reset token not found".into()))?; if row.expires_at < OffsetDateTime::now_utc() { return Err(AuthError::BadRequest("reset token expired".into())); } // Resolve user via email; if the user was deleted out-of-band we hard-fail. let lower = cmd.email.trim().to_ascii_lowercase(); let (em, user) = EmailEntity::find() .filter(EmailEntity::COLUMN.lower_email.eq(lower)) .find_also_related(UserEntity) .one(&db) .await? .ok_or_else(|| AuthError::NotFound("user not found".into()))?; if em.user_id != row.user_id { return Err(AuthError::BadRequest( "reset token does not match email".into(), )); } let user = user.ok_or_else(|| AuthError::NotFound("user not found".into()))?; let new_hash = password_hash::usecase::hash_password(password_hash::PasswordInput { password: &cmd.new_password, })?; // VULN-006/B5: a reset by the real owner of the email also activates an // account that was registered but never verified (email squatting). This // is the forgot-password recovery lane for unclaimed addresses. let activate = !user.is_active; let txn = db.begin().await?; let mut am: UserActiveModel = user.into(); let current_version = match &am.passwd_version { db::conn::sea_orm::ActiveValue::Set(Some(v)) => *v, _ => 0, }; let new_version = current_version + 1; am.passwd_hash = Set(Some(new_hash.0)); am.password_last_changed_at = Set(Some(OffsetDateTime::now_utc())); am.passwd_version = Set(Some(new_version)); if activate { am.is_active = Set(true); } am.updated_at = Set(OffsetDateTime::now_utc()); am.update(&txn).await?; let mut tam: TokenActiveModel = row.into(); tam.used_at = Set(Some(OffsetDateTime::now_utc())); tam.update(&txn).await?; // Revoke every existing session atomically with the password change. // A failure here aborts the transaction, preventing the caller from // receiving a success response while stale refresh tokens remain live. jwt_session::delete_sessions_by_user(&txn, em.user_id).await?; txn.commit().await?; // VULN-005: put the user on the access-token deny-list so any already-issued // stateless access tokens stop validating immediately after the password // change (the DB sessions were revoked inside the transaction above). let ttl = svc.jwt.settings.access_ttl.whole_seconds().max(1) as u64; rate_limit::AccessTokenBlacklist::new(svc.kv.clone()) .revoke_user(em.user_id, ttl) .await .map_err(map_redis)?; Ok(ResetPasswordResp { ok: true }) }