use crate::errors::{CoreError, CoreResult};
use crate::password::verify_password;
use crate::time::SharedClock;
use chrono::Duration;
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)))
}
async 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()),
},
).await;
}
async 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,
},
).await;
}
pub async 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).await? {
LoginOutcome::SessionEstablished(row) => Ok(row),
LoginOutcome::MfaRequired { .. } => Err(CoreError::Unauthenticated),
}
}
pub enum LoginOutcome {
SessionEstablished(SessionRow),
MfaRequired {
pending: sui_id_store::models::LoginPendingMfaRow,
},
}
pub async 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).await {
Ok(u) => u,
Err(sui_id_store::StoreError::NotFound) => {
let _ = verify_password(password, DUMMY_PHC);
record_login_failure(db, clock, username, "unknown user").await;
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").await;
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").await;
return Err(CoreError::InvalidCredentials);
}
}
let cred = credentials::get(db, user.id).await.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).await.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)).await;
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()
)),
},
).await;
} else {
record_login_failure(db, clock, username, "wrong password").await;
}
return Err(e);
}
let _ = users::clear_lockout(db, user.id).await;
if crate::mfa::is_mfa_enabled(db, user.id).await? {
let pending = crate::mfa::issue_pending_mfa(db, clock, user.id).await?;
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,
},
).await;
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,
last_used_at: None,
};
sessions::insert(db, &row).await?;
enforce_concurrent_session_cap(db, clock, user.id).await;
record_login_success(db, clock, user.id).await;
Ok(LoginOutcome::SessionEstablished(row))
}
pub(crate) async fn enforce_concurrent_session_cap(
db: &Database,
clock: &SharedClock,
user_id: UserId,
) {
let settings = match sui_id_store::repos::server_settings::get(db).await {
Ok(s) => s,
Err(_) => return,
};
let cap = settings.max_concurrent_sessions;
if cap <= 0 {
return;
}
let now = clock.now();
let count = match sessions::count_active_for_user(db, user_id, now).await {
Ok(c) => c,
Err(_) => return,
};
if count <= cap {
return;
}
let evict = count - cap;
let oldest = match sessions::oldest_active_for_user(db, user_id, now, evict).await {
Ok(rows) => rows,
Err(_) => return,
};
for old in oldest {
let _ = sessions::revoke(db, old.id).await;
}
}
pub async fn resolve(db: &Database, clock: &SharedClock, id: SessionId) -> CoreResult<UserId> {
let row = sessions::get(db, id).await.map_err(|e| match e {
sui_id_store::StoreError::NotFound => CoreError::Unauthenticated,
other => other.into(),
})?;
let now = clock.now();
if row.revoked_at.is_some() || row.expires_at <= now {
return Err(CoreError::Unauthenticated);
}
if let Ok(settings) = sui_id_store::repos::server_settings::get(db).await {
let timeout = settings.idle_session_timeout_secs;
if timeout > 0 {
let reference = row.last_used_at.unwrap_or(row.created_at);
let elapsed = (now - reference).num_seconds();
if elapsed > timeout {
let _ = sessions::revoke(db, id).await;
return Err(CoreError::Unauthenticated);
}
}
}
Ok(row.user_id)
}
pub async fn touch_last_used(
db: &Database,
clock: &SharedClock,
id: SessionId,
) -> CoreResult<()> {
let now = clock.now();
let row = match sessions::get(db, id).await {
Ok(r) => r,
Err(sui_id_store::StoreError::NotFound) => return Ok(()),
Err(other) => return Err(other.into()),
};
let stale = match row.last_used_at {
Some(t) => (now - t).num_seconds() >= LAST_USED_AT_THROTTLE_SECS,
None => true,
};
if stale {
sessions::touch_last_used(db, id, now).await?;
}
Ok(())
}
pub const LAST_USED_AT_THROTTLE_SECS: i64 = 60;
pub async fn logout(db: &Database, id: SessionId) -> CoreResult<()> {
sessions::revoke(db, id).await?;
Ok(())
}
pub async fn logout_user(db: &Database, clock: &SharedClock, user_id: UserId) -> CoreResult<()> {
let _ = clock; sessions::revoke_all_for_user(db, user_id).await?;
sui_id_store::repos::refresh_tokens::revoke_all_for_user(db, user_id).await?;
Ok(())
}
#[cfg(test)]
mod lockout_tests {
use super::lockout_backoff;
use proptest::prelude::*;
#[tokio::test]
async 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);
}
#[tokio::test]
async 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);
}
#[tokio::test]
async 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);
}
}
}
}
#[cfg(test)]
mod session_limit_tests {
use super::*;
use crate::time::{system_clock, MockClock, SharedClock};
use chrono::{Duration as ChronoDuration, TimeZone, Utc};
use sui_id_store::crypto::MasterKey;
use sui_id_store::Database;
fn fresh_db() -> Database {
Database::open_in_memory(MasterKey::generate()).expect("db")
}
async fn make_user(db: &Database) -> UserId {
use sui_id_store::models::UserRow;
use sui_id_store::repos::users;
let id = UserId::new();
let now = Utc::now();
users::create(
db,
&UserRow {
id,
username: "alice".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("user");
id
}
async fn insert_session(
db: &Database,
user_id: UserId,
created_at: chrono::DateTime<Utc>,
last_used_at: Option<chrono::DateTime<Utc>>,
) -> SessionId {
let id = SessionId::new();
sessions::insert(
db,
&SessionRow {
id,
user_id,
expires_at: created_at + ChronoDuration::hours(24),
created_at,
revoked_at: None,
auth_methods: vec![sui_id_shared::AuthMethod::Pwd],
last_step_up_at: None,
last_used_at,
},
).await
.expect("insert");
id
}
#[tokio::test]
async fn resolve_passes_when_idle_timeout_disabled() {
let db = fresh_db();
let clock = system_clock();
let uid = make_user(&db).await;
let stale = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
let sid = insert_session(&db, uid, Utc::now(), Some(stale)).await;
assert_eq!(resolve(&db, &clock, sid).await.expect("resolve"), uid);
}
#[tokio::test]
async fn resolve_revokes_after_idle_window() {
let db = fresh_db();
let uid = make_user(&db).await;
sui_id_store::repos::server_settings::update_idle_session_timeout(
&db,
60,
Utc::now(),
).await
.expect("set timeout");
let now = Utc::now();
let stale = now - ChronoDuration::seconds(120);
let sid = insert_session(&db, uid, now - ChronoDuration::hours(1), Some(stale)).await;
let clock: SharedClock = std::sync::Arc::new(MockClock::at(now));
assert!(matches!(
resolve(&db, &clock, sid).await,
Err(CoreError::Unauthenticated)
));
let row = sessions::get(&db, sid).await.expect("get");
assert!(row.revoked_at.is_some());
}
#[tokio::test]
async fn resolve_passes_within_idle_window() {
let db = fresh_db();
let uid = make_user(&db).await;
sui_id_store::repos::server_settings::update_idle_session_timeout(
&db,
300,
Utc::now(),
).await
.expect("set timeout");
let now = Utc::now();
let recent = now - ChronoDuration::seconds(10);
let sid = insert_session(&db, uid, now - ChronoDuration::hours(1), Some(recent)).await;
let clock: SharedClock = std::sync::Arc::new(MockClock::at(now));
assert_eq!(resolve(&db, &clock, sid).await.expect("resolve"), uid);
}
#[tokio::test]
async fn resolve_treats_null_last_used_at_as_created_at() {
let db = fresh_db();
let uid = make_user(&db).await;
sui_id_store::repos::server_settings::update_idle_session_timeout(
&db,
60,
Utc::now(),
).await
.expect("set timeout");
let now = Utc::now();
let sid = insert_session(&db, uid, now - ChronoDuration::seconds(120), None).await;
let clock: SharedClock = std::sync::Arc::new(MockClock::at(now));
assert!(matches!(
resolve(&db, &clock, sid).await,
Err(CoreError::Unauthenticated)
));
}
#[tokio::test]
async fn touch_last_used_throttles_within_window() {
let db = fresh_db();
let uid = make_user(&db).await;
let now = Utc::now();
let original = now - ChronoDuration::seconds(10);
let sid = insert_session(&db, uid, now - ChronoDuration::hours(1), Some(original)).await;
let clock: SharedClock = std::sync::Arc::new(MockClock::at(now));
touch_last_used(&db, &clock, sid).await.expect("touch");
let row = sessions::get(&db, sid).await.expect("get");
assert_eq!(row.last_used_at, Some(original));
}
#[tokio::test]
async fn touch_last_used_writes_when_stale() {
let db = fresh_db();
let uid = make_user(&db).await;
let now = Utc::now();
let stale = now - ChronoDuration::seconds(120);
let sid = insert_session(&db, uid, now - ChronoDuration::hours(1), Some(stale)).await;
let clock: SharedClock = std::sync::Arc::new(MockClock::at(now));
touch_last_used(&db, &clock, sid).await.expect("touch");
let row = sessions::get(&db, sid).await.expect("get");
let updated = row.last_used_at.expect("set");
assert!(updated > stale);
}
#[tokio::test]
async fn enforce_cap_does_nothing_when_cap_zero() {
let db = fresh_db();
let clock = system_clock();
let uid = make_user(&db).await;
for i in 0..5 {
let _ = insert_session(
&db,
uid,
Utc::now() - ChronoDuration::seconds(i),
None,
).await;
}
enforce_concurrent_session_cap(&db, &clock, uid).await;
let active =
sessions::count_active_for_user(&db, uid, Utc::now()).await.expect("count");
assert_eq!(active, 5);
}
#[tokio::test]
async fn enforce_cap_evicts_oldest_in_fifo_order() {
let db = fresh_db();
let clock = system_clock();
let uid = make_user(&db).await;
sui_id_store::repos::server_settings::update_max_concurrent_sessions(
&db,
2,
Utc::now(),
).await
.expect("set cap");
let base = Utc::now() - ChronoDuration::hours(1);
let s1 = insert_session(&db, uid, base, None).await;
let s2 = insert_session(&db, uid, base + ChronoDuration::seconds(1), None).await;
let s3 = insert_session(&db, uid, base + ChronoDuration::seconds(2), None).await;
let s4 = insert_session(&db, uid, base + ChronoDuration::seconds(3), None).await;
enforce_concurrent_session_cap(&db, &clock, uid).await;
assert!(sessions::get(&db, s1).await.expect("get").revoked_at.is_some(), "s1 should be revoked");
assert!(sessions::get(&db, s2).await.expect("get").revoked_at.is_some(), "s2 should be revoked");
assert!(sessions::get(&db, s3).await.expect("get").revoked_at.is_none(), "s3 should remain");
assert!(sessions::get(&db, s4).await.expect("get").revoked_at.is_none(), "s4 should remain");
}
}