use crate::errors::{CoreError, 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 async 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).await? {
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 async fn user_has_mfa(db: &Database, user_id: UserId) -> CoreResult<bool> {
let totp = user_totp::get(db, user_id).await?
.map(|r| r.enabled)
.unwrap_or(false);
if totp {
return Ok(true);
}
let has_passkey = webauthn::has_credentials(db, user_id).await?;
Ok(has_passkey)
}
pub async fn touch_step_up(
db: &Database,
clock: &SharedClock,
session_id: SessionId,
) -> CoreResult<()> {
sessions::touch_step_up(db, session_id, clock.now()).await?;
Ok(())
}
pub async fn verify_totp_code(
db: &Database,
clock: &SharedClock,
user_id: UserId,
session_id: SessionId,
code_input: &str,
) -> CoreResult<()> {
use crate::mfa;
use crate::totp;
use zeroize::Zeroize;
let totp_row = user_totp::get(db, user_id).await?
.ok_or(CoreError::InvalidCredentials)?;
if !totp_row.enabled {
return Err(CoreError::InvalidCredentials);
}
let trimmed = code_input.trim();
let accepted = if let Ok(digits) = trimmed.parse::<u32>() {
let mut secret = user_totp::decrypt_secret(db, &totp_row).await?;
let now = clock.now().timestamp();
let result = totp::verify(&secret, now, digits, totp_row.last_used_step).await;
secret.zeroize();
match result {
Some(step) => {
user_totp::set_last_used_step(db, user_id, step).await?;
true
}
None => false,
}
} else {
mfa::consume_recovery_code(db, user_id, &totp_row, trimmed).await?
};
if !accepted {
return Err(CoreError::InvalidCredentials);
}
touch_step_up(db, clock, session_id).await?;
Ok(())
}
pub struct WebauthnStepUpStart {
pub challenge_json: String,
pub pending_id: sui_id_shared::ids::WebauthnPendingId,
}
pub async fn start_webauthn(
db: &Database,
clock: &SharedClock,
issuer_url: &str,
user_id: UserId,
) -> CoreResult<WebauthnStepUpStart> {
use crate::webauthn;
use sui_id_store::models::{WebauthnPendingKind, WebauthnPendingRow};
use sui_id_store::repos::webauthn_pending;
let started = webauthn::start_authentication(db, clock, issuer_url, user_id).await?;
let row = webauthn_pending::get(db, started.pending_id).await?
.ok_or(CoreError::Internal)?;
let stepped = WebauthnPendingRow {
id: row.id,
kind: WebauthnPendingKind::StepUp,
user_id: row.user_id,
state_json: row.state_json,
expires_at: row.expires_at,
created_at: row.created_at,
};
webauthn_pending::delete(db, row.id).await?;
webauthn_pending::insert(db, &stepped).await?;
Ok(WebauthnStepUpStart {
challenge_json: started.challenge_json,
pending_id: started.pending_id,
})
}
pub async fn finish_webauthn(
db: &Database,
clock: &SharedClock,
issuer_url: &str,
user_id: UserId,
session_id: SessionId,
pending_id: sui_id_shared::ids::WebauthnPendingId,
credential_json: &str,
) -> CoreResult<()> {
use crate::webauthn;
use sui_id_store::models::WebauthnPendingKind;
use sui_id_store::repos::webauthn_pending;
use webauthn_rs::prelude::PublicKeyCredential;
let pending = webauthn_pending::get(db, pending_id).await?
.ok_or(CoreError::InvalidCredentials)?;
if pending.kind != WebauthnPendingKind::StepUp {
return Err(CoreError::InvalidCredentials);
}
if pending.user_id != Some(user_id) {
return Err(CoreError::InvalidCredentials);
}
let credential: PublicKeyCredential = serde_json::from_str(credential_json)
.map_err(|_| CoreError::InvalidCredentials)?;
{
use sui_id_store::models::WebauthnPendingRow;
let switched = WebauthnPendingRow {
id: pending.id,
kind: WebauthnPendingKind::Authenticate,
user_id: pending.user_id,
state_json: pending.state_json.clone(),
expires_at: pending.expires_at,
created_at: pending.created_at,
};
webauthn_pending::delete(db, pending.id).await?;
webauthn_pending::insert(db, &switched).await?;
}
match webauthn::finish_authentication(
db,
clock,
issuer_url,
pending_id,
user_id,
&credential,
).await {
Ok(()) => {
touch_step_up(db, clock, session_id).await?;
Ok(())
}
Err(_) => Err(CoreError::InvalidCredentials),
}
}
#[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")
}
async 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,
role: if false { 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("the-tester-password").expect("hash");
credentials::upsert(
db,
&CredentialRow {
user_id: id,
password_hash: phc,
must_change: false,
updated_at: now,
},
).await
.expect("cred");
id
}
async 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").await
.expect("upsert pending");
user_totp::confirm_with_recovery(db, user_id, b"[]").await.expect("confirm");
}
#[tokio::test]
async fn user_with_no_mfa_is_always_allowed() {
let db = fresh_db();
let clock = crate::time::system_clock();
let uid = create_user(&db).await;
let r = policy_for_session(&db, &clock, uid, None, STEP_UP_FRESHNESS_SECS).await.unwrap();
assert_eq!(r, StepUpDecision::Allow);
let r = policy_for_session(
&db,
&clock,
uid,
Some(Utc::now() - Duration::days(7)),
STEP_UP_FRESHNESS_SECS,
).await
.unwrap();
assert_eq!(r, StepUpDecision::Allow);
}
#[tokio::test]
async fn mfa_user_with_no_step_up_must_challenge() {
let db = fresh_db();
let clock = crate::time::system_clock();
let uid = create_user(&db).await;
enrol_totp(&db, uid).await;
let r = policy_for_session(&db, &clock, uid, None, STEP_UP_FRESHNESS_SECS).await.unwrap();
assert_eq!(r, StepUpDecision::Challenge);
}
#[tokio::test]
async fn mfa_user_with_fresh_step_up_is_allowed() {
let db = fresh_db();
let clock = crate::time::system_clock();
let uid = create_user(&db).await;
enrol_totp(&db, uid).await;
let now = clock.now();
let r = policy_for_session(&db, &clock, uid, Some(now), STEP_UP_FRESHNESS_SECS).await.unwrap();
assert_eq!(r, StepUpDecision::Allow);
}
#[tokio::test]
async 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).await;
enrol_totp(&db, uid).await;
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).await.unwrap();
assert_eq!(r, StepUpDecision::Challenge);
}
#[tokio::test]
async 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).await;
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,
last_used_at: None,
},
).await
.expect("insert");
touch_step_up(&db, &clock, session_id).await.expect("touch");
let row = sessions::get(&db, session_id).await.expect("get");
assert!(row.last_step_up_at.is_some());
}
async fn fresh_session(db: &Database, clock: &SharedClock, uid: UserId) -> SessionId {
use sui_id_store::models::SessionRow;
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,
last_used_at: None,
},
).await
.expect("insert session");
session_id
}
#[tokio::test]
async fn verify_totp_code_with_correct_code_marks_session_fresh() {
use crate::totp;
let db = fresh_db();
let clock = crate::time::system_clock();
let uid = create_user(&db).await;
let secret = b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09";
user_totp::upsert_pending(&db, uid, secret).await.expect("pending");
user_totp::confirm_with_recovery(&db, uid, b"[]").await.expect("confirm");
let session_id = fresh_session(&db, &clock, uid).await;
let now = clock.now().timestamp();
let step = now / 30;
let code = totp::code_for_step(secret, step).await;
verify_totp_code(&db, &clock, uid, session_id, &code.to_string()).await
.expect("verify ok");
let row = sessions::get(&db, session_id).await.expect("get");
assert!(row.last_step_up_at.is_some(), "session should be fresh");
}
#[tokio::test]
async fn verify_totp_code_with_wrong_code_does_not_touch_session() {
let db = fresh_db();
let clock = crate::time::system_clock();
let uid = create_user(&db).await;
let secret = b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09";
user_totp::upsert_pending(&db, uid, secret).await.expect("pending");
user_totp::confirm_with_recovery(&db, uid, b"[]").await.expect("confirm");
let session_id = fresh_session(&db, &clock, uid).await;
let result = verify_totp_code(&db, &clock, uid, session_id, "000000").await;
assert!(matches!(result, Err(crate::errors::CoreError::InvalidCredentials)));
let row = sessions::get(&db, session_id).await.expect("get");
assert!(
row.last_step_up_at.is_none(),
"session must NOT be marked fresh on a failed verify"
);
}
#[tokio::test]
async fn verify_totp_code_for_user_without_totp_returns_invalid_credentials() {
let db = fresh_db();
let clock = crate::time::system_clock();
let uid = create_user(&db).await;
let session_id = fresh_session(&db, &clock, uid).await;
let result = verify_totp_code(&db, &clock, uid, session_id, "123456").await;
assert!(matches!(result, Err(crate::errors::CoreError::InvalidCredentials)));
}
#[tokio::test]
async fn finish_webauthn_refuses_pending_with_wrong_kind() {
use sui_id_store::models::{WebauthnPendingKind, WebauthnPendingRow};
use sui_id_store::repos::webauthn_pending;
use sui_id_shared::ids::WebauthnPendingId;
let db = fresh_db();
let clock = crate::time::system_clock();
let uid = create_user(&db).await;
let session_id = fresh_session(&db, &clock, uid).await;
let pending_id = WebauthnPendingId::new();
let now = clock.now();
webauthn_pending::insert(
&db,
&WebauthnPendingRow {
id: pending_id,
kind: WebauthnPendingKind::Authenticate, user_id: Some(uid),
state_json: "{}".into(),
expires_at: now + Duration::seconds(60),
created_at: now,
},
).await
.expect("insert");
let issuer = "https://test.example";
let result = finish_webauthn(
&db,
&clock,
issuer,
uid,
session_id,
pending_id,
r#"{"id":"x","rawId":"x","type":"public-key","response":{}}"#,
).await;
assert!(matches!(result, Err(crate::errors::CoreError::InvalidCredentials)));
let still_there = webauthn_pending::get(&db, pending_id).await
.expect("query")
.expect("row preserved");
assert_eq!(still_there.kind, WebauthnPendingKind::Authenticate);
}
#[tokio::test]
async fn finish_webauthn_refuses_pending_for_other_user() {
use sui_id_store::models::{WebauthnPendingKind, WebauthnPendingRow};
use sui_id_store::repos::webauthn_pending;
use sui_id_shared::ids::WebauthnPendingId;
let db = fresh_db();
let clock = crate::time::system_clock();
let real_owner = create_user(&db).await;
let imposter = {
let id = UserId::new();
let now = Utc::now();
users::create(
&db,
&sui_id_store::models::UserRow {
id,
username: "imposter".into(),
display_name: None,
is_admin: false,
role: if false { 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("imposter");
id
};
let session_id = fresh_session(&db, &clock, imposter).await;
let pending_id = WebauthnPendingId::new();
let now = clock.now();
webauthn_pending::insert(
&db,
&WebauthnPendingRow {
id: pending_id,
kind: WebauthnPendingKind::StepUp,
user_id: Some(real_owner), state_json: "{}".into(),
expires_at: now + Duration::seconds(60),
created_at: now,
},
).await
.expect("insert");
let result = finish_webauthn(
&db,
&clock,
"https://test.example",
imposter,
session_id,
pending_id,
r#"{"id":"x","rawId":"x","type":"public-key","response":{}}"#,
).await;
assert!(matches!(result, Err(crate::errors::CoreError::InvalidCredentials)));
assert!(webauthn_pending::get(&db, pending_id).await.expect("query").is_some());
}
}