use crate::errors::{CoreError, CoreResult};
use crate::password::verify_password;
use crate::time::SharedClock;
use chrono::{DateTime, Duration, Utc};
use sui_id_store::models::{AuditLogRow, SessionRow};
use sui_id_store::repos::{audit, credentials, sessions, users};
use sui_id_store::Database;
use sui_id_shared::ids::{SessionId, UserId};
const SESSION_LIFETIME_HOURS: i64 = 12;
const DUMMY_PHC: &str =
"$argon2id$v=19$m=65536,t=2,p=1$c2FsdHNhbHRzYWx0$ZHVtbXloYXNoZHVtbXloYXNoZHVtbXloYXNoZHVtbQ";
pub fn lockout_backoff(failures: i64, max_secs: i64) -> Option<Duration> {
let secs: i64 = match failures {
..=2 => return None,
3 => 30,
4 => 60,
5 => 5 * 60,
6 => 30 * 60,
7 => 2 * 60 * 60,
8 => 6 * 60 * 60,
9 => 12 * 60 * 60,
_ => 24 * 60 * 60,
};
Some(Duration::seconds(secs.min(max_secs)))
}
fn record_login_failure(db: &Database, clock: &SharedClock, username: &str, reason: &str) {
let _ = audit::append(
db,
&AuditLogRow {
at: clock.now(),
actor: None,
action: "auth.login.failure".into(),
target: Some(username.to_owned()),
result: "denied".into(),
note: Some(reason.to_owned()),
},
);
}
fn record_login_success(db: &Database, clock: &SharedClock, user_id: UserId) {
let _ = audit::append(
db,
&AuditLogRow {
at: clock.now(),
actor: Some(user_id),
action: "auth.login.success".into(),
target: Some(user_id.to_string()),
result: "ok".into(),
note: None,
},
);
}
pub fn login(
db: &Database,
clock: &SharedClock,
username: &str,
password: &str,
max_lockout_secs: i64,
) -> CoreResult<SessionRow> {
match login_with_mfa(db, clock, username, password, max_lockout_secs)? {
LoginOutcome::SessionEstablished(row) => Ok(row),
LoginOutcome::MfaRequired { .. } => Err(CoreError::Unauthenticated),
}
}
pub enum LoginOutcome {
SessionEstablished(SessionRow),
MfaRequired {
pending: sui_id_store::models::LoginPendingMfaRow,
},
}
pub fn login_with_mfa(
db: &Database,
clock: &SharedClock,
username: &str,
password: &str,
max_lockout_secs: i64,
) -> CoreResult<LoginOutcome> {
let user = match users::find_by_username(db, username) {
Ok(u) => u,
Err(sui_id_store::StoreError::NotFound) => {
let _ = verify_password(password, DUMMY_PHC);
record_login_failure(db, clock, username, "unknown user");
return Err(CoreError::InvalidCredentials);
}
Err(e) => return Err(e.into()),
};
if user.is_disabled || user.is_deleted {
let _ = verify_password(password, DUMMY_PHC);
record_login_failure(db, clock, username, "user disabled or deleted");
return Err(CoreError::InvalidCredentials);
}
if let Some(locked_until) = user.locked_until {
if locked_until > clock.now() {
let _ = verify_password(password, DUMMY_PHC);
record_login_failure(db, clock, username, "account locked");
return Err(CoreError::InvalidCredentials);
}
}
let cred = credentials::get(db, user.id).map_err(|e| match e {
sui_id_store::StoreError::NotFound => CoreError::InvalidCredentials,
other => other.into(),
})?;
if let Err(e) = verify_password(password, &cred.password_hash) {
let next_count = users::record_login_failure(db, user.id, None).unwrap_or(0);
if let Some(window) = lockout_backoff(next_count, max_lockout_secs) {
let until = clock.now() + window;
let _ = users::record_login_failure(db, user.id, Some(until));
let _ = audit::append(
db,
&AuditLogRow {
at: clock.now(),
actor: Some(user.id),
action: "auth.login.locked".into(),
target: Some(user.id.to_string()),
result: "denied".into(),
note: Some(format!(
"consecutive failures = {next_count}, locked for {} s",
window.num_seconds()
)),
},
);
} else {
record_login_failure(db, clock, username, "wrong password");
}
return Err(e);
}
let _ = users::clear_lockout(db, user.id);
if crate::mfa::is_mfa_enabled(db, user.id)? {
let pending = crate::mfa::issue_pending_mfa(db, clock, user.id)?;
let _ = audit::append(
db,
&AuditLogRow {
at: clock.now(),
actor: Some(user.id),
action: "auth.login.password_ok_mfa_required".into(),
target: Some(user.id.to_string()),
result: "ok".into(),
note: None,
},
);
return Ok(LoginOutcome::MfaRequired { pending });
}
let now = clock.now();
let row = SessionRow {
id: SessionId::new(),
user_id: user.id,
expires_at: now + Duration::hours(SESSION_LIFETIME_HOURS),
created_at: now,
revoked_at: None,
auth_methods: vec![sui_id_shared::AuthMethod::Pwd],
last_step_up_at: None,
};
sessions::insert(db, &row)?;
record_login_success(db, clock, user.id);
Ok(LoginOutcome::SessionEstablished(row))
}
pub fn resolve(db: &Database, clock: &SharedClock, id: SessionId) -> CoreResult<UserId> {
let row = sessions::get(db, id).map_err(|e| match e {
sui_id_store::StoreError::NotFound => CoreError::Unauthenticated,
other => other.into(),
})?;
if row.revoked_at.is_some() || row.expires_at <= clock.now() {
return Err(CoreError::Unauthenticated);
}
Ok(row.user_id)
}
pub fn logout(db: &Database, id: SessionId) -> CoreResult<()> {
sessions::revoke(db, id)?;
Ok(())
}
pub fn logout_user(db: &Database, clock: &SharedClock, user_id: UserId) -> CoreResult<()> {
let _ = clock; sessions::revoke_all_for_user(db, user_id)?;
sui_id_store::repos::refresh_tokens::revoke_all_for_user(db, user_id)?;
Ok(())
}
#[cfg(test)]
mod lockout_tests {
use super::lockout_backoff;
use proptest::prelude::*;
#[test]
fn first_two_failures_yield_no_lock() {
assert_eq!(lockout_backoff(1, 24 * 60 * 60), None);
assert_eq!(lockout_backoff(2, 24 * 60 * 60), None);
}
#[test]
fn third_failure_yields_a_short_lock() {
let d = lockout_backoff(3, 24 * 60 * 60).expect("lock at 3rd failure");
assert_eq!(d.num_seconds(), 30);
}
#[test]
fn lock_window_is_capped_at_max_secs() {
let cap = 60 * 60;
for n in 9..20 {
let d = lockout_backoff(n, cap).expect("locked");
assert!(
d.num_seconds() <= cap,
"failure {n} produced {} s, exceeds cap {} s",
d.num_seconds(),
cap
);
}
}
proptest! {
#![proptest_config(ProptestConfig {
cases: 256,
..ProptestConfig::default()
})]
#[test]
fn backoff_is_monotone_in_failure_count(
cap in 1i64..(48 * 60 * 60),
a in 1i64..15,
b in 1i64..15,
) {
prop_assume!(a <= b);
let da = lockout_backoff(a, cap).map(|d| d.num_seconds()).unwrap_or(0);
let db = lockout_backoff(b, cap).map(|d| d.num_seconds()).unwrap_or(0);
prop_assert!(db >= da, "{a} -> {da}s, {b} -> {db}s");
}
#[test]
fn backoff_is_bounded_by_max_secs(
cap in 1i64..(48 * 60 * 60),
n in 1i64..50,
) {
if let Some(d) = lockout_backoff(n, cap) {
prop_assert!(d.num_seconds() <= cap);
}
}
}
}