Skip to content
Clone
d5939db
148 lines 5.1 KB Rust
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<ResetPasswordResp, AuthError> {
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 })
}