use crate::errors::CoreResult;
use crate::time::SharedClock;
use crate::webauthn;
use chrono::{DateTime, Duration};
use sui_id_shared::ids::{SessionId, UserId};
use sui_id_store::repos::{sessions, user_totp};
use sui_id_store::Database;
pub const STEP_UP_FRESHNESS_SECS: i64 = 300;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepUpDecision {
Allow,
Challenge,
}
pub fn policy_for_session(
db: &Database,
clock: &SharedClock,
user_id: UserId,
last_step_up_at: Option<DateTime<chrono::Utc>>,
freshness_secs: i64,
) -> CoreResult<StepUpDecision> {
if !user_has_mfa(db, user_id)? {
return Ok(StepUpDecision::Allow);
}
let last = match last_step_up_at {
Some(t) => t,
None => return Ok(StepUpDecision::Challenge),
};
let cutoff = clock.now() - Duration::seconds(freshness_secs);
if last >= cutoff {
Ok(StepUpDecision::Allow)
} else {
Ok(StepUpDecision::Challenge)
}
}
pub fn user_has_mfa(db: &Database, user_id: UserId) -> CoreResult<bool> {
let totp = user_totp::get(db, user_id)?
.map(|r| r.enabled)
.unwrap_or(false);
if totp {
return Ok(true);
}
let has_passkey = webauthn::has_credentials(db, user_id)?;
Ok(has_passkey)
}
pub fn touch_step_up(
db: &Database,
clock: &SharedClock,
session_id: SessionId,
) -> CoreResult<()> {
sessions::touch_step_up(db, session_id, clock.now())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::password;
use chrono::Utc;
use sui_id_shared::ids::UserId;
use sui_id_store::crypto::MasterKey;
use sui_id_store::models::{CredentialRow, UserRow};
use sui_id_store::repos::{credentials, user_totp, users};
fn fresh_db() -> Database {
Database::open_in_memory(MasterKey::generate()).expect("db")
}
fn create_user(db: &Database) -> UserId {
let id = UserId::new();
let now = Utc::now();
users::create(
db,
&UserRow {
id,
username: "u".into(),
display_name: None,
is_admin: false,
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,
},
)
.expect("create user");
let phc = password::hash_password("the-tester-password").expect("hash");
credentials::upsert(
db,
&CredentialRow {
user_id: id,
password_hash: phc,
must_change: false,
updated_at: now,
},
)
.expect("cred");
id
}
fn enrol_totp(db: &Database, user_id: UserId) {
user_totp::upsert_pending(db, user_id, b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09")
.expect("upsert pending");
user_totp::confirm_with_recovery(db, user_id, b"[]").expect("confirm");
}
#[test]
fn user_with_no_mfa_is_always_allowed() {
let db = fresh_db();
let clock = crate::time::system_clock();
let uid = create_user(&db);
let r = policy_for_session(&db, &clock, uid, None, STEP_UP_FRESHNESS_SECS).unwrap();
assert_eq!(r, StepUpDecision::Allow);
let r = policy_for_session(
&db,
&clock,
uid,
Some(Utc::now() - Duration::days(7)),
STEP_UP_FRESHNESS_SECS,
)
.unwrap();
assert_eq!(r, StepUpDecision::Allow);
}
#[test]
fn mfa_user_with_no_step_up_must_challenge() {
let db = fresh_db();
let clock = crate::time::system_clock();
let uid = create_user(&db);
enrol_totp(&db, uid);
let r = policy_for_session(&db, &clock, uid, None, STEP_UP_FRESHNESS_SECS).unwrap();
assert_eq!(r, StepUpDecision::Challenge);
}
#[test]
fn mfa_user_with_fresh_step_up_is_allowed() {
let db = fresh_db();
let clock = crate::time::system_clock();
let uid = create_user(&db);
enrol_totp(&db, uid);
let now = clock.now();
let r = policy_for_session(&db, &clock, uid, Some(now), STEP_UP_FRESHNESS_SECS).unwrap();
assert_eq!(r, StepUpDecision::Allow);
}
#[test]
fn mfa_user_with_stale_step_up_must_challenge_again() {
let db = fresh_db();
let clock = crate::time::system_clock();
let uid = create_user(&db);
enrol_totp(&db, uid);
let stale = clock.now() - Duration::seconds(STEP_UP_FRESHNESS_SECS + 60);
let r = policy_for_session(&db, &clock, uid, Some(stale), STEP_UP_FRESHNESS_SECS).unwrap();
assert_eq!(r, StepUpDecision::Challenge);
}
#[test]
fn touch_step_up_updates_session_row() {
use sui_id_store::models::SessionRow;
let db = fresh_db();
let clock = crate::time::system_clock();
let uid = create_user(&db);
let session_id = SessionId::new();
let now = clock.now();
sessions::insert(
&db,
&SessionRow {
id: session_id,
user_id: uid,
expires_at: now + Duration::hours(8),
created_at: now,
revoked_at: None,
auth_methods: vec![sui_id_shared::AuthMethod::Pwd],
last_step_up_at: None,
},
)
.expect("insert");
touch_step_up(&db, &clock, session_id).expect("touch");
let row = sessions::get(&db, session_id).expect("get");
assert!(row.last_step_up_at.is_some());
}
}