Skip to main content

doido_auth/
lockable.rs

1//! `lockable` module — locks an account after repeated failed sign-ins and
2//! auto-unlocks after `auth.unlock_in` seconds. Operates on the conventional
3//! `users` columns (`failed_attempts`, `locked_at`) via backend-agnostic SQL,
4//! gated at runtime by `auth.modules`. Email-based unlock is a follow-up; the
5//! time-based unlock strategy needs no mailer.
6
7use crate::config::AuthModule;
8use crate::error::AuthError;
9use crate::state::try_global;
10use doido_model::sea_orm::{ConnectionTrait, DatabaseConnection, DbBackend, Statement, Value};
11
12struct Settings {
13    maximum_attempts: u32,
14    unlock_in: i64,
15}
16
17/// The lockable settings when the module is enabled, else `None`.
18fn settings() -> Option<Settings> {
19    let state = try_global()?;
20    if !state.config.has_module(AuthModule::Lockable) {
21        return None;
22    }
23    Some(Settings {
24        maximum_attempts: state.config.maximum_attempts,
25        unlock_in: state.config.unlock_in,
26    })
27}
28
29async fn locked_at(db: &DatabaseConnection, email: &str) -> Result<Option<String>, AuthError> {
30    let row = db
31        .query_one_raw(Statement::from_sql_and_values(
32            DbBackend::Sqlite,
33            "SELECT locked_at FROM users WHERE email = ?",
34            [Value::from(email.to_string())],
35        ))
36        .await
37        .map_err(|e| AuthError::Internal(e.to_string()))?;
38    match row {
39        Some(row) => Ok(row
40            .try_get::<Option<String>>("", "locked_at")
41            .map_err(|e| AuthError::Internal(e.to_string()))?),
42        None => Ok(None),
43    }
44}
45
46async fn exec(db: &DatabaseConnection, sql: &str, email: &str) -> Result<(), AuthError> {
47    db.execute_raw(Statement::from_sql_and_values(
48        DbBackend::Sqlite,
49        sql,
50        [Value::from(email.to_string())],
51    ))
52    .await
53    .map_err(|e| AuthError::Internal(e.to_string()))?;
54    Ok(())
55}
56
57/// Reject a sign-in attempt for a locked account. Auto-unlocks (and allows the
58/// attempt) once `unlock_in` seconds have elapsed since `locked_at`. No-op when
59/// the module is disabled.
60pub async fn ensure_not_locked(db: &DatabaseConnection, email: &str) -> Result<(), AuthError> {
61    let settings = match settings() {
62        Some(s) => s,
63        None => return Ok(()),
64    };
65    let locked = match locked_at(db, email).await? {
66        Some(ts) => ts,
67        None => return Ok(()),
68    };
69    let locked_time = chrono::DateTime::parse_from_rfc3339(&locked)
70        .map(|t| t.with_timezone(&chrono::Utc))
71        .map_err(|e| AuthError::Internal(e.to_string()))?;
72    if (chrono::Utc::now() - locked_time).num_seconds() >= settings.unlock_in {
73        // Lock window elapsed — auto-unlock and let the attempt proceed.
74        unlock(db, email).await?;
75        Ok(())
76    } else {
77        Err(AuthError::AccountLocked)
78    }
79}
80
81/// Record a failed sign-in: increment `failed_attempts` and lock the account
82/// (stamp `locked_at`) once it reaches `maximum_attempts`. No-op when disabled.
83pub async fn record_failure(db: &DatabaseConnection, email: &str) -> Result<(), AuthError> {
84    let settings = match settings() {
85        Some(s) => s,
86        None => return Ok(()),
87    };
88    exec(
89        db,
90        "UPDATE users SET failed_attempts = failed_attempts + 1 WHERE email = ?",
91        email,
92    )
93    .await?;
94
95    let row = db
96        .query_one_raw(Statement::from_sql_and_values(
97            DbBackend::Sqlite,
98            "SELECT failed_attempts FROM users WHERE email = ?",
99            [Value::from(email.to_string())],
100        ))
101        .await
102        .map_err(|e| AuthError::Internal(e.to_string()))?;
103    let attempts = match row {
104        Some(row) => row
105            .try_get::<i32>("", "failed_attempts")
106            .map_err(|e| AuthError::Internal(e.to_string()))?,
107        None => return Ok(()),
108    };
109
110    if attempts as u32 >= settings.maximum_attempts {
111        db.execute_raw(Statement::from_sql_and_values(
112            DbBackend::Sqlite,
113            "UPDATE users SET locked_at = ? WHERE email = ?",
114            [
115                Value::from(chrono::Utc::now().to_rfc3339()),
116                Value::from(email.to_string()),
117            ],
118        ))
119        .await
120        .map_err(|e| AuthError::Internal(e.to_string()))?;
121    }
122    Ok(())
123}
124
125/// Clear the failed-attempt counter and lock on a successful sign-in. No-op when
126/// the module is disabled.
127pub async fn reset_attempts(db: &DatabaseConnection, email: &str) -> Result<(), AuthError> {
128    if settings().is_none() {
129        return Ok(());
130    }
131    unlock(db, email).await
132}
133
134async fn unlock(db: &DatabaseConnection, email: &str) -> Result<(), AuthError> {
135    exec(
136        db,
137        "UPDATE users SET failed_attempts = 0, locked_at = NULL WHERE email = ?",
138        email,
139    )
140    .await
141}