Skip to main content

laterite_auth/
service.rs

1//! The authentication and authorization service.
2//!
3//! Composes the store, password, and permission layers into the flows the
4//! admin surface calls: `authenticate` (throttle, verify, issue session, log),
5//! `verify_session` (resolve a token to an identity), and `logout`.
6
7use std::fmt::Write as _;
8use std::time::Duration;
9
10use chrono::{DateTime, Utc};
11use rand::RngCore;
12use serde::Deserialize;
13use sha2::{Digest, Sha256};
14
15use laterite_core::{Actor, Db, Events};
16
17use crate::error::AuthError;
18use crate::events;
19use crate::models::{AccessEvent, BackendUser};
20use crate::password;
21use crate::permission::PermissionSet;
22use crate::store;
23
24/// Tunable auth policy. Loadable from a config section (all keys optional; an unset
25/// key keeps its default):
26///
27/// ```toml
28/// [auth]
29/// session_idle_timeout_secs = 7200
30/// session_absolute_timeout_secs = 43200
31/// remember_duration_secs = 1209600
32/// max_failures = 5
33/// max_failures_per_address = 20
34/// failure_window_secs = 900
35///
36/// [auth.password_policy]
37/// min_length = 8
38/// ```
39#[derive(Debug, Clone, Deserialize)]
40#[serde(default)]
41#[non_exhaustive]
42pub struct AuthConfig {
43    /// How long a session survives without a request. Activity past the halfway
44    /// mark of this window pushes the deadline out again.
45    #[serde(rename = "session_idle_timeout_secs", deserialize_with = "de_secs")]
46    pub session_idle_timeout: Duration,
47    /// The ceiling a session cannot be renewed past, counted from login. Bounds
48    /// how long a stolen token stays useful however busy the thief keeps it.
49    /// `session_ttl_secs` is the former name of this key and still reads.
50    #[serde(
51        rename = "session_absolute_timeout_secs",
52        alias = "session_ttl_secs",
53        deserialize_with = "de_secs"
54    )]
55    pub session_absolute_timeout: Duration,
56    /// How long a "stay signed in" credential lasts. It survives the session
57    /// ceiling: the point of it is to outlive the session and mint a new one.
58    #[serde(rename = "remember_duration_secs", deserialize_with = "de_secs")]
59    pub remember_duration: Duration,
60    /// Failed attempts within `failure_window`, for one username from one
61    /// address, before that address is locked out of that username. Counted
62    /// per address so a stranger cannot lock an operator out from elsewhere.
63    pub max_failures: i64,
64    /// Failed attempts within `failure_window` from one address, whatever the
65    /// usernames, before that address is locked out of signing in at all.
66    pub max_failures_per_address: i64,
67    /// The window over which failed attempts are counted.
68    #[serde(rename = "failure_window_secs", deserialize_with = "de_secs")]
69    pub failure_window: Duration,
70    /// What a new password must satisfy, wherever one is set.
71    pub password_policy: PasswordPolicy,
72}
73
74impl Default for AuthConfig {
75    fn default() -> Self {
76        Self {
77            session_idle_timeout: Duration::from_secs(60 * 60 * 2),
78            session_absolute_timeout: Duration::from_secs(60 * 60 * 12),
79            remember_duration: Duration::from_secs(60 * 60 * 24 * 14),
80            max_failures: 5,
81            max_failures_per_address: 20,
82            failure_window: Duration::from_secs(60 * 15),
83            password_policy: PasswordPolicy::default(),
84        }
85    }
86}
87
88/// The rules a new password must meet, applied wherever one is set: the
89/// Preferences form, first-run setup, and `lat admin create`. Loaded from
90/// `[auth.password_policy]`.
91#[derive(Debug, Clone, Deserialize)]
92#[serde(default)]
93#[non_exhaustive]
94pub struct PasswordPolicy {
95    /// The shortest password accepted, in characters. Default 8.
96    pub min_length: usize,
97}
98
99impl Default for PasswordPolicy {
100    fn default() -> Self {
101        Self {
102            min_length: MIN_PASSWORD_LENGTH,
103        }
104    }
105}
106
107impl PasswordPolicy {
108    /// Refuses a password that breaks a rule, with the reason as
109    /// [`AuthError::Refused`].
110    pub fn check(&self, password: &str) -> Result<(), AuthError> {
111        if password.chars().count() < self.min_length {
112            return Err(AuthError::Refused(format!(
113                "A password needs at least {} characters.",
114                self.min_length
115            )));
116        }
117        Ok(())
118    }
119}
120
121impl AuthConfig {
122    /// When a session created at `created_at` and last seen at `last_seen`
123    /// expires: the idle window measured from the last request, the ceiling
124    /// measured from login, whichever falls first.
125    fn deadline(&self, created_at: DateTime<Utc>, last_seen: DateTime<Utc>) -> DateTime<Utc> {
126        let idle = last_seen + chrono_from_std(self.session_idle_timeout);
127        let ceiling = created_at + chrono_from_std(self.session_absolute_timeout);
128        idle.min(ceiling)
129    }
130}
131
132/// Deserializes a whole-second count into a `Duration`.
133fn de_secs<'de, D: serde::Deserializer<'de>>(de: D) -> Result<Duration, D::Error> {
134    Ok(Duration::from_secs(u64::deserialize(de)?))
135}
136
137#[cfg(test)]
138mod auth_config_tests {
139    use super::{AuthConfig, MIN_PASSWORD_LENGTH};
140    use chrono::Utc;
141    use std::time::Duration;
142
143    #[test]
144    fn unset_keys_keep_defaults() {
145        let cfg: AuthConfig = serde_json::from_str(r#"{"max_failures": 3}"#).unwrap();
146        assert_eq!(cfg.max_failures, 3);
147        assert_eq!(cfg.session_idle_timeout, Duration::from_secs(60 * 60 * 2));
148        assert_eq!(
149            cfg.session_absolute_timeout,
150            Duration::from_secs(60 * 60 * 12)
151        );
152        assert_eq!(cfg.failure_window, Duration::from_secs(60 * 15));
153    }
154
155    #[test]
156    fn seconds_map_to_durations() {
157        let cfg: AuthConfig = serde_json::from_str(
158            r#"{"session_idle_timeout_secs": 1800, "session_absolute_timeout_secs": 3600,
159                 "max_failures": 7, "failure_window_secs": 120}"#,
160        )
161        .unwrap();
162        assert_eq!(cfg.session_idle_timeout, Duration::from_secs(1800));
163        assert_eq!(cfg.session_absolute_timeout, Duration::from_secs(3600));
164        assert_eq!(cfg.max_failures, 7);
165        assert_eq!(cfg.failure_window, Duration::from_secs(120));
166    }
167
168    #[test]
169    fn the_idle_window_ends_a_quiet_session_first() {
170        let cfg = AuthConfig::default();
171        let login = Utc::now();
172        // Seen just now, eleven hours into a twelve-hour ceiling: two hours idle
173        // still falls first.
174        let seen = login + chrono::Duration::hours(1);
175        assert_eq!(cfg.deadline(login, seen), seen + chrono::Duration::hours(2));
176    }
177
178    #[test]
179    fn the_ceiling_caps_a_session_kept_busy() {
180        let cfg = AuthConfig::default();
181        let login = Utc::now();
182        // Active at the eleventh hour: the idle window would reach thirteen
183        // hours, so the twelve-hour ceiling has to win.
184        let seen = login + chrono::Duration::hours(11);
185        assert_eq!(
186            cfg.deadline(login, seen),
187            login + chrono::Duration::hours(12)
188        );
189    }
190
191    #[test]
192    fn the_password_policy_reads_from_its_own_table() {
193        let cfg: AuthConfig =
194            serde_json::from_str(r#"{"password_policy": {"min_length": 12}}"#).unwrap();
195        assert_eq!(cfg.password_policy.min_length, 12);
196        assert_eq!(
197            AuthConfig::default().password_policy.min_length,
198            MIN_PASSWORD_LENGTH
199        );
200        assert!(cfg.password_policy.check("eleven-char").is_err());
201        assert!(cfg.password_policy.check("twelve-chars").is_ok());
202    }
203
204    #[test]
205    fn the_former_ttl_key_still_sets_the_ceiling() {
206        let cfg: AuthConfig = serde_json::from_str(r#"{"session_ttl_secs": 3600}"#).unwrap();
207        assert_eq!(cfg.session_absolute_timeout, Duration::from_secs(3600));
208    }
209}
210
211/// Per-request context recorded in the access log.
212#[derive(Debug, Clone, Default)]
213pub struct RequestContext {
214    pub ip_address: Option<String>,
215    pub user_agent: Option<String>,
216}
217
218/// A freshly issued session. `token` is the raw bearer value for the client
219/// cookie; only its hash is persisted.
220#[derive(Debug, Clone)]
221pub struct IssuedSession {
222    pub token: String,
223    pub expires_at: DateTime<Utc>,
224    /// Whose session it is, so the caller can issue a remember credential
225    /// without looking the user up again.
226    pub user_id: i64,
227}
228
229/// A freshly minted "stay signed in" credential. `cookie` is the raw
230/// `selector:verifier` value for the client; only the verifier's hash is kept.
231#[derive(Debug, Clone)]
232pub struct RememberCredential {
233    pub cookie: String,
234    pub expires_at: DateTime<Utc>,
235}
236
237/// What a presented remember cookie produced: a new session, and the
238/// replacement credential that must overwrite the cookie just used.
239#[derive(Debug, Clone)]
240pub struct RecalledSession {
241    pub session: IssuedSession,
242    pub remember: RememberCredential,
243}
244
245/// One of an account's live sessions, as the account's own sessions list shows
246/// it. `id` is derived from the stored key and is safe to put in a page: it
247/// names a row without being usable to authenticate as it.
248#[derive(Debug, Clone)]
249pub struct ActiveSession {
250    pub id: String,
251    pub created_at: DateTime<Utc>,
252    pub last_seen_at: DateTime<Utc>,
253    pub expires_at: DateTime<Utc>,
254    /// Where it signed in from, when the deployment could determine one.
255    pub ip_address: Option<String>,
256    /// The raw user agent as captured, for the caller to label as it likes.
257    pub user_agent: Option<String>,
258    /// Whether this is the session doing the asking.
259    pub current: bool,
260}
261
262/// An authenticated backend user together with the permissions in force.
263#[derive(Debug, Clone)]
264pub struct AuthenticatedUser {
265    pub user: BackendUser,
266    pub permissions: PermissionSet,
267}
268
269/// The acting operator as a record-layer actor, so a write attributes itself
270/// without the admin mapping it by hand.
271impl From<&AuthenticatedUser> for laterite_core::Actor {
272    fn from(user: &AuthenticatedUser) -> Self {
273        laterite_core::Actor::user(user.user.id, &user.user.username)
274    }
275}
276
277/// A resolved live session: the authenticated identity plus the opaque data
278/// blob the surface stored on it (`None` until the surface writes one). Auth
279/// does not interpret the blob.
280#[derive(Debug, Clone)]
281pub struct ResolvedSession {
282    pub identity: AuthenticatedUser,
283    pub data: Option<String>,
284    /// When the session now expires, whichever clock runs out first. The login
285    /// cookie is bounded by the ceiling, so this never outruns it.
286    pub expires_at: DateTime<Utc>,
287}
288
289impl AuthenticatedUser {
290    /// Whether this identity holds `permission`.
291    pub fn allows(&self, permission: &str) -> bool {
292        self.permissions.allows(permission)
293    }
294
295    /// Returns an error unless this identity holds `permission`.
296    pub fn require(&self, permission: &str) -> Result<(), AuthError> {
297        if self.allows(permission) {
298            Ok(())
299        } else {
300            Err(AuthError::PermissionDenied(permission.to_string()))
301        }
302    }
303}
304
305/// The details for creating a backend operator. Used by the CLI and the
306/// first-run setup screen so account creation has one code path.
307#[derive(Debug, Clone)]
308pub struct NewOperator<'a> {
309    pub username: &'a str,
310    pub email: &'a str,
311    pub first_name: &'a str,
312    pub last_name: Option<&'a str>,
313    pub password: &'a str,
314    /// The operator's display timezone (an IANA name), or `None` to inherit the
315    /// deployment default.
316    pub timezone: Option<&'a str>,
317}
318
319/// One append-only audit entry: who did what, to which target. Actions are
320/// dot-keyed (`backend.role.update`); the username is snapshotted so the entry
321/// stays legible after the actor's account is removed.
322#[non_exhaustive]
323pub struct AuditEntry<'a> {
324    /// The acting operator's id, or `None` for a system action.
325    pub actor_user_id: Option<i64>,
326    /// The acting operator's username, recorded verbatim.
327    pub actor_username: &'a str,
328    /// A dot-keyed action, e.g. `backend.plugin.disable`.
329    pub action: &'a str,
330    /// What was acted on (e.g. `backend_role`) and its id; both optional.
331    pub target_type: Option<&'a str>,
332    pub target_id: Option<&'a str>,
333    /// The target's name as operators know it (a username, a role name),
334    /// snapshotted like the actor's username.
335    pub target_label: Option<&'a str>,
336    /// Optional JSON describing the change.
337    pub detail: Option<&'a str>,
338}
339
340impl<'a> AuditEntry<'a> {
341    /// An entry for `action` by `actor`, with no target yet.
342    pub fn new(actor: &'a Actor, action: &'a str) -> Self {
343        Self {
344            actor_user_id: actor.user_id(),
345            actor_username: actor.label(),
346            action,
347            target_type: None,
348            target_id: None,
349            target_label: None,
350            detail: None,
351        }
352    }
353
354    /// What was acted on: its type (`backend_user`), its id, and its name.
355    pub fn target(mut self, kind: &'a str, id: &'a str, label: Option<&'a str>) -> Self {
356        self.target_type = Some(kind);
357        self.target_id = Some(id);
358        self.target_label = label;
359        self
360    }
361
362    /// JSON describing the change.
363    pub fn detail(mut self, detail: &'a str) -> Self {
364        self.detail = Some(detail);
365        self
366    }
367}
368
369/// What [`AuthService::purge_expired`] removed.
370#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
371#[non_exhaustive]
372pub struct Purged {
373    pub sessions: u64,
374    pub remember_tokens: u64,
375}
376
377/// The default [`PasswordPolicy::min_length`].
378pub const MIN_PASSWORD_LENGTH: usize = 8;
379
380/// The auth service. Cheap to clone: it holds a database handle, config and
381/// the event bus.
382#[derive(Clone)]
383pub struct AuthService {
384    db: Db,
385    config: AuthConfig,
386    events: Events,
387}
388
389impl AuthService {
390    /// A service announcing on a bus of its own, which nothing listens on.
391    /// [`AuthService::with_events`] hands it the application's.
392    pub fn new(db: Db, config: AuthConfig) -> Self {
393        let events = Events::new(db.clone());
394        Self { db, config, events }
395    }
396
397    /// Announces on `events`: the bus the application's listeners are on. See
398    /// [`crate::events`] for what is announced.
399    pub fn with_events(mut self, events: Events) -> Self {
400        self.events = events;
401        self
402    }
403
404    /// The bus this service announces on.
405    pub fn events(&self) -> &Events {
406        &self.events
407    }
408
409    /// How long a session stays valid. A caller that persists the session in a
410    /// cookie matches its lifetime to this, so the two cannot disagree.
411    pub fn session_absolute_timeout(&self) -> Duration {
412        self.config.session_absolute_timeout
413    }
414
415    /// Verifies a username and password, and on success issues a session.
416    ///
417    /// Failures are throttled per username and every outcome is logged. The
418    /// error deliberately does not reveal whether the username exists.
419    pub async fn authenticate(
420        &self,
421        username: &str,
422        password: &str,
423        ctx: &RequestContext,
424    ) -> Result<IssuedSession, AuthError> {
425        let now = Utc::now();
426        let since = now - chrono_from_std(self.config.failure_window);
427
428        if self.locked_out(username, ctx, since).await? {
429            self.log(None, username, AccessEvent::LockedOut, ctx)
430                .await?;
431            return Err(AuthError::TooManyAttempts);
432        }
433
434        let user = match store::find_user_by_username(&self.db, username).await? {
435            Some(user) => user,
436            None => {
437                self.log(None, username, AccessEvent::LoginFailure, ctx)
438                    .await?;
439                return Err(AuthError::InvalidCredentials);
440            }
441        };
442
443        if !password::verify_password(password, &user.password_hash)? {
444            self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
445                .await?;
446            return Err(AuthError::InvalidCredentials);
447        }
448
449        if !user.is_active {
450            self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
451                .await?;
452            return Err(AuthError::InactiveAccount);
453        }
454
455        let token = generate_token();
456        let expires_at = self.config.deadline(now, now);
457        store::insert_session(
458            &self.db,
459            &hash_token(&token),
460            user.id,
461            expires_at,
462            ctx.ip_address.as_deref(),
463            ctx.user_agent.as_deref(),
464        )
465        .await?;
466        self.log(Some(user.id), username, AccessEvent::LoginSuccess, ctx)
467            .await?;
468
469        Ok(IssuedSession {
470            token,
471            expires_at,
472            user_id: user.id,
473        })
474    }
475
476    /// Resolves a raw session token to an identity and its stored data blob,
477    /// refreshing last-seen. Expired sessions, and sessions whose user was
478    /// disabled or removed, resolve to [`AuthError::SessionInvalid`]. The blob
479    /// is read in the same query as the session, adding no round-trip.
480    pub async fn resolve_session(&self, token: &str) -> Result<ResolvedSession, AuthError> {
481        let token_hash = hash_token(token);
482        let now = Utc::now();
483
484        let session = store::find_valid_session(&self.db, &token_hash, now)
485            .await?
486            .ok_or(AuthError::SessionInvalid)?;
487        let user = store::find_active_user_by_id(&self.db, session.user_id)
488            .await?
489            .ok_or(AuthError::SessionInvalid)?;
490        // Renewing on every request would cost a write per request for a
491        // deadline that moves by seconds. Past the halfway mark of the idle
492        // window the write buys real time, so that is where it happens.
493        let idle = chrono_from_std(self.config.session_idle_timeout);
494        let expires_at = self.config.deadline(session.created_at, now);
495        if now - session.last_seen_at > idle / 2 {
496            store::renew_session(&self.db, &token_hash, now, expires_at).await?;
497        }
498
499        let grants = store::load_role_permissions(&self.db, user.id)
500            .await?
501            .into_iter()
502            .flatten();
503        // Split the user's per-permission overrides into allow (1) and deny (-1),
504        // which take precedence over the role grants.
505        let overrides = store::load_user_permission_overrides(&self.db, user.id).await?;
506        let (mut allow, mut deny) = (Vec::new(), Vec::new());
507        for (code, decision) in overrides {
508            match decision.signum() {
509                1 => allow.push(code),
510                -1 => deny.push(code),
511                _ => {}
512            }
513        }
514        let permissions = PermissionSet::with_overrides(user.is_superuser, grants, allow, deny);
515
516        Ok(ResolvedSession {
517            identity: AuthenticatedUser { user, permissions },
518            data: session.data,
519            expires_at,
520        })
521    }
522
523    /// Resolves a raw session token to an identity. A thin wrapper over
524    /// [`AuthService::resolve_session`] for callers that need only the identity.
525    pub async fn verify_session(&self, token: &str) -> Result<AuthenticatedUser, AuthError> {
526        Ok(self.resolve_session(token).await?.identity)
527    }
528
529    /// Overwrites the opaque per-session data blob (the surface's serialised
530    /// state, e.g. CSRF token + flash). The surface writes only when its blob
531    /// changed, so an unchanged request adds no write.
532    pub async fn set_session_data(&self, token: &str, data: &str) -> Result<(), AuthError> {
533        store::set_session_data(&self.db, &hash_token(token), data).await
534    }
535
536    /// Issues a "stay signed in" credential for `user_id`, one row per device.
537    pub async fn issue_remember(&self, user_id: i64) -> Result<RememberCredential, AuthError> {
538        let (selector, verifier) = (generate_token(), generate_token());
539        let expires_at = Utc::now() + chrono_from_std(self.config.remember_duration);
540        store::insert_remember_token(
541            &self.db,
542            &selector,
543            &hash_token(&verifier),
544            user_id,
545            expires_at,
546        )
547        .await?;
548        Ok(RememberCredential {
549            cookie: format!("{selector}:{verifier}"),
550            expires_at,
551        })
552    }
553
554    /// Trades a presented remember cookie for a fresh session and a replacement
555    /// credential, rotating the stored row so a cookie is single-use.
556    ///
557    /// A selector that resolves with the wrong verifier means a copy of the
558    /// cookie is in circulation: one of the two holders used it first and
559    /// rotated it, and this is the other. Every credential the user holds is
560    /// dropped, which signs out the thief at the cost of signing out the owner.
561    pub async fn consume_remember(
562        &self,
563        cookie: &str,
564        ctx: &RequestContext,
565    ) -> Result<RecalledSession, AuthError> {
566        let (selector, verifier) = cookie.split_once(':').ok_or(AuthError::SessionInvalid)?;
567        let now = Utc::now();
568        let found = store::find_remember_token(&self.db, selector, now)
569            .await?
570            .ok_or(AuthError::SessionInvalid)?;
571
572        if !constant_time_eq(&hash_token(verifier), &found.verifier_hash) {
573            tracing::warn!(
574                user_id = found.user_id,
575                "a stay-signed-in cookie was presented with a stale secret;                  dropping every credential for this account"
576            );
577            store::delete_user_remember_tokens(&self.db, found.user_id).await?;
578            self.log(Some(found.user_id), "", AccessEvent::LoginFailure, ctx)
579                .await?;
580            return Err(AuthError::SessionInvalid);
581        }
582
583        // A disabled or removed account must not be recalled back in.
584        let user = store::find_active_user_by_id(&self.db, found.user_id)
585            .await?
586            .ok_or(AuthError::SessionInvalid)?;
587
588        // Rotate in place. The selector stays, so a copy of the spent cookie
589        // comes back as a mismatch on a known series rather than as a stranger,
590        // which is the only thing that makes theft visible at all.
591        let replacement = generate_token();
592        let remember_expires = now + chrono_from_std(self.config.remember_duration);
593        store::rotate_remember_token(
594            &self.db,
595            selector,
596            &hash_token(&replacement),
597            remember_expires,
598        )
599        .await?;
600        let remember = RememberCredential {
601            cookie: format!("{selector}:{replacement}"),
602            expires_at: remember_expires,
603        };
604
605        let token = generate_token();
606        let expires_at = self.config.deadline(now, now);
607        store::insert_session(
608            &self.db,
609            &hash_token(&token),
610            user.id,
611            expires_at,
612            ctx.ip_address.as_deref(),
613            ctx.user_agent.as_deref(),
614        )
615        .await?;
616        self.log(
617            Some(user.id),
618            &user.username,
619            AccessEvent::LoginSuccess,
620            ctx,
621        )
622        .await?;
623
624        Ok(RecalledSession {
625            session: IssuedSession {
626                token,
627                expires_at,
628                user_id: user.id,
629            },
630            remember,
631        })
632    }
633
634    /// Drops the credential a cookie names. Unknown or malformed values are a
635    /// no-op, so a stale cookie on logout is not an error.
636    pub async fn revoke_remember(&self, cookie: &str) -> Result<(), AuthError> {
637        if let Some((selector, _)) = cookie.split_once(':') {
638            store::delete_remember_token(&self.db, selector).await?;
639        }
640        Ok(())
641    }
642
643    /// Lists the account's live sessions, newest activity first, marking the one
644    /// `current_token` belongs to.
645    pub async fn list_sessions(
646        &self,
647        user_id: i64,
648        current_token: &str,
649    ) -> Result<Vec<ActiveSession>, AuthError> {
650        let current_hash = hash_token(current_token);
651        let rows = store::list_user_sessions(&self.db, user_id, Utc::now()).await?;
652        Ok(rows
653            .into_iter()
654            .map(|s| ActiveSession {
655                id: public_session_id(&s.token_hash),
656                created_at: s.created_at,
657                last_seen_at: s.last_seen_at,
658                expires_at: s.expires_at,
659                ip_address: s.ip_address,
660                user_agent: s.user_agent,
661                current: s.token_hash == current_hash,
662            })
663            .collect())
664    }
665
666    /// Ends one of the account's sessions by its public id, with a reason so the
667    /// device is told. Returns whether a session matched. Scoped to `user_id`, so
668    /// the worst a forged id can do is end one of the caller's own sessions.
669    pub async fn revoke_session(&self, user_id: i64, id: &str) -> Result<bool, AuthError> {
670        let rows = store::list_user_sessions(&self.db, user_id, Utc::now()).await?;
671        let Some(target) = rows
672            .into_iter()
673            .find(|s| constant_time_eq(&public_session_id(&s.token_hash), id))
674        else {
675            return Ok(false);
676        };
677        let done = store::revoke_user_session(
678            &self.db,
679            user_id,
680            &target.token_hash,
681            store::RevokeReason::SignedOutElsewhere,
682        )
683        .await?;
684        Ok(done > 0)
685    }
686
687    /// Ends every session but the caller's, and drops every stay-signed-in
688    /// credential the account holds. The credentials have to go too: leaving
689    /// them would let any signed-out device mint itself a new session on its
690    /// next request, which is the opposite of what this asks for.
691    pub async fn sign_out_everywhere(
692        &self,
693        user_id: i64,
694        keep_token: &str,
695    ) -> Result<u64, AuthError> {
696        let ended = store::revoke_user_sessions(
697            &self.db,
698            user_id,
699            Some(&hash_token(keep_token)),
700            store::RevokeReason::SignedOutElsewhere,
701        )
702        .await?;
703        store::revoke_user_remember_tokens(
704            &self.db,
705            user_id,
706            store::RevokeReason::SignedOutElsewhere,
707        )
708        .await?;
709        Ok(ended)
710    }
711
712    /// Creates an operator who is not a superuser: the panel's account creation,
713    /// by an administrator. The password is checked against the policy, the
714    /// creation audited as `backend.user.create` and credited to `actor`. Roles
715    /// and the temporary-password mark are set by the caller.
716    pub async fn create_operator(
717        &self,
718        new: NewOperator<'_>,
719        actor: &Actor,
720    ) -> Result<i64, AuthError> {
721        self.config.password_policy.check(new.password)?;
722        let hash = password::hash_password(new.password)?;
723        let id = store::create_user(
724            &self.db,
725            new.username,
726            new.email,
727            new.first_name,
728            new.last_name,
729            &hash,
730            false,
731        )
732        .await?;
733        if new.timezone.is_some() {
734            store::set_user_timezone(&self.db, id, new.timezone).await?;
735        }
736        let target = id.to_string();
737        self.record_audit(AuditEntry::new(actor, "backend.user.create").target(
738            "backend_user",
739            &target,
740            Some(new.username),
741        ))
742        .await?;
743        Ok(id)
744    }
745
746    /// Replaces an operator's password with a generated temporary one, on an
747    /// administrator's behalf: every session and stay-signed-in device ends
748    /// with the reason, the change is audited under `actor`, and the account
749    /// must set its own at the next sign-in. Returns the password, to be shown
750    /// once. Who may reset whom is the caller's rule.
751    pub async fn reset_operator_password(
752        &self,
753        user_id: i64,
754        actor: &Actor,
755    ) -> Result<String, AuthError> {
756        let password = password::generate();
757        self.change_password(user_id, &password, None, actor)
758            .await?;
759        store::set_must_change_password(&self.db, user_id, true).await?;
760        Ok(password)
761    }
762
763    /// Whether failed sign-ins have locked the username out from any address
764    /// for now.
765    pub async fn is_locked_out(&self, username: &str) -> Result<bool, AuthError> {
766        let since = Utc::now() - chrono_from_std(self.config.failure_window);
767        store::any_address_locked(&self.db, username, since, self.config.max_failures).await
768    }
769
770    /// Whether this request may try a password: its address has not reached
771    /// the per-address ceiling across every username, nor the limit for this
772    /// username in particular.
773    async fn locked_out(
774        &self,
775        username: &str,
776        ctx: &RequestContext,
777        since: DateTime<Utc>,
778    ) -> Result<bool, AuthError> {
779        let address = ctx.ip_address.as_deref();
780        if store::count_recent_failures_by_address(&self.db, address, since).await?
781            >= self.config.max_failures_per_address
782        {
783            return Ok(true);
784        }
785        Ok(
786            store::count_recent_failures_from(&self.db, username, address, since).await?
787                >= self.config.max_failures,
788        )
789    }
790
791    /// Clears the failed sign-ins that locked the username out, so it can sign
792    /// in again now. Returns how many attempts were cleared.
793    pub async fn unlock(&self, username: &str) -> Result<u64, AuthError> {
794        store::clear_failed_attempts(&self.db, username).await
795    }
796
797    /// Removes every session and stay-signed-in credential past its expiry,
798    /// revoked ones included. The admin runs this at boot and hourly;
799    /// `lat admin purge` runs it on demand.
800    pub async fn purge_expired(&self) -> Result<Purged, AuthError> {
801        let now = Utc::now();
802        Ok(Purged {
803            sessions: store::delete_expired_sessions(&self.db, now).await?,
804            remember_tokens: store::delete_expired_remember_tokens(&self.db, now).await?,
805        })
806    }
807
808    /// Marks the account as holding a temporary password: one made for it by an
809    /// administrator or the command line. Until the operator sets their own
810    /// through [`AuthService::change_own_password`], the admin gate sends every
811    /// request to the Preferences password form.
812    pub async fn require_password_change(&self, user_id: i64) -> Result<(), AuthError> {
813        if store::set_must_change_password(&self.db, user_id, true).await? == 0 {
814            return Err(AuthError::SessionInvalid);
815        }
816        Ok(())
817    }
818
819    /// Whether the account still holds a temporary password.
820    pub async fn must_change_password(&self, user_id: i64) -> Result<bool, AuthError> {
821        store::must_change_password(&self.db, user_id).await
822    }
823
824    /// The rules a new password must meet, for a form to state them up front.
825    pub fn password_policy(&self) -> &PasswordPolicy {
826        &self.config.password_policy
827    }
828
829    /// Sets a new password and signs the account out everywhere else.
830    ///
831    /// A password is changed because it was forgotten, shared or stolen. In the
832    /// last two cases whoever else holds it is already signed in, so every other
833    /// session is ended and every stay-signed-in credential dropped; leaving
834    /// either would make the change decorative. `keep_token` is the session the
835    /// change was made from, which survives so nobody is thrown out mid-edit.
836    /// The sessions ended record why, so their holders are told.
837    ///
838    /// The change is written to the audit log under `backend.user.password_change`,
839    /// attributed to `actor`: the operator who made it, or the process for a
840    /// system change. The password itself is never recorded.
841    ///
842    /// Refuses a password the [`PasswordPolicy`] rejects.
843    pub async fn change_password(
844        &self,
845        user_id: i64,
846        new_password: &str,
847        keep_token: Option<&str>,
848        actor: &Actor,
849    ) -> Result<(), AuthError> {
850        self.config.password_policy.check(new_password)?;
851        let hash = password::hash_password(new_password)?;
852        let keep = keep_token.map(hash_token);
853        let done = store::change_password(
854            &self.db,
855            user_id,
856            &hash,
857            keep.as_deref(),
858            actor.user_id(),
859            actor.label(),
860        )
861        .await?;
862        if done == 0 {
863            return Err(AuthError::SessionInvalid);
864        }
865        self.events
866            .emit(&events::PasswordChanged::new(user_id, actor.user_id()))
867            .await;
868        Ok(())
869    }
870
871    /// [`AuthService::change_password`] for the signed-in operator, after
872    /// confirming their current password.
873    ///
874    /// A wrong current password is logged as a failed sign-in and counts toward
875    /// the same lockout, so a borrowed session cannot guess it freely. The session
876    /// `keep_token` names stays signed in.
877    pub async fn change_own_password(
878        &self,
879        user_id: i64,
880        current_password: &str,
881        new_password: &str,
882        keep_token: &str,
883        ctx: &RequestContext,
884    ) -> Result<(), AuthError> {
885        let user = store::find_user_by_id(&self.db, user_id)
886            .await?
887            .ok_or(AuthError::SessionInvalid)?;
888        let since = Utc::now() - chrono_from_std(self.config.failure_window);
889        if self.locked_out(&user.username, ctx, since).await? {
890            self.log(Some(user.id), &user.username, AccessEvent::LockedOut, ctx)
891                .await?;
892            return Err(AuthError::TooManyAttempts);
893        }
894        if !password::verify_password(current_password, &user.password_hash)? {
895            self.log(
896                Some(user.id),
897                &user.username,
898                AccessEvent::LoginFailure,
899                ctx,
900            )
901            .await?;
902            return Err(AuthError::InvalidCredentials);
903        }
904        self.change_password(
905            user.id,
906            new_password,
907            Some(keep_token),
908            &Actor::user(user.id, user.username.as_str()),
909        )
910        .await?;
911        self.log(
912            Some(user.id),
913            &user.username,
914            AccessEvent::PasswordChanged,
915            ctx,
916        )
917        .await
918    }
919
920    /// The account's most recent access-log rows, newest first: sign-ins,
921    /// failures, lockouts, sign-outs and its own password changes.
922    pub async fn recent_access(
923        &self,
924        user_id: i64,
925        limit: u64,
926    ) -> Result<Vec<store::AccessRecord>, AuthError> {
927        store::recent_access_log(&self.db, user_id, limit).await
928    }
929
930    /// When the account's password was last set, `None` if never recorded.
931    pub async fn password_changed_at(
932        &self,
933        user_id: i64,
934    ) -> Result<Option<DateTime<Utc>>, AuthError> {
935        store::password_changed_at(&self.db, user_id).await
936    }
937
938    /// [`AuthService::change_password`] for an account named by username, with no
939    /// session kept: the command-line reset, run from outside every session.
940    /// `Ok(false)` when no such account exists.
941    pub async fn reset_password(
942        &self,
943        username: &str,
944        new_password: &str,
945        actor: &Actor,
946    ) -> Result<bool, AuthError> {
947        let Some(user) = store::find_user_by_username(&self.db, username).await? else {
948            return Ok(false);
949        };
950        self.change_password(user.id, new_password, None, actor)
951            .await?;
952        Ok(true)
953    }
954
955    /// Whose stay-signed-in credential a cookie is, without spending it: `None`
956    /// for one that is not current. The verifier has to match, so a stale copy
957    /// names nobody.
958    pub async fn remember_holder(&self, cookie: &str) -> Result<Option<i64>, AuthError> {
959        let Some((selector, verifier)) = cookie.split_once(':') else {
960            return Ok(None);
961        };
962        let Some(found) = store::find_remember_token(&self.db, selector, Utc::now()).await? else {
963            return Ok(None);
964        };
965        Ok(constant_time_eq(&hash_token(verifier), &found.verifier_hash).then_some(found.user_id))
966    }
967
968    /// Why a presented stay-signed-in cookie no longer works, when it was ended
969    /// on purpose.
970    ///
971    /// Only the current holder is told: the verifier has to match, so a stale
972    /// copy learns nothing. Reading the reason removes the credential, so it is
973    /// told once.
974    pub async fn remember_end(
975        &self,
976        cookie: &str,
977    ) -> Result<Option<store::RevokeReason>, AuthError> {
978        let Some((selector, verifier)) = cookie.split_once(':') else {
979            return Ok(None);
980        };
981        let Some((verifier_hash, reason)) =
982            store::revoked_remember_token(&self.db, selector).await?
983        else {
984            return Ok(None);
985        };
986        if !constant_time_eq(&hash_token(verifier), &verifier_hash) {
987            return Ok(None);
988        }
989        store::delete_remember_token(&self.db, selector).await?;
990        Ok(Some(reason))
991    }
992
993    /// Why a presented session no longer works, if it was ever issued.
994    ///
995    /// A deliberately ended session is told once: reading its reason removes it,
996    /// so a stale tab reloaded later is simply signed out.
997    pub async fn session_end(&self, token: &str) -> Result<Option<store::SessionEnd>, AuthError> {
998        let token_hash = hash_token(token);
999        let end = store::session_end(&self.db, &token_hash, Utc::now()).await?;
1000        if matches!(end, Some(store::SessionEnd::Revoked(_))) {
1001            store::delete_session(&self.db, &token_hash).await?;
1002        }
1003        Ok(end)
1004    }
1005
1006    /// Activates or deactivates a backend user.
1007    ///
1008    /// Deactivating is the reversible counterpart to deleting an operator, which
1009    /// the panel does not offer: the account keeps its history, its audit trail
1010    /// and its permission overrides, and simply cannot sign in.
1011    ///
1012    /// Two deactivations are refused, because both end with a panel somebody has
1013    /// to repair from the command line: your own account, and the last active
1014    /// superuser. Both return [`AuthError::Refused`] with the reason.
1015    ///
1016    /// A deactivated account's sessions and stay-signed-in credentials are
1017    /// dropped in the same call. An account that may not sign in must not remain
1018    /// signed in on a machine somewhere, which is the whole point of revoking it.
1019    pub async fn set_user_active(
1020        &self,
1021        actor_id: i64,
1022        user_id: i64,
1023        active: bool,
1024    ) -> Result<(), AuthError> {
1025        if !active {
1026            if actor_id == user_id {
1027                return Err(AuthError::Refused(
1028                    "You cannot deactivate your own account.".to_string(),
1029                ));
1030            }
1031            let user = store::find_user_by_id(&self.db, user_id)
1032                .await?
1033                .ok_or(AuthError::SessionInvalid)?;
1034            if user.is_superuser && store::other_active_superusers(&self.db, user_id).await? == 0 {
1035                return Err(AuthError::Refused(
1036                    "This is the last active superuser; the panel would have no \
1037                     administrator left."
1038                        .to_string(),
1039                ));
1040            }
1041        }
1042
1043        if store::set_user_active(&self.db, user_id, active).await? == 0 {
1044            return Err(AuthError::SessionInvalid);
1045        }
1046        Ok(())
1047    }
1048
1049    /// Invalidates a session. Unknown tokens are a no-op.
1050    pub async fn logout(&self, token: &str) -> Result<(), AuthError> {
1051        let hash = hash_token(token);
1052        // Read before the row goes, to know whose sign-out to announce. A
1053        // session already expired or revoked ended earlier, so it names nobody.
1054        let owner = store::find_valid_session(&self.db, &hash, Utc::now()).await?;
1055        store::delete_session(&self.db, &hash).await?;
1056        if let Some(session) = owner {
1057            self.events
1058                .emit(&events::SignedOut::new(session.user_id))
1059                .await;
1060        }
1061        Ok(())
1062    }
1063
1064    /// Persists an operator's own display timezone. `Some(name)` sets an IANA
1065    /// timezone; `None` clears it so the operator falls back to the deployment
1066    /// default. Validating that `name` is a real timezone is the caller's job.
1067    pub async fn set_user_timezone(
1068        &self,
1069        user_id: i64,
1070        timezone: Option<&str>,
1071    ) -> Result<(), AuthError> {
1072        store::set_user_timezone(&self.db, user_id, timezone).await
1073    }
1074
1075    /// Persists an operator's own UI locale. `Some(tag)` sets a base language tag;
1076    /// `None` clears it so the operator falls back to the deployment default.
1077    /// Validating that `tag` is a supported locale is the caller's job.
1078    pub async fn set_user_locale(
1079        &self,
1080        user_id: i64,
1081        locale: Option<&str>,
1082    ) -> Result<(), AuthError> {
1083        store::set_user_locale(&self.db, user_id, locale).await
1084    }
1085
1086    /// Loads a user's per-permission overrides (code to `1` allow or `-1` deny).
1087    pub async fn user_permission_overrides(
1088        &self,
1089        user_id: i64,
1090    ) -> Result<std::collections::HashMap<String, i64>, AuthError> {
1091        store::load_user_permission_overrides(&self.db, user_id).await
1092    }
1093
1094    /// Replaces a user's per-permission overrides. Callers pass only `1` and `-1`
1095    /// entries; an inherited permission is represented by its absence.
1096    pub async fn set_user_permissions(
1097        &self,
1098        user_id: i64,
1099        overrides: &std::collections::HashMap<String, i64>,
1100    ) -> Result<(), AuthError> {
1101        store::set_user_permissions(&self.db, user_id, overrides).await
1102    }
1103
1104    /// Appends an entry to the append-only audit log. Mutating admin paths call
1105    /// this after a successful change, so actions that affect privileges or data
1106    /// are recorded. Self-service preference changes are not audited.
1107    pub async fn record_audit(&self, entry: AuditEntry<'_>) -> Result<(), AuthError> {
1108        store::insert_audit_log(
1109            &self.db,
1110            entry.actor_user_id,
1111            entry.actor_username,
1112            entry.action,
1113            entry.target_type,
1114            entry.target_id,
1115            entry.target_label,
1116            entry.detail,
1117        )
1118        .await
1119    }
1120
1121    /// The most recent audit entries, newest first, for the admin audit view.
1122    pub async fn recent_audit(&self, limit: u64) -> Result<Vec<store::AuditRecord>, AuthError> {
1123        store::recent_audit(&self.db, limit).await
1124    }
1125
1126    /// Whether any backend operator exists yet. A fresh install with none is
1127    /// routed to first-run setup instead of login.
1128    pub async fn has_any_operator(&self) -> Result<bool, AuthError> {
1129        store::any_user_exists(&self.db).await
1130    }
1131
1132    /// Creates a superuser operator: hashes the password, inserts the user, and
1133    /// records their timezone preference. The single account-creation path,
1134    /// shared by the CLI and the first-run setup screen.
1135    pub async fn create_superuser(&self, new: NewOperator<'_>) -> Result<i64, AuthError> {
1136        self.config.password_policy.check(new.password)?;
1137        let hash = password::hash_password(new.password)?;
1138        let id = store::create_user(
1139            &self.db,
1140            new.username,
1141            new.email,
1142            new.first_name,
1143            new.last_name,
1144            &hash,
1145            true,
1146        )
1147        .await?;
1148        if new.timezone.is_some() {
1149            store::set_user_timezone(&self.db, id, new.timezone).await?;
1150        }
1151        // The administrator role, when the panel has written it. A superuser
1152        // passes every check anyway; this is so the roles screen reads true and
1153        // the account keeps working if its superuser flag is ever cleared.
1154        if let Some(role_id) = store::role_id_by_code(&self.db, crate::ROLE_ADMIN).await? {
1155            store::assign_role(&self.db, id, role_id).await?;
1156        }
1157        Ok(id)
1158    }
1159
1160    async fn log(
1161        &self,
1162        user_id: Option<i64>,
1163        username: &str,
1164        event: AccessEvent,
1165        ctx: &RequestContext,
1166    ) -> Result<(), AuthError> {
1167        store::insert_access_log(
1168            &self.db,
1169            user_id,
1170            username,
1171            event,
1172            ctx.ip_address.as_deref(),
1173            ctx.user_agent.as_deref(),
1174        )
1175        .await?;
1176        // Announced from the one place the access log is written, so the log
1177        // and the bus cannot disagree about what happened.
1178        match (event, user_id) {
1179            (AccessEvent::LoginSuccess, Some(id)) => {
1180                self.events
1181                    .emit(&events::SignedIn::new(id, username, ctx))
1182                    .await;
1183            }
1184            (AccessEvent::LoginFailure, _) => {
1185                self.events
1186                    .emit(&events::SignInFailed::new(user_id, username, ctx))
1187                    .await;
1188            }
1189            (AccessEvent::LockedOut, _) => {
1190                self.events
1191                    .emit(&events::LockedOut::new(user_id, username, ctx))
1192                    .await;
1193            }
1194            // A password change is announced by `change_password`, which every
1195            // change goes through; this row is only the operator's own.
1196            _ => {}
1197        }
1198        Ok(())
1199    }
1200}
1201
1202/// Converts a small, in-range `std::time::Duration` to `chrono::Duration`.
1203/// The auth policy durations are hours at most, well within range.
1204fn chrono_from_std(d: Duration) -> chrono::Duration {
1205    chrono::Duration::from_std(d).expect("auth policy duration out of range")
1206}
1207
1208fn generate_token() -> String {
1209    let mut bytes = [0u8; 32];
1210    rand::rngs::OsRng.fill_bytes(&mut bytes);
1211    to_hex(&bytes)
1212}
1213
1214/// A page-safe name for a session row. Hashing the stored key again means the
1215/// id identifies a session without being the key: even if a rendered page leaks,
1216/// nothing in it can be presented as a session or looked up as one.
1217fn public_session_id(token_hash: &str) -> String {
1218    let mut hasher = Sha256::new();
1219    hasher.update(b"laterite:session-id:");
1220    hasher.update(token_hash.as_bytes());
1221    to_hex(&hasher.finalize())
1222}
1223
1224/// Compares two equal-length hex digests without an early return.
1225fn constant_time_eq(a: &str, b: &str) -> bool {
1226    let (a, b) = (a.as_bytes(), b.as_bytes());
1227    if a.len() != b.len() {
1228        return false;
1229    }
1230    a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
1231}
1232
1233fn hash_token(token: &str) -> String {
1234    let mut hasher = Sha256::new();
1235    hasher.update(token.as_bytes());
1236    to_hex(&hasher.finalize())
1237}
1238
1239fn to_hex(bytes: &[u8]) -> String {
1240    let mut out = String::with_capacity(bytes.len() * 2);
1241    for byte in bytes {
1242        let _ = write!(out, "{byte:02x}");
1243    }
1244    out
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249    use super::*;
1250
1251    async fn seed_user(db: &Db, username: &str, password: &str, superuser: bool) -> i64 {
1252        let hash = password::hash_password(password).unwrap();
1253        store::create_user(
1254            db,
1255            username,
1256            &format!("{username}@example.test"),
1257            "Test",
1258            Some("Operator"),
1259            &hash,
1260            superuser,
1261        )
1262        .await
1263        .unwrap()
1264    }
1265
1266    fn service(db: Db) -> AuthService {
1267        AuthService::new(db, AuthConfig::default())
1268    }
1269
1270    /// A fresh test database with this module's migrations applied through the
1271    /// framework runner, on whichever backend the run targets (see
1272    /// `laterite_core::testing`). Hold the returned guard for the test's lifetime.
1273    async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
1274        laterite_core::testing::connect_test(&[crate::migrations()]).await
1275    }
1276
1277    /// Backdates a session's idle clock, standing in for time passing.
1278    async fn set_last_seen(db: &Db, token: &str, seen: DateTime<Utc>) {
1279        store::renew_session(
1280            db,
1281            &hash_token(token),
1282            seen,
1283            seen + chrono::Duration::hours(2),
1284        )
1285        .await
1286        .unwrap();
1287    }
1288
1289    async fn last_seen(db: &Db, token: &str) -> DateTime<Utc> {
1290        store::find_valid_session(db, &hash_token(token), Utc::now())
1291            .await
1292            .unwrap()
1293            .expect("session still valid")
1294            .last_seen_at
1295    }
1296
1297    #[tokio::test]
1298    async fn a_request_past_the_halfway_mark_renews_the_session() {
1299        let (pool, _guard) = test_db().await;
1300        seed_user(&pool, "root", "hunter2", true).await;
1301        let svc = service(pool.clone());
1302
1303        let issued = svc
1304            .authenticate("root", "hunter2", &RequestContext::default())
1305            .await
1306            .unwrap();
1307        // Ninety minutes of quiet, past the one-hour halfway mark of the
1308        // two-hour idle window.
1309        let stale = Utc::now() - chrono::Duration::minutes(90);
1310        set_last_seen(&pool, &issued.token, stale).await;
1311
1312        let resolved = svc.resolve_session(&issued.token).await.unwrap();
1313
1314        assert!(
1315            last_seen(&pool, &issued.token).await > stale,
1316            "a request past the halfway mark should push the idle clock"
1317        );
1318        assert!(
1319            resolved.expires_at > Utc::now() + chrono::Duration::minutes(115),
1320            "renewal should restore very nearly the full idle window"
1321        );
1322    }
1323
1324    #[tokio::test]
1325    async fn verify_session_renews_like_the_path_it_wraps() {
1326        let (pool, _guard) = test_db().await;
1327        seed_user(&pool, "root", "hunter2", true).await;
1328        let svc = service(pool.clone());
1329
1330        let issued = svc
1331            .authenticate("root", "hunter2", &RequestContext::default())
1332            .await
1333            .unwrap();
1334        let stale = Utc::now() - chrono::Duration::minutes(90);
1335        set_last_seen(&pool, &issued.token, stale).await;
1336
1337        svc.verify_session(&issued.token).await.unwrap();
1338
1339        // The thin wrapper must not become a way to read a session without
1340        // ageing it; that is the whole reason the store's session functions
1341        // are crate-visible.
1342        assert!(last_seen(&pool, &issued.token).await > stale);
1343    }
1344
1345    #[tokio::test]
1346    async fn a_request_before_the_halfway_mark_writes_nothing() {
1347        let (pool, _guard) = test_db().await;
1348        seed_user(&pool, "root", "hunter2", true).await;
1349        let svc = service(pool.clone());
1350
1351        let issued = svc
1352            .authenticate("root", "hunter2", &RequestContext::default())
1353            .await
1354            .unwrap();
1355        // Ten minutes of quiet: nowhere near the halfway mark, so the write
1356        // would buy minutes and cost a round-trip on every request.
1357        let recent = Utc::now() - chrono::Duration::minutes(10);
1358        set_last_seen(&pool, &issued.token, recent).await;
1359
1360        svc.resolve_session(&issued.token).await.unwrap();
1361
1362        assert_eq!(
1363            last_seen(&pool, &issued.token).await.timestamp(),
1364            recent.timestamp(),
1365            "a request inside the halfway mark should leave the row alone"
1366        );
1367    }
1368
1369    #[tokio::test]
1370    async fn a_session_past_its_idle_window_no_longer_resolves() {
1371        let (pool, _guard) = test_db().await;
1372        seed_user(&pool, "root", "hunter2", true).await;
1373        let svc = service(pool.clone());
1374
1375        let issued = svc
1376            .authenticate("root", "hunter2", &RequestContext::default())
1377            .await
1378            .unwrap();
1379        // renew_session writes last-seen and the deadline together, so a
1380        // backdated pair is exactly what an abandoned session looks like.
1381        let long_ago = Utc::now() - chrono::Duration::hours(3);
1382        set_last_seen(&pool, &issued.token, long_ago).await;
1383
1384        assert!(matches!(
1385            svc.resolve_session(&issued.token).await,
1386            Err(AuthError::SessionInvalid)
1387        ));
1388    }
1389
1390    #[tokio::test]
1391    async fn a_remember_cookie_is_single_use_and_rotates() {
1392        let (pool, _guard) = test_db().await;
1393        let uid = seed_user(&pool, "root", "hunter2", true).await;
1394        let svc = service(pool);
1395
1396        let first = svc.issue_remember(uid).await.unwrap();
1397        let recalled = svc
1398            .consume_remember(&first.cookie, &RequestContext::default())
1399            .await
1400            .unwrap();
1401
1402        assert_ne!(
1403            recalled.remember.cookie, first.cookie,
1404            "using a credential must replace its secret"
1405        );
1406        assert_eq!(
1407            recalled.remember.cookie.split_once(':').unwrap().0,
1408            first.cookie.split_once(':').unwrap().0,
1409            "the selector is the series and must survive rotation"
1410        );
1411        // The session it minted is real.
1412        svc.verify_session(&recalled.session.token).await.unwrap();
1413        // And the spent cookie is dead, so a copy taken from a stolen laptop
1414        // buys nothing once the owner's browser has used it.
1415        assert!(matches!(
1416            svc.consume_remember(&first.cookie, &RequestContext::default())
1417                .await,
1418            Err(AuthError::SessionInvalid)
1419        ));
1420    }
1421
1422    #[tokio::test]
1423    async fn a_stale_secret_drops_every_credential_the_user_holds() {
1424        let (pool, _guard) = test_db().await;
1425        let uid = seed_user(&pool, "root", "hunter2", true).await;
1426        let svc = service(pool);
1427
1428        let stolen = svc.issue_remember(uid).await.unwrap();
1429        let other_device = svc.issue_remember(uid).await.unwrap();
1430        // The owner's browser uses it first, rotating the row.
1431        let owner = svc
1432            .consume_remember(&stolen.cookie, &RequestContext::default())
1433            .await
1434            .unwrap();
1435
1436        // The thief presents the copy they took. Same selector, stale secret:
1437        // proof a cookie is in two places.
1438        let selector = stolen.cookie.split_once(':').unwrap().0;
1439        let forged = format!("{selector}:{}", generate_token());
1440        assert!(matches!(
1441            svc.consume_remember(&forged, &RequestContext::default())
1442                .await,
1443            Err(AuthError::SessionInvalid)
1444        ));
1445
1446        // Everything is revoked, the owner's fresh credential included. Signing
1447        // the owner out is the price of signing the thief out.
1448        for credential in [owner.remember.cookie, other_device.cookie] {
1449            assert!(matches!(
1450                svc.consume_remember(&credential, &RequestContext::default())
1451                    .await,
1452                Err(AuthError::SessionInvalid)
1453            ));
1454        }
1455    }
1456
1457    #[tokio::test]
1458    async fn the_sessions_list_marks_the_one_asking() {
1459        let (pool, _guard) = test_db().await;
1460        seed_user(&pool, "root", "hunter2", true).await;
1461        let svc = service(pool);
1462
1463        let first = svc
1464            .authenticate("root", "hunter2", &RequestContext::default())
1465            .await
1466            .unwrap();
1467        let second = svc
1468            .authenticate("root", "hunter2", &RequestContext::default())
1469            .await
1470            .unwrap();
1471
1472        let listed = svc
1473            .list_sessions(first.user_id, &second.token)
1474            .await
1475            .unwrap();
1476        assert_eq!(listed.len(), 2);
1477        assert_eq!(listed.iter().filter(|s| s.current).count(), 1);
1478        // The public id names a row without being the row's key.
1479        assert!(listed.iter().all(|s| !s.id.is_empty()));
1480    }
1481
1482    #[tokio::test]
1483    async fn one_account_cannot_revoke_anothers_session() {
1484        let (pool, _guard) = test_db().await;
1485        seed_user(&pool, "root", "hunter2", true).await;
1486        seed_user(&pool, "mallory", "hunter2", false).await;
1487        let svc = service(pool);
1488
1489        let victim = svc
1490            .authenticate("root", "hunter2", &RequestContext::default())
1491            .await
1492            .unwrap();
1493        let attacker = svc
1494            .authenticate("mallory", "hunter2", &RequestContext::default())
1495            .await
1496            .unwrap();
1497
1498        // The attacker has somehow learned the victim's public session id.
1499        let victims_id = svc
1500            .list_sessions(victim.user_id, &victim.token)
1501            .await
1502            .unwrap()
1503            .remove(0)
1504            .id;
1505
1506        assert!(
1507            !svc.revoke_session(attacker.user_id, &victims_id)
1508                .await
1509                .unwrap(),
1510            "a revoke is scoped to its own account"
1511        );
1512        // And the victim is still signed in.
1513        svc.verify_session(&victim.token).await.unwrap();
1514    }
1515
1516    #[tokio::test]
1517    async fn signing_out_everywhere_keeps_this_one_and_drops_credentials() {
1518        let (pool, _guard) = test_db().await;
1519        let uid = seed_user(&pool, "root", "hunter2", true).await;
1520        let svc = service(pool);
1521
1522        let keep = svc
1523            .authenticate("root", "hunter2", &RequestContext::default())
1524            .await
1525            .unwrap();
1526        let other = svc
1527            .authenticate("root", "hunter2", &RequestContext::default())
1528            .await
1529            .unwrap();
1530        let credential = svc.issue_remember(uid).await.unwrap();
1531
1532        let ended = svc.sign_out_everywhere(uid, &keep.token).await.unwrap();
1533
1534        assert_eq!(ended, 1);
1535        svc.verify_session(&keep.token).await.unwrap();
1536        assert!(matches!(
1537            svc.verify_session(&other.token).await,
1538            Err(AuthError::SessionInvalid)
1539        ));
1540        // Leaving the credential would let the signed-out device mint a new
1541        // session on its very next request.
1542        assert!(matches!(
1543            svc.consume_remember(&credential.cookie, &RequestContext::default())
1544                .await,
1545            Err(AuthError::SessionInvalid)
1546        ));
1547    }
1548
1549    #[tokio::test]
1550    async fn revoking_a_credential_ends_it() {
1551        let (pool, _guard) = test_db().await;
1552        let uid = seed_user(&pool, "root", "hunter2", true).await;
1553        let svc = service(pool);
1554
1555        let credential = svc.issue_remember(uid).await.unwrap();
1556        svc.revoke_remember(&credential.cookie).await.unwrap();
1557
1558        assert!(matches!(
1559            svc.consume_remember(&credential.cookie, &RequestContext::default())
1560                .await,
1561            Err(AuthError::SessionInvalid)
1562        ));
1563    }
1564
1565    #[tokio::test]
1566    async fn deactivating_blocks_sign_in_and_ends_the_sessions() {
1567        let (pool, _guard) = test_db().await;
1568        let admin = seed_user(&pool, "root", "hunter2", true).await;
1569        let target = seed_user(&pool, "editor", "hunter2", false).await;
1570        let svc = service(pool.clone());
1571
1572        let issued = svc
1573            .authenticate("editor", "hunter2", &RequestContext::default())
1574            .await
1575            .unwrap();
1576        let credential = svc.issue_remember(target).await.unwrap();
1577
1578        svc.set_user_active(admin, target, false).await.unwrap();
1579
1580        // Cannot sign in again.
1581        assert!(matches!(
1582            svc.authenticate("editor", "hunter2", &RequestContext::default())
1583                .await,
1584            Err(AuthError::InactiveAccount)
1585        ));
1586        // And is not left signed in where they already were: an account that may
1587        // not sign in must not stay signed in.
1588        assert!(matches!(
1589            svc.verify_session(&issued.token).await,
1590            Err(AuthError::SessionInvalid)
1591        ));
1592        assert!(matches!(
1593            svc.consume_remember(&credential.cookie, &RequestContext::default())
1594                .await,
1595            Err(AuthError::SessionInvalid)
1596        ));
1597    }
1598
1599    #[tokio::test]
1600    async fn reactivating_lets_them_back_in() {
1601        let (pool, _guard) = test_db().await;
1602        let admin = seed_user(&pool, "root", "hunter2", true).await;
1603        let target = seed_user(&pool, "editor", "hunter2", false).await;
1604        let svc = service(pool);
1605
1606        svc.set_user_active(admin, target, false).await.unwrap();
1607        svc.set_user_active(admin, target, true).await.unwrap();
1608
1609        // Reversible is the whole reason this exists instead of deletion.
1610        svc.authenticate("editor", "hunter2", &RequestContext::default())
1611            .await
1612            .unwrap();
1613    }
1614
1615    #[tokio::test]
1616    async fn an_operator_cannot_deactivate_themselves() {
1617        let (pool, _guard) = test_db().await;
1618        let admin = seed_user(&pool, "root", "hunter2", true).await;
1619        let svc = service(pool);
1620
1621        // Locking yourself out of the panel you are administering is never the
1622        // intent, and the repair is a command line.
1623        assert!(matches!(
1624            svc.set_user_active(admin, admin, false).await,
1625            Err(AuthError::Refused(_))
1626        ));
1627    }
1628
1629    #[tokio::test]
1630    async fn the_last_superuser_cannot_be_deactivated() {
1631        let (pool, _guard) = test_db().await;
1632        let only = seed_user(&pool, "root", "hunter2", true).await;
1633        let editor = seed_user(&pool, "editor", "hunter2", false).await;
1634        let svc = service(pool.clone());
1635
1636        // An ordinary operator acting on the only superuser still cannot leave
1637        // the panel without one.
1638        assert!(matches!(
1639            svc.set_user_active(editor, only, false).await,
1640            Err(AuthError::Refused(_))
1641        ));
1642
1643        // With a second superuser, the first may be deactivated.
1644        let second = seed_user(&pool, "root2", "hunter2", true).await;
1645        svc.set_user_active(second, only, false).await.unwrap();
1646    }
1647
1648    #[tokio::test]
1649    async fn audit_entries_record_and_read_back_newest_first() {
1650        let (pool, _guard) = test_db().await;
1651        let actor = seed_user(&pool, "root", "hunter2", true).await;
1652        let svc = service(pool);
1653
1654        svc.record_audit(AuditEntry {
1655            actor_user_id: Some(actor),
1656            actor_username: "root",
1657            action: "backend.role.update",
1658            target_type: Some("backend_role"),
1659            target_id: Some("42"),
1660            target_label: Some("Editors"),
1661            detail: Some(r#"{"added":["a.b"]}"#),
1662        })
1663        .await
1664        .unwrap();
1665        svc.record_audit(AuditEntry {
1666            actor_user_id: Some(actor),
1667            actor_username: "root",
1668            action: "backend.plugin.disable",
1669            target_type: Some("plugin"),
1670            target_id: Some("acme.widgets"),
1671            target_label: None,
1672            detail: None,
1673        })
1674        .await
1675        .unwrap();
1676
1677        let entries = svc.recent_audit(10).await.unwrap();
1678        assert_eq!(entries.len(), 2);
1679        // Newest first.
1680        assert_eq!(entries[0].action, "backend.plugin.disable");
1681        assert_eq!(entries[0].target_id.as_deref(), Some("acme.widgets"));
1682        assert_eq!(entries[0].detail, None);
1683        assert_eq!(entries[1].action, "backend.role.update");
1684        assert_eq!(entries[1].actor_username, "root");
1685        assert_eq!(entries[1].actor_user_id, Some(actor));
1686        assert_eq!(entries[1].detail.as_deref(), Some(r#"{"added":["a.b"]}"#));
1687    }
1688
1689    #[tokio::test]
1690    async fn authenticate_issues_a_verifiable_session() {
1691        let (pool, _guard) = test_db().await;
1692        seed_user(&pool, "root", "hunter2", true).await;
1693        let svc = service(pool);
1694
1695        let session = svc
1696            .authenticate("root", "hunter2", &RequestContext::default())
1697            .await
1698            .expect("login should succeed");
1699        let identity = svc
1700            .verify_session(&session.token)
1701            .await
1702            .expect("session should resolve");
1703
1704        assert_eq!(identity.user.username, "root");
1705        assert_eq!(identity.user.full_name(), "Test Operator");
1706        assert!(identity.allows("anything.superuser.can.do"));
1707    }
1708
1709    #[tokio::test]
1710    async fn session_data_blob_round_trips() {
1711        let (pool, _guard) = test_db().await;
1712        seed_user(&pool, "root", "hunter2", true).await;
1713        let svc = service(pool);
1714
1715        let session = svc
1716            .authenticate("root", "hunter2", &RequestContext::default())
1717            .await
1718            .unwrap();
1719
1720        // A fresh session has no blob.
1721        let resolved = svc.resolve_session(&session.token).await.unwrap();
1722        assert_eq!(resolved.data, None);
1723        assert_eq!(resolved.identity.user.username, "root");
1724
1725        // The surface writes its opaque state; the next resolve reads it back.
1726        svc.set_session_data(&session.token, r#"{"v":1,"csrf":"abc"}"#)
1727            .await
1728            .unwrap();
1729        let resolved = svc.resolve_session(&session.token).await.unwrap();
1730        assert_eq!(resolved.data.as_deref(), Some(r#"{"v":1,"csrf":"abc"}"#));
1731    }
1732
1733    #[tokio::test]
1734    async fn username_is_case_insensitive_across_backends() {
1735        // The account is created lower-cased, and a differently-cased login
1736        // resolves to it: this must hold identically on every backend (MySQL's
1737        // collation is case-insensitive, Postgres and SQLite are not).
1738        let (pool, _guard) = test_db().await;
1739        let hash = password::hash_password("pw").unwrap();
1740        store::create_user(
1741            &pool,
1742            "Root",
1743            "Root@Example.test",
1744            "Case",
1745            None,
1746            &hash,
1747            true,
1748        )
1749        .await
1750        .unwrap();
1751        let svc = service(pool);
1752        let session = svc
1753            .authenticate("ROOT", "pw", &RequestContext::default())
1754            .await
1755            .expect("case-varied login should resolve to the same account");
1756        let identity = svc.verify_session(&session.token).await.unwrap();
1757        assert_eq!(identity.user.username, "root");
1758        assert_eq!(identity.user.email, "root@example.test");
1759    }
1760
1761    #[tokio::test]
1762    async fn full_name_falls_back_to_first_name_when_last_is_absent() {
1763        let (pool, _guard) = test_db().await;
1764        let hash = password::hash_password("pw").unwrap();
1765        store::create_user(
1766            &pool,
1767            "mono",
1768            "mono@example.test",
1769            "Prakash",
1770            None,
1771            &hash,
1772            true,
1773        )
1774        .await
1775        .unwrap();
1776        let svc = service(pool);
1777        let session = svc
1778            .authenticate("mono", "pw", &RequestContext::default())
1779            .await
1780            .unwrap();
1781        let identity = svc.verify_session(&session.token).await.unwrap();
1782        assert_eq!(identity.user.full_name(), "Prakash");
1783    }
1784
1785    #[tokio::test]
1786    async fn operator_timezone_round_trips_and_clears() {
1787        let (pool, _guard) = test_db().await;
1788        let id = seed_user(&pool, "tz", "pw", true).await;
1789
1790        // A fresh operator has no preference and inherits the default.
1791        let user = store::find_active_user_by_id(&pool, id)
1792            .await
1793            .unwrap()
1794            .unwrap();
1795        assert_eq!(user.timezone, None);
1796
1797        let svc = service(pool.clone());
1798        svc.set_user_timezone(id, Some("Asia/Kolkata"))
1799            .await
1800            .unwrap();
1801        let user = store::find_active_user_by_id(&pool, id)
1802            .await
1803            .unwrap()
1804            .unwrap();
1805        assert_eq!(user.timezone.as_deref(), Some("Asia/Kolkata"));
1806
1807        // Clearing it returns the operator to the default.
1808        svc.set_user_timezone(id, None).await.unwrap();
1809        let user = store::find_active_user_by_id(&pool, id)
1810            .await
1811            .unwrap()
1812            .unwrap();
1813        assert_eq!(user.timezone, None);
1814    }
1815
1816    #[tokio::test]
1817    async fn operator_locale_round_trips_and_clears() {
1818        let (pool, _guard) = test_db().await;
1819        let id = seed_user(&pool, "loc", "pw", true).await;
1820
1821        // A fresh operator has no preference and inherits the default.
1822        let user = store::find_active_user_by_id(&pool, id)
1823            .await
1824            .unwrap()
1825            .unwrap();
1826        assert_eq!(user.locale, None);
1827
1828        let svc = service(pool.clone());
1829        svc.set_user_locale(id, Some("kn")).await.unwrap();
1830        let user = store::find_active_user_by_id(&pool, id)
1831            .await
1832            .unwrap()
1833            .unwrap();
1834        assert_eq!(user.locale.as_deref(), Some("kn"));
1835
1836        // Clearing it returns the operator to the default.
1837        svc.set_user_locale(id, None).await.unwrap();
1838        let user = store::find_active_user_by_id(&pool, id)
1839            .await
1840            .unwrap()
1841            .unwrap();
1842        assert_eq!(user.locale, None);
1843    }
1844
1845    #[tokio::test]
1846    async fn has_any_operator_flips_after_the_first_account() {
1847        let (pool, _guard) = test_db().await;
1848        let svc = service(pool);
1849
1850        // A fresh install has no operators, so setup (not login) applies.
1851        assert!(!svc.has_any_operator().await.unwrap());
1852
1853        svc.create_superuser(NewOperator {
1854            username: "first",
1855            email: "first@example.test",
1856            first_name: "First",
1857            last_name: None,
1858            password: "hunter2-hunter2",
1859            timezone: Some("Asia/Kolkata"),
1860        })
1861        .await
1862        .unwrap();
1863
1864        assert!(svc.has_any_operator().await.unwrap());
1865
1866        // The account is a usable superuser with the onboarding timezone recorded.
1867        let session = svc
1868            .authenticate("first", "hunter2-hunter2", &RequestContext::default())
1869            .await
1870            .unwrap();
1871        let identity = svc.verify_session(&session.token).await.unwrap();
1872        assert!(identity.allows("anything.a.superuser.can.do"));
1873        assert_eq!(identity.user.timezone.as_deref(), Some("Asia/Kolkata"));
1874    }
1875
1876    #[tokio::test]
1877    async fn wrong_password_is_rejected() {
1878        let (pool, _guard) = test_db().await;
1879        seed_user(&pool, "root", "hunter2", false).await;
1880        let svc = service(pool);
1881
1882        let err = svc
1883            .authenticate("root", "wrong", &RequestContext::default())
1884            .await
1885            .unwrap_err();
1886        assert!(matches!(err, AuthError::InvalidCredentials));
1887    }
1888
1889    #[tokio::test]
1890    async fn unknown_user_is_rejected_without_distinction() {
1891        let (pool, _guard) = test_db().await;
1892        let svc = service(pool);
1893        let err = svc
1894            .authenticate("ghost", "whatever", &RequestContext::default())
1895            .await
1896            .unwrap_err();
1897        assert!(matches!(err, AuthError::InvalidCredentials));
1898    }
1899
1900    #[tokio::test]
1901    async fn lockout_trips_after_max_failures() {
1902        let (pool, _guard) = test_db().await;
1903        seed_user(&pool, "root", "hunter2", false).await;
1904        let svc = AuthService::new(
1905            pool,
1906            AuthConfig {
1907                max_failures: 3,
1908                ..AuthConfig::default()
1909            },
1910        );
1911        let ctx = RequestContext::default();
1912
1913        for _ in 0..3 {
1914            let err = svc.authenticate("root", "bad", &ctx).await.unwrap_err();
1915            assert!(matches!(err, AuthError::InvalidCredentials));
1916        }
1917        // The correct password is now refused: the account is locked out.
1918        let err = svc.authenticate("root", "hunter2", &ctx).await.unwrap_err();
1919        assert!(matches!(err, AuthError::TooManyAttempts));
1920    }
1921
1922    #[tokio::test]
1923    async fn permissions_come_from_assigned_roles() {
1924        let (pool, _guard) = test_db().await;
1925        let user_id = seed_user(&pool, "mod", "pw", false).await;
1926        let role_id = store::create_role(
1927            &pool,
1928            "content_editor",
1929            "Content Editor",
1930            &["posts.*".to_string()],
1931        )
1932        .await
1933        .unwrap();
1934        store::assign_role(&pool, user_id, role_id).await.unwrap();
1935
1936        let svc = service(pool);
1937        let session = svc
1938            .authenticate("mod", "pw", &RequestContext::default())
1939            .await
1940            .unwrap();
1941        let identity = svc.verify_session(&session.token).await.unwrap();
1942
1943        assert!(identity.allows("posts.approve"));
1944        assert!(!identity.allows("users.edit"));
1945        identity.require("posts.edit").unwrap();
1946        assert!(identity.require("users.edit").is_err());
1947    }
1948
1949    #[tokio::test]
1950    async fn logout_invalidates_the_session() {
1951        let (pool, _guard) = test_db().await;
1952        seed_user(&pool, "root", "pw", true).await;
1953        let svc = service(pool);
1954
1955        let session = svc
1956            .authenticate("root", "pw", &RequestContext::default())
1957            .await
1958            .unwrap();
1959        svc.verify_session(&session.token).await.unwrap();
1960        svc.logout(&session.token).await.unwrap();
1961
1962        let err = svc.verify_session(&session.token).await.unwrap_err();
1963        assert!(matches!(err, AuthError::SessionInvalid));
1964    }
1965
1966    #[tokio::test]
1967    async fn inactive_account_is_refused_after_correct_password() {
1968        let (pool, _guard) = test_db().await;
1969        let user_id = seed_user(&pool, "root", "pw", false).await;
1970        // Deactivate through the query layer so the placeholder renders per backend.
1971        let (sql, values) = laterite_core::query::build(
1972            pool.backend,
1973            sea_query::Query::update()
1974                .table(crate::schema::BackendUsers::Table)
1975                .value(crate::schema::BackendUsers::IsActive, false)
1976                .and_where(sea_query::Expr::col(crate::schema::BackendUsers::Id).eq(user_id))
1977                .to_owned(),
1978        );
1979        laterite_core::query::bind_values(sqlx::query(&sql), values)
1980            .execute(&pool.pool)
1981            .await
1982            .unwrap();
1983
1984        let svc = service(pool);
1985        let err = svc
1986            .authenticate("root", "pw", &RequestContext::default())
1987            .await
1988            .unwrap_err();
1989        assert!(matches!(err, AuthError::InactiveAccount));
1990    }
1991
1992    #[tokio::test]
1993    async fn reset_password_updates_the_hash() {
1994        let (pool, _guard) = test_db().await;
1995        seed_user(&pool, "root", "oldpw", true).await;
1996
1997        let new_hash = password::hash_password("newpw").unwrap();
1998        let affected = store::update_password_by_username(&pool, "root", &new_hash)
1999            .await
2000            .unwrap();
2001        assert_eq!(affected, 1);
2002
2003        let svc = service(pool);
2004        let ctx = RequestContext::default();
2005        assert!(matches!(
2006            svc.authenticate("root", "oldpw", &ctx).await.unwrap_err(),
2007            AuthError::InvalidCredentials
2008        ));
2009        svc.authenticate("root", "newpw", &ctx).await.unwrap();
2010    }
2011
2012    #[tokio::test]
2013    async fn reset_password_reports_unknown_user() {
2014        let (pool, _guard) = test_db().await;
2015        let hash = password::hash_password("x").unwrap();
2016        let affected = store::update_password_by_username(&pool, "ghost", &hash)
2017            .await
2018            .unwrap();
2019        assert_eq!(affected, 0);
2020    }
2021
2022    #[tokio::test]
2023    async fn list_users_returns_all_seeded() {
2024        let (pool, _guard) = test_db().await;
2025        seed_user(&pool, "alice", "pw", true).await;
2026        seed_user(&pool, "bob", "pw", false).await;
2027
2028        let users = store::list_backend_users(&pool).await.unwrap();
2029        assert_eq!(users.len(), 2);
2030        assert!(users
2031            .iter()
2032            .any(|u| u.username == "alice" && u.is_superuser));
2033        assert!(users.iter().any(|u| u.username == "bob" && !u.is_superuser));
2034    }
2035
2036    #[tokio::test]
2037    async fn unlock_clears_the_lockout() {
2038        let (pool, _guard) = test_db().await;
2039        seed_user(&pool, "root", "pw", false).await;
2040        let svc = AuthService::new(
2041            pool.clone(),
2042            AuthConfig {
2043                max_failures: 3,
2044                ..AuthConfig::default()
2045            },
2046        );
2047        let ctx = RequestContext::default();
2048
2049        for _ in 0..3 {
2050            let _ = svc.authenticate("root", "bad", &ctx).await;
2051        }
2052        assert!(matches!(
2053            svc.authenticate("root", "pw", &ctx).await.unwrap_err(),
2054            AuthError::TooManyAttempts
2055        ));
2056
2057        let cleared = store::clear_failed_attempts(&pool, "root").await.unwrap();
2058        assert!(cleared >= 3);
2059        svc.authenticate("root", "pw", &ctx).await.unwrap();
2060    }
2061}