use crate::errors::{CoreError, CoreResult};
use crate::hibp::{self, HibpClient, HibpEnforcement};
use crate::password;
use crate::time::SharedClock;
use chrono::Utc;
use sui_id_shared::ids::{SessionId, UserId};
use sui_id_store::models::{AuditLogRow, CredentialRow, HibpMode};
use sui_id_store::repos::{audit, credentials, refresh_tokens, sessions};
use sui_id_store::Database;
#[derive(Debug, Clone, Copy)]
pub struct PasswordChangeReport {
pub sessions_revoked: usize,
pub refresh_tokens_revoked: usize,
pub hibp_warned: bool,
}
pub async fn change_password_self(
db: &Database,
clock: &SharedClock,
hibp_client: Option<&dyn HibpClient>,
hibp_mode: HibpMode,
user_id: UserId,
current_password: &str,
new_password: &str,
keep_current_session: Option<SessionId>,
revoke_others: bool,
) -> CoreResult<PasswordChangeReport> {
let row = credentials::get(db, user_id).await.map_err(|e| match e {
sui_id_store::StoreError::NotFound => CoreError::InvalidCredentials,
other => CoreError::from(other),
})?;
password::verify_password(current_password, &row.password_hash)?;
password::check_password_policy(new_password)?;
let hibp_warned = match hibp::enforce_hibp(hibp_mode, hibp_client, new_password).await {
HibpEnforcement::Blocked { .. } => {
return Err(CoreError::BadRequest(
"New password found in known data breaches. Please choose a different password.".into(),
));
}
HibpEnforcement::AllowedWithWarning { .. } => true,
_ => false,
};
let new_phc = password::hash_password(new_password)?;
credentials::upsert(
db,
&CredentialRow {
user_id,
password_hash: new_phc,
must_change: false,
updated_at: Utc::now(),
},
).await?;
let mut report = PasswordChangeReport {
sessions_revoked: 0,
refresh_tokens_revoked: 0,
hibp_warned,
};
if revoke_others {
report.sessions_revoked = match keep_current_session {
Some(keep) => sessions::revoke_all_for_user_except(db, user_id, keep).await?,
None => sessions::revoke_all_for_user(db, user_id).await?,
};
report.refresh_tokens_revoked = refresh_tokens::revoke_all_for_user(db, user_id).await?;
}
let _ = audit::append(
db,
&AuditLogRow {
at: clock.now(),
actor: Some(user_id),
action: "auth.password.changed_self".into(),
target: Some(user_id.to_string()),
result: "ok".into(),
note: Some(format!(
"sessions_revoked={} refresh_tokens_revoked={}",
report.sessions_revoked, report.refresh_tokens_revoked
)),
},
).await;
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
use sui_id_shared::ids::UserId;
use sui_id_store::crypto::MasterKey;
use sui_id_store::models::UserRow;
use sui_id_store::repos::users;
fn fresh_db() -> Database {
Database::open_in_memory(MasterKey::generate()).expect("db")
}
async fn create_user_with_password(db: &Database, password: &str) -> UserId {
let id = UserId::new();
let now = Utc::now();
users::create(
db,
&UserRow {
id,
username: "alice".into(),
display_name: None,
is_admin: true,
role: if true { sui_id_store::models::Role::Admin } else { sui_id_store::models::Role::User },
is_disabled: false,
is_deleted: false,
user_uuid: uuid::Uuid::new_v4(),
created_at: now,
updated_at: now,
failed_login_count: 0,
locked_until: None,
email: None,
preferred_lang: None,
email_normalized: None,
email_verified_at: None,
},
).await
.expect("create user");
let phc = password::hash_password(password).expect("hash");
credentials::upsert(
db,
&CredentialRow {
user_id: id,
password_hash: phc,
must_change: false,
updated_at: now,
},
).await
.expect("set credential");
id
}
#[tokio::test]
async fn happy_path_replaces_hash_and_returns_zero_sweep_when_box_unchecked() {
let db = fresh_db();
let clock = crate::time::system_clock();
let uid = create_user_with_password(&db, "the-old-tester-password").await;
let r = change_password_self(
&db,
&clock,
None,
sui_id_store::models::HibpMode::Off,
uid,
"the-old-tester-password",
"the-new-tester-password",
None,
false,
).await
.expect("change");
assert_eq!(r.sessions_revoked, 0);
assert_eq!(r.refresh_tokens_revoked, 0);
let stored = credentials::get(&db, uid).await.expect("cred").password_hash;
assert!(password::verify_password("the-old-tester-password", &stored).is_err());
assert!(password::verify_password("the-new-tester-password", &stored).is_ok());
}
#[tokio::test]
async fn wrong_current_password_is_rejected_as_invalid_credentials() {
let db = fresh_db();
let clock = crate::time::system_clock();
let uid = create_user_with_password(&db, "the-old-tester-password").await;
let r = change_password_self(
&db,
&clock,
None,
sui_id_store::models::HibpMode::Off,
uid,
"wrong-current-tester-password",
"the-new-tester-password",
None,
false,
).await;
assert!(matches!(r, Err(CoreError::InvalidCredentials)));
let stored = credentials::get(&db, uid).await.expect("cred").password_hash;
assert!(password::verify_password("the-old-tester-password", &stored).is_ok());
}
#[tokio::test]
async fn weak_new_password_is_rejected_after_current_is_verified() {
let db = fresh_db();
let clock = crate::time::system_clock();
let uid = create_user_with_password(&db, "the-old-tester-password").await;
let r = change_password_self(
&db,
&clock,
None,
sui_id_store::models::HibpMode::Off,
uid,
"the-old-tester-password",
"short",
None,
false,
).await;
assert!(matches!(r, Err(CoreError::BadRequest(_))), "{r:?}");
let stored = credentials::get(&db, uid).await.expect("cred").password_hash;
assert!(password::verify_password("the-old-tester-password", &stored).is_ok());
}
#[tokio::test]
async fn must_change_flag_is_reset_on_self_change() {
let db = fresh_db();
let clock = crate::time::system_clock();
let uid = create_user_with_password(&db, "the-old-tester-password").await;
let phc = password::hash_password("the-old-tester-password").expect("hash");
credentials::upsert(
&db,
&CredentialRow {
user_id: uid,
password_hash: phc,
must_change: true,
updated_at: Utc::now(),
},
).await
.expect("upsert");
change_password_self(
&db,
&clock,
None,
sui_id_store::models::HibpMode::Off,
uid,
"the-old-tester-password",
"the-new-tester-password",
None,
false,
).await
.expect("change");
let row = credentials::get(&db, uid).await.expect("cred");
assert!(!row.must_change, "must_change should be cleared");
}
#[tokio::test]
async fn audit_event_is_appended() {
let db = fresh_db();
let clock = crate::time::system_clock();
let uid = create_user_with_password(&db, "the-old-tester-password").await;
change_password_self(
&db,
&clock,
None,
sui_id_store::models::HibpMode::Off,
uid,
"the-old-tester-password",
"the-new-tester-password",
None,
false,
).await
.expect("change");
let rows = audit::recent(&db, 50).await.expect("audit");
assert!(
rows.iter().any(|r| r.action == "auth.password.changed_self"),
"expected auth.password.changed_self in audit log; got: {:?}",
rows.iter().map(|r| r.action.as_str()).collect::<Vec<_>>()
);
}
}