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::Db;
16
17use crate::error::AuthError;
18use crate::models::{AccessEvent, BackendUser};
19use crate::password;
20use crate::permission::PermissionSet;
21use crate::store;
22
23/// Tunable auth policy. Loadable from a config section (all keys optional; an unset
24/// key keeps its default):
25///
26/// ```toml
27/// [auth]
28/// session_idle_timeout_secs = 7200
29/// session_absolute_timeout_secs = 43200
30/// remember_duration_secs = 1209600
31/// max_failures = 5
32/// failure_window_secs = 900
33/// ```
34#[derive(Debug, Clone, Deserialize)]
35#[serde(default)]
36pub struct AuthConfig {
37    /// How long a session survives without a request. Activity past the halfway
38    /// mark of this window pushes the deadline out again.
39    #[serde(rename = "session_idle_timeout_secs", deserialize_with = "de_secs")]
40    pub session_idle_timeout: Duration,
41    /// The ceiling a session cannot be renewed past, counted from login. Bounds
42    /// how long a stolen token stays useful however busy the thief keeps it.
43    /// `session_ttl_secs` is the former name of this key and still reads.
44    #[serde(
45        rename = "session_absolute_timeout_secs",
46        alias = "session_ttl_secs",
47        deserialize_with = "de_secs"
48    )]
49    pub session_absolute_timeout: Duration,
50    /// How long a "stay signed in" credential lasts. It survives the session
51    /// ceiling: the point of it is to outlive the session and mint a new one.
52    #[serde(rename = "remember_duration_secs", deserialize_with = "de_secs")]
53    pub remember_duration: Duration,
54    /// Failed attempts within `failure_window` before a username is locked out.
55    pub max_failures: i64,
56    /// The window over which failed attempts are counted.
57    #[serde(rename = "failure_window_secs", deserialize_with = "de_secs")]
58    pub failure_window: Duration,
59}
60
61impl Default for AuthConfig {
62    fn default() -> Self {
63        Self {
64            session_idle_timeout: Duration::from_secs(60 * 60 * 2),
65            session_absolute_timeout: Duration::from_secs(60 * 60 * 12),
66            remember_duration: Duration::from_secs(60 * 60 * 24 * 14),
67            max_failures: 5,
68            failure_window: Duration::from_secs(60 * 15),
69        }
70    }
71}
72
73impl AuthConfig {
74    /// When a session created at `created_at` and last seen at `last_seen`
75    /// expires: the idle window measured from the last request, the ceiling
76    /// measured from login, whichever falls first.
77    fn deadline(&self, created_at: DateTime<Utc>, last_seen: DateTime<Utc>) -> DateTime<Utc> {
78        let idle = last_seen + chrono_from_std(self.session_idle_timeout);
79        let ceiling = created_at + chrono_from_std(self.session_absolute_timeout);
80        idle.min(ceiling)
81    }
82}
83
84/// Deserializes a whole-second count into a `Duration`.
85fn de_secs<'de, D: serde::Deserializer<'de>>(de: D) -> Result<Duration, D::Error> {
86    Ok(Duration::from_secs(u64::deserialize(de)?))
87}
88
89#[cfg(test)]
90mod auth_config_tests {
91    use super::AuthConfig;
92    use chrono::Utc;
93    use std::time::Duration;
94
95    #[test]
96    fn unset_keys_keep_defaults() {
97        let cfg: AuthConfig = serde_json::from_str(r#"{"max_failures": 3}"#).unwrap();
98        assert_eq!(cfg.max_failures, 3);
99        assert_eq!(cfg.session_idle_timeout, Duration::from_secs(60 * 60 * 2));
100        assert_eq!(
101            cfg.session_absolute_timeout,
102            Duration::from_secs(60 * 60 * 12)
103        );
104        assert_eq!(cfg.failure_window, Duration::from_secs(60 * 15));
105    }
106
107    #[test]
108    fn seconds_map_to_durations() {
109        let cfg: AuthConfig = serde_json::from_str(
110            r#"{"session_idle_timeout_secs": 1800, "session_absolute_timeout_secs": 3600,
111                 "max_failures": 7, "failure_window_secs": 120}"#,
112        )
113        .unwrap();
114        assert_eq!(cfg.session_idle_timeout, Duration::from_secs(1800));
115        assert_eq!(cfg.session_absolute_timeout, Duration::from_secs(3600));
116        assert_eq!(cfg.max_failures, 7);
117        assert_eq!(cfg.failure_window, Duration::from_secs(120));
118    }
119
120    #[test]
121    fn the_idle_window_ends_a_quiet_session_first() {
122        let cfg = AuthConfig::default();
123        let login = Utc::now();
124        // Seen just now, eleven hours into a twelve-hour ceiling: two hours idle
125        // still falls first.
126        let seen = login + chrono::Duration::hours(1);
127        assert_eq!(cfg.deadline(login, seen), seen + chrono::Duration::hours(2));
128    }
129
130    #[test]
131    fn the_ceiling_caps_a_session_kept_busy() {
132        let cfg = AuthConfig::default();
133        let login = Utc::now();
134        // Active at the eleventh hour: the idle window would reach thirteen
135        // hours, so the twelve-hour ceiling has to win.
136        let seen = login + chrono::Duration::hours(11);
137        assert_eq!(
138            cfg.deadline(login, seen),
139            login + chrono::Duration::hours(12)
140        );
141    }
142
143    #[test]
144    fn the_former_ttl_key_still_sets_the_ceiling() {
145        let cfg: AuthConfig = serde_json::from_str(r#"{"session_ttl_secs": 3600}"#).unwrap();
146        assert_eq!(cfg.session_absolute_timeout, Duration::from_secs(3600));
147    }
148}
149
150/// Per-request context recorded in the access log.
151#[derive(Debug, Clone, Default)]
152pub struct RequestContext {
153    pub ip_address: Option<String>,
154    pub user_agent: Option<String>,
155}
156
157/// A freshly issued session. `token` is the raw bearer value for the client
158/// cookie; only its hash is persisted.
159#[derive(Debug, Clone)]
160pub struct IssuedSession {
161    pub token: String,
162    pub expires_at: DateTime<Utc>,
163    /// Whose session it is, so the caller can issue a remember credential
164    /// without looking the user up again.
165    pub user_id: i64,
166}
167
168/// A freshly minted "stay signed in" credential. `cookie` is the raw
169/// `selector:verifier` value for the client; only the verifier's hash is kept.
170#[derive(Debug, Clone)]
171pub struct RememberCredential {
172    pub cookie: String,
173    pub expires_at: DateTime<Utc>,
174}
175
176/// What a presented remember cookie produced: a new session, and the
177/// replacement credential that must overwrite the cookie just used.
178#[derive(Debug, Clone)]
179pub struct RecalledSession {
180    pub session: IssuedSession,
181    pub remember: RememberCredential,
182}
183
184/// One of an account's live sessions, as the account's own sessions list shows
185/// it. `id` is derived from the stored key and is safe to put in a page: it
186/// names a row without being usable to authenticate as it.
187#[derive(Debug, Clone)]
188pub struct ActiveSession {
189    pub id: String,
190    pub created_at: DateTime<Utc>,
191    pub last_seen_at: DateTime<Utc>,
192    pub expires_at: DateTime<Utc>,
193    /// Where it signed in from, when the deployment could determine one.
194    pub ip_address: Option<String>,
195    /// The raw user agent as captured, for the caller to label as it likes.
196    pub user_agent: Option<String>,
197    /// Whether this is the session doing the asking.
198    pub current: bool,
199}
200
201/// An authenticated backend user together with the permissions in force.
202#[derive(Debug, Clone)]
203pub struct AuthenticatedUser {
204    pub user: BackendUser,
205    pub permissions: PermissionSet,
206}
207
208/// The acting operator as a record-layer actor, so a write attributes itself
209/// without the admin mapping it by hand.
210impl From<&AuthenticatedUser> for laterite_core::Actor {
211    fn from(user: &AuthenticatedUser) -> Self {
212        laterite_core::Actor::user(user.user.id, &user.user.username)
213    }
214}
215
216/// A resolved live session: the authenticated identity plus the opaque data
217/// blob the surface stored on it (`None` until the surface writes one). Auth
218/// does not interpret the blob.
219#[derive(Debug, Clone)]
220pub struct ResolvedSession {
221    pub identity: AuthenticatedUser,
222    pub data: Option<String>,
223    /// When the session now expires, whichever clock runs out first. The login
224    /// cookie is bounded by the ceiling, so this never outruns it.
225    pub expires_at: DateTime<Utc>,
226}
227
228impl AuthenticatedUser {
229    /// Whether this identity holds `permission`.
230    pub fn allows(&self, permission: &str) -> bool {
231        self.permissions.allows(permission)
232    }
233
234    /// Returns an error unless this identity holds `permission`.
235    pub fn require(&self, permission: &str) -> Result<(), AuthError> {
236        if self.allows(permission) {
237            Ok(())
238        } else {
239            Err(AuthError::PermissionDenied(permission.to_string()))
240        }
241    }
242}
243
244/// The details for creating a backend operator. Used by the CLI and the
245/// first-run setup screen so account creation has one code path.
246#[derive(Debug, Clone)]
247pub struct NewOperator<'a> {
248    pub username: &'a str,
249    pub email: &'a str,
250    pub first_name: &'a str,
251    pub last_name: Option<&'a str>,
252    pub password: &'a str,
253    /// The operator's display timezone (an IANA name), or `None` to inherit the
254    /// deployment default.
255    pub timezone: Option<&'a str>,
256}
257
258/// One append-only audit entry: who did what, to which target. Actions are
259/// dot-keyed (`backend.role.update`); the username is snapshotted so the entry
260/// stays legible after the actor's account is removed.
261pub struct AuditEntry<'a> {
262    /// The acting operator's id, or `None` for a system action.
263    pub actor_user_id: Option<i64>,
264    /// The acting operator's username, recorded verbatim.
265    pub actor_username: &'a str,
266    /// A dot-keyed action, e.g. `backend.plugin.disable`.
267    pub action: &'a str,
268    /// What was acted on (e.g. `backend_role`) and its id; both optional.
269    pub target_type: Option<&'a str>,
270    pub target_id: Option<&'a str>,
271    /// Optional JSON describing the change.
272    pub detail: Option<&'a str>,
273}
274
275/// The auth service. Cheap to clone: it holds a database handle and config.
276#[derive(Clone)]
277pub struct AuthService {
278    db: Db,
279    config: AuthConfig,
280}
281
282impl AuthService {
283    pub fn new(db: Db, config: AuthConfig) -> Self {
284        Self { db, config }
285    }
286
287    /// How long a session stays valid. A caller that persists the session in a
288    /// cookie matches its lifetime to this, so the two cannot disagree.
289    pub fn session_absolute_timeout(&self) -> Duration {
290        self.config.session_absolute_timeout
291    }
292
293    /// Verifies a username and password, and on success issues a session.
294    ///
295    /// Failures are throttled per username and every outcome is logged. The
296    /// error deliberately does not reveal whether the username exists.
297    pub async fn authenticate(
298        &self,
299        username: &str,
300        password: &str,
301        ctx: &RequestContext,
302    ) -> Result<IssuedSession, AuthError> {
303        let now = Utc::now();
304        let since = now - chrono_from_std(self.config.failure_window);
305
306        if store::count_recent_failures(&self.db, username, since).await?
307            >= self.config.max_failures
308        {
309            self.log(None, username, AccessEvent::LockedOut, ctx)
310                .await?;
311            return Err(AuthError::TooManyAttempts);
312        }
313
314        let user = match store::find_user_by_username(&self.db, username).await? {
315            Some(user) => user,
316            None => {
317                self.log(None, username, AccessEvent::LoginFailure, ctx)
318                    .await?;
319                return Err(AuthError::InvalidCredentials);
320            }
321        };
322
323        if !password::verify_password(password, &user.password_hash)? {
324            self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
325                .await?;
326            return Err(AuthError::InvalidCredentials);
327        }
328
329        if !user.is_active {
330            self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
331                .await?;
332            return Err(AuthError::InactiveAccount);
333        }
334
335        let token = generate_token();
336        let expires_at = self.config.deadline(now, now);
337        store::insert_session(
338            &self.db,
339            &hash_token(&token),
340            user.id,
341            expires_at,
342            ctx.ip_address.as_deref(),
343            ctx.user_agent.as_deref(),
344        )
345        .await?;
346        self.log(Some(user.id), username, AccessEvent::LoginSuccess, ctx)
347            .await?;
348
349        Ok(IssuedSession {
350            token,
351            expires_at,
352            user_id: user.id,
353        })
354    }
355
356    /// Resolves a raw session token to an identity and its stored data blob,
357    /// refreshing last-seen. Expired sessions, and sessions whose user was
358    /// disabled or removed, resolve to [`AuthError::SessionInvalid`]. The blob
359    /// is read in the same query as the session, adding no round-trip.
360    pub async fn resolve_session(&self, token: &str) -> Result<ResolvedSession, AuthError> {
361        let token_hash = hash_token(token);
362        let now = Utc::now();
363
364        let session = store::find_valid_session(&self.db, &token_hash, now)
365            .await?
366            .ok_or(AuthError::SessionInvalid)?;
367        let user = store::find_active_user_by_id(&self.db, session.user_id)
368            .await?
369            .ok_or(AuthError::SessionInvalid)?;
370        // Renewing on every request would cost a write per request for a
371        // deadline that moves by seconds. Past the halfway mark of the idle
372        // window the write buys real time, so that is where it happens.
373        let idle = chrono_from_std(self.config.session_idle_timeout);
374        let expires_at = self.config.deadline(session.created_at, now);
375        if now - session.last_seen_at > idle / 2 {
376            store::renew_session(&self.db, &token_hash, now, expires_at).await?;
377        }
378
379        let grants = store::load_role_permissions(&self.db, user.id)
380            .await?
381            .into_iter()
382            .flatten();
383        // Split the user's per-permission overrides into allow (1) and deny (-1),
384        // which take precedence over the role grants.
385        let overrides = store::load_user_permission_overrides(&self.db, user.id).await?;
386        let (mut allow, mut deny) = (Vec::new(), Vec::new());
387        for (code, decision) in overrides {
388            match decision.signum() {
389                1 => allow.push(code),
390                -1 => deny.push(code),
391                _ => {}
392            }
393        }
394        let permissions = PermissionSet::with_overrides(user.is_superuser, grants, allow, deny);
395
396        Ok(ResolvedSession {
397            identity: AuthenticatedUser { user, permissions },
398            data: session.data,
399            expires_at,
400        })
401    }
402
403    /// Resolves a raw session token to an identity. A thin wrapper over
404    /// [`AuthService::resolve_session`] for callers that need only the identity.
405    pub async fn verify_session(&self, token: &str) -> Result<AuthenticatedUser, AuthError> {
406        Ok(self.resolve_session(token).await?.identity)
407    }
408
409    /// Overwrites the opaque per-session data blob (the surface's serialised
410    /// state, e.g. CSRF token + flash). The surface writes only when its blob
411    /// changed, so an unchanged request adds no write.
412    pub async fn set_session_data(&self, token: &str, data: &str) -> Result<(), AuthError> {
413        store::set_session_data(&self.db, &hash_token(token), data).await
414    }
415
416    /// Issues a "stay signed in" credential for `user_id`, one row per device.
417    pub async fn issue_remember(&self, user_id: i64) -> Result<RememberCredential, AuthError> {
418        let (selector, verifier) = (generate_token(), generate_token());
419        let expires_at = Utc::now() + chrono_from_std(self.config.remember_duration);
420        store::insert_remember_token(
421            &self.db,
422            &selector,
423            &hash_token(&verifier),
424            user_id,
425            expires_at,
426        )
427        .await?;
428        Ok(RememberCredential {
429            cookie: format!("{selector}:{verifier}"),
430            expires_at,
431        })
432    }
433
434    /// Trades a presented remember cookie for a fresh session and a replacement
435    /// credential, rotating the stored row so a cookie is single-use.
436    ///
437    /// A selector that resolves with the wrong verifier means a copy of the
438    /// cookie is in circulation: one of the two holders used it first and
439    /// rotated it, and this is the other. Every credential the user holds is
440    /// dropped, which signs out the thief at the cost of signing out the owner.
441    pub async fn consume_remember(
442        &self,
443        cookie: &str,
444        ctx: &RequestContext,
445    ) -> Result<RecalledSession, AuthError> {
446        let (selector, verifier) = cookie.split_once(':').ok_or(AuthError::SessionInvalid)?;
447        let now = Utc::now();
448        let found = store::find_remember_token(&self.db, selector, now)
449            .await?
450            .ok_or(AuthError::SessionInvalid)?;
451
452        if !constant_time_eq(&hash_token(verifier), &found.verifier_hash) {
453            tracing::warn!(
454                user_id = found.user_id,
455                "a stay-signed-in cookie was presented with a stale secret;                  dropping every credential for this account"
456            );
457            store::delete_user_remember_tokens(&self.db, found.user_id).await?;
458            self.log(Some(found.user_id), "", AccessEvent::LoginFailure, ctx)
459                .await?;
460            return Err(AuthError::SessionInvalid);
461        }
462
463        // A disabled or removed account must not be recalled back in.
464        let user = store::find_active_user_by_id(&self.db, found.user_id)
465            .await?
466            .ok_or(AuthError::SessionInvalid)?;
467
468        // Rotate in place. The selector stays, so a copy of the spent cookie
469        // comes back as a mismatch on a known series rather than as a stranger,
470        // which is the only thing that makes theft visible at all.
471        let replacement = generate_token();
472        let remember_expires = now + chrono_from_std(self.config.remember_duration);
473        store::rotate_remember_token(
474            &self.db,
475            selector,
476            &hash_token(&replacement),
477            remember_expires,
478        )
479        .await?;
480        let remember = RememberCredential {
481            cookie: format!("{selector}:{replacement}"),
482            expires_at: remember_expires,
483        };
484
485        let token = generate_token();
486        let expires_at = self.config.deadline(now, now);
487        store::insert_session(
488            &self.db,
489            &hash_token(&token),
490            user.id,
491            expires_at,
492            ctx.ip_address.as_deref(),
493            ctx.user_agent.as_deref(),
494        )
495        .await?;
496        self.log(
497            Some(user.id),
498            &user.username,
499            AccessEvent::LoginSuccess,
500            ctx,
501        )
502        .await?;
503
504        Ok(RecalledSession {
505            session: IssuedSession {
506                token,
507                expires_at,
508                user_id: user.id,
509            },
510            remember,
511        })
512    }
513
514    /// Drops the credential a cookie names. Unknown or malformed values are a
515    /// no-op, so a stale cookie on logout is not an error.
516    pub async fn revoke_remember(&self, cookie: &str) -> Result<(), AuthError> {
517        if let Some((selector, _)) = cookie.split_once(':') {
518            store::delete_remember_token(&self.db, selector).await?;
519        }
520        Ok(())
521    }
522
523    /// Lists the account's live sessions, newest activity first, marking the one
524    /// `current_token` belongs to.
525    pub async fn list_sessions(
526        &self,
527        user_id: i64,
528        current_token: &str,
529    ) -> Result<Vec<ActiveSession>, AuthError> {
530        let current_hash = hash_token(current_token);
531        let rows = store::list_user_sessions(&self.db, user_id, Utc::now()).await?;
532        Ok(rows
533            .into_iter()
534            .map(|s| ActiveSession {
535                id: public_session_id(&s.token_hash),
536                created_at: s.created_at,
537                last_seen_at: s.last_seen_at,
538                expires_at: s.expires_at,
539                ip_address: s.ip_address,
540                user_agent: s.user_agent,
541                current: s.token_hash == current_hash,
542            })
543            .collect())
544    }
545
546    /// Ends one of the account's sessions by its public id. Returns whether a
547    /// session matched. Scoped to `user_id`, so the worst a forged id can do is
548    /// end one of the caller's own sessions.
549    pub async fn revoke_session(&self, user_id: i64, id: &str) -> Result<bool, AuthError> {
550        let rows = store::list_user_sessions(&self.db, user_id, Utc::now()).await?;
551        let Some(target) = rows
552            .into_iter()
553            .find(|s| constant_time_eq(&public_session_id(&s.token_hash), id))
554        else {
555            return Ok(false);
556        };
557        let done = store::delete_user_session(&self.db, user_id, &target.token_hash).await?;
558        Ok(done > 0)
559    }
560
561    /// Ends every session but the caller's, and drops every stay-signed-in
562    /// credential the account holds. The credentials have to go too: leaving
563    /// them would let any signed-out device mint itself a new session on its
564    /// next request, which is the opposite of what this asks for.
565    pub async fn sign_out_everywhere(
566        &self,
567        user_id: i64,
568        keep_token: &str,
569    ) -> Result<u64, AuthError> {
570        let ended =
571            store::delete_user_sessions_except(&self.db, user_id, &hash_token(keep_token)).await?;
572        store::delete_user_remember_tokens(&self.db, user_id).await?;
573        Ok(ended)
574    }
575
576    /// Activates or deactivates a backend user.
577    ///
578    /// Deactivating is the reversible counterpart to deleting an operator, which
579    /// the panel does not offer: the account keeps its history, its audit trail
580    /// and its permission overrides, and simply cannot sign in.
581    ///
582    /// Two deactivations are refused, because both end with a panel somebody has
583    /// to repair from the command line: your own account, and the last active
584    /// superuser. Both return [`AuthError::Refused`] with the reason.
585    ///
586    /// A deactivated account's sessions and stay-signed-in credentials are
587    /// dropped in the same call. An account that may not sign in must not remain
588    /// signed in on a machine somewhere, which is the whole point of revoking it.
589    pub async fn set_user_active(
590        &self,
591        actor_id: i64,
592        user_id: i64,
593        active: bool,
594    ) -> Result<(), AuthError> {
595        if !active {
596            if actor_id == user_id {
597                return Err(AuthError::Refused(
598                    "You cannot deactivate your own account.".to_string(),
599                ));
600            }
601            let user = store::find_user_by_id(&self.db, user_id)
602                .await?
603                .ok_or(AuthError::SessionInvalid)?;
604            if user.is_superuser && store::other_active_superusers(&self.db, user_id).await? == 0 {
605                return Err(AuthError::Refused(
606                    "This is the last active superuser; the panel would have no \
607                     administrator left."
608                        .to_string(),
609                ));
610            }
611        }
612
613        if store::set_user_active(&self.db, user_id, active).await? == 0 {
614            return Err(AuthError::SessionInvalid);
615        }
616        if !active {
617            store::delete_all_user_sessions(&self.db, user_id).await?;
618            store::delete_user_remember_tokens(&self.db, user_id).await?;
619        }
620        Ok(())
621    }
622
623    /// Invalidates a session. Unknown tokens are a no-op.
624    pub async fn logout(&self, token: &str) -> Result<(), AuthError> {
625        store::delete_session(&self.db, &hash_token(token)).await
626    }
627
628    /// Persists an operator's own display timezone. `Some(name)` sets an IANA
629    /// timezone; `None` clears it so the operator falls back to the deployment
630    /// default. Validating that `name` is a real timezone is the caller's job.
631    pub async fn set_user_timezone(
632        &self,
633        user_id: i64,
634        timezone: Option<&str>,
635    ) -> Result<(), AuthError> {
636        store::set_user_timezone(&self.db, user_id, timezone).await
637    }
638
639    /// Persists an operator's own UI locale. `Some(tag)` sets a base language tag;
640    /// `None` clears it so the operator falls back to the deployment default.
641    /// Validating that `tag` is a supported locale is the caller's job.
642    pub async fn set_user_locale(
643        &self,
644        user_id: i64,
645        locale: Option<&str>,
646    ) -> Result<(), AuthError> {
647        store::set_user_locale(&self.db, user_id, locale).await
648    }
649
650    /// Loads a user's per-permission overrides (code to `1` allow or `-1` deny).
651    pub async fn user_permission_overrides(
652        &self,
653        user_id: i64,
654    ) -> Result<std::collections::HashMap<String, i64>, AuthError> {
655        store::load_user_permission_overrides(&self.db, user_id).await
656    }
657
658    /// Replaces a user's per-permission overrides. Callers pass only `1` and `-1`
659    /// entries; an inherited permission is represented by its absence.
660    pub async fn set_user_permissions(
661        &self,
662        user_id: i64,
663        overrides: &std::collections::HashMap<String, i64>,
664    ) -> Result<(), AuthError> {
665        store::set_user_permissions(&self.db, user_id, overrides).await
666    }
667
668    /// Appends an entry to the append-only audit log. Mutating admin paths call
669    /// this after a successful change, so actions that affect privileges or data
670    /// are recorded. Self-service preference changes are not audited.
671    pub async fn record_audit(&self, entry: AuditEntry<'_>) -> Result<(), AuthError> {
672        store::insert_audit_log(
673            &self.db,
674            entry.actor_user_id,
675            entry.actor_username,
676            entry.action,
677            entry.target_type,
678            entry.target_id,
679            entry.detail,
680        )
681        .await
682    }
683
684    /// The most recent audit entries, newest first, for the admin audit view.
685    pub async fn recent_audit(&self, limit: u64) -> Result<Vec<store::AuditRecord>, AuthError> {
686        store::recent_audit(&self.db, limit).await
687    }
688
689    /// Whether any backend operator exists yet. A fresh install with none is
690    /// routed to first-run setup instead of login.
691    pub async fn has_any_operator(&self) -> Result<bool, AuthError> {
692        store::any_user_exists(&self.db).await
693    }
694
695    /// Creates a superuser operator: hashes the password, inserts the user, and
696    /// records their timezone preference. The single account-creation path,
697    /// shared by the CLI and the first-run setup screen.
698    pub async fn create_superuser(&self, new: NewOperator<'_>) -> Result<i64, AuthError> {
699        let hash = password::hash_password(new.password)?;
700        let id = store::create_user(
701            &self.db,
702            new.username,
703            new.email,
704            new.first_name,
705            new.last_name,
706            &hash,
707            true,
708        )
709        .await?;
710        if new.timezone.is_some() {
711            store::set_user_timezone(&self.db, id, new.timezone).await?;
712        }
713        Ok(id)
714    }
715
716    async fn log(
717        &self,
718        user_id: Option<i64>,
719        username: &str,
720        event: AccessEvent,
721        ctx: &RequestContext,
722    ) -> Result<(), AuthError> {
723        store::insert_access_log(
724            &self.db,
725            user_id,
726            username,
727            event,
728            ctx.ip_address.as_deref(),
729            ctx.user_agent.as_deref(),
730        )
731        .await
732    }
733}
734
735/// Converts a small, in-range `std::time::Duration` to `chrono::Duration`.
736/// The auth policy durations are hours at most, well within range.
737fn chrono_from_std(d: Duration) -> chrono::Duration {
738    chrono::Duration::from_std(d).expect("auth policy duration out of range")
739}
740
741fn generate_token() -> String {
742    let mut bytes = [0u8; 32];
743    rand::rngs::OsRng.fill_bytes(&mut bytes);
744    to_hex(&bytes)
745}
746
747/// A page-safe name for a session row. Hashing the stored key again means the
748/// id identifies a session without being the key: even if a rendered page leaks,
749/// nothing in it can be presented as a session or looked up as one.
750fn public_session_id(token_hash: &str) -> String {
751    let mut hasher = Sha256::new();
752    hasher.update(b"laterite:session-id:");
753    hasher.update(token_hash.as_bytes());
754    to_hex(&hasher.finalize())
755}
756
757/// Compares two equal-length hex digests without an early return.
758fn constant_time_eq(a: &str, b: &str) -> bool {
759    let (a, b) = (a.as_bytes(), b.as_bytes());
760    if a.len() != b.len() {
761        return false;
762    }
763    a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
764}
765
766fn hash_token(token: &str) -> String {
767    let mut hasher = Sha256::new();
768    hasher.update(token.as_bytes());
769    to_hex(&hasher.finalize())
770}
771
772fn to_hex(bytes: &[u8]) -> String {
773    let mut out = String::with_capacity(bytes.len() * 2);
774    for byte in bytes {
775        let _ = write!(out, "{byte:02x}");
776    }
777    out
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783
784    async fn seed_user(db: &Db, username: &str, password: &str, superuser: bool) -> i64 {
785        let hash = password::hash_password(password).unwrap();
786        store::create_user(
787            db,
788            username,
789            &format!("{username}@example.test"),
790            "Test",
791            Some("Operator"),
792            &hash,
793            superuser,
794        )
795        .await
796        .unwrap()
797    }
798
799    fn service(db: Db) -> AuthService {
800        AuthService::new(db, AuthConfig::default())
801    }
802
803    /// A fresh test database with this module's migrations applied through the
804    /// framework runner, on whichever backend the run targets (see
805    /// `laterite_core::testing`). Hold the returned guard for the test's lifetime.
806    async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
807        laterite_core::testing::connect_test(&[crate::migrations()]).await
808    }
809
810    /// Backdates a session's idle clock, standing in for time passing.
811    async fn set_last_seen(db: &Db, token: &str, seen: DateTime<Utc>) {
812        store::renew_session(
813            db,
814            &hash_token(token),
815            seen,
816            seen + chrono::Duration::hours(2),
817        )
818        .await
819        .unwrap();
820    }
821
822    async fn last_seen(db: &Db, token: &str) -> DateTime<Utc> {
823        store::find_valid_session(db, &hash_token(token), Utc::now())
824            .await
825            .unwrap()
826            .expect("session still valid")
827            .last_seen_at
828    }
829
830    #[tokio::test]
831    async fn a_request_past_the_halfway_mark_renews_the_session() {
832        let (pool, _guard) = test_db().await;
833        seed_user(&pool, "root", "hunter2", true).await;
834        let svc = service(pool.clone());
835
836        let issued = svc
837            .authenticate("root", "hunter2", &RequestContext::default())
838            .await
839            .unwrap();
840        // Ninety minutes of quiet, past the one-hour halfway mark of the
841        // two-hour idle window.
842        let stale = Utc::now() - chrono::Duration::minutes(90);
843        set_last_seen(&pool, &issued.token, stale).await;
844
845        let resolved = svc.resolve_session(&issued.token).await.unwrap();
846
847        assert!(
848            last_seen(&pool, &issued.token).await > stale,
849            "a request past the halfway mark should push the idle clock"
850        );
851        assert!(
852            resolved.expires_at > Utc::now() + chrono::Duration::minutes(115),
853            "renewal should restore very nearly the full idle window"
854        );
855    }
856
857    #[tokio::test]
858    async fn verify_session_renews_like_the_path_it_wraps() {
859        let (pool, _guard) = test_db().await;
860        seed_user(&pool, "root", "hunter2", true).await;
861        let svc = service(pool.clone());
862
863        let issued = svc
864            .authenticate("root", "hunter2", &RequestContext::default())
865            .await
866            .unwrap();
867        let stale = Utc::now() - chrono::Duration::minutes(90);
868        set_last_seen(&pool, &issued.token, stale).await;
869
870        svc.verify_session(&issued.token).await.unwrap();
871
872        // The thin wrapper must not become a way to read a session without
873        // ageing it; that is the whole reason the store's session functions
874        // are crate-visible.
875        assert!(last_seen(&pool, &issued.token).await > stale);
876    }
877
878    #[tokio::test]
879    async fn a_request_before_the_halfway_mark_writes_nothing() {
880        let (pool, _guard) = test_db().await;
881        seed_user(&pool, "root", "hunter2", true).await;
882        let svc = service(pool.clone());
883
884        let issued = svc
885            .authenticate("root", "hunter2", &RequestContext::default())
886            .await
887            .unwrap();
888        // Ten minutes of quiet: nowhere near the halfway mark, so the write
889        // would buy minutes and cost a round-trip on every request.
890        let recent = Utc::now() - chrono::Duration::minutes(10);
891        set_last_seen(&pool, &issued.token, recent).await;
892
893        svc.resolve_session(&issued.token).await.unwrap();
894
895        assert_eq!(
896            last_seen(&pool, &issued.token).await.timestamp(),
897            recent.timestamp(),
898            "a request inside the halfway mark should leave the row alone"
899        );
900    }
901
902    #[tokio::test]
903    async fn a_session_past_its_idle_window_no_longer_resolves() {
904        let (pool, _guard) = test_db().await;
905        seed_user(&pool, "root", "hunter2", true).await;
906        let svc = service(pool.clone());
907
908        let issued = svc
909            .authenticate("root", "hunter2", &RequestContext::default())
910            .await
911            .unwrap();
912        // renew_session writes last-seen and the deadline together, so a
913        // backdated pair is exactly what an abandoned session looks like.
914        let long_ago = Utc::now() - chrono::Duration::hours(3);
915        set_last_seen(&pool, &issued.token, long_ago).await;
916
917        assert!(matches!(
918            svc.resolve_session(&issued.token).await,
919            Err(AuthError::SessionInvalid)
920        ));
921    }
922
923    #[tokio::test]
924    async fn a_remember_cookie_is_single_use_and_rotates() {
925        let (pool, _guard) = test_db().await;
926        let uid = seed_user(&pool, "root", "hunter2", true).await;
927        let svc = service(pool);
928
929        let first = svc.issue_remember(uid).await.unwrap();
930        let recalled = svc
931            .consume_remember(&first.cookie, &RequestContext::default())
932            .await
933            .unwrap();
934
935        assert_ne!(
936            recalled.remember.cookie, first.cookie,
937            "using a credential must replace its secret"
938        );
939        assert_eq!(
940            recalled.remember.cookie.split_once(':').unwrap().0,
941            first.cookie.split_once(':').unwrap().0,
942            "the selector is the series and must survive rotation"
943        );
944        // The session it minted is real.
945        svc.verify_session(&recalled.session.token).await.unwrap();
946        // And the spent cookie is dead, so a copy taken from a stolen laptop
947        // buys nothing once the owner's browser has used it.
948        assert!(matches!(
949            svc.consume_remember(&first.cookie, &RequestContext::default())
950                .await,
951            Err(AuthError::SessionInvalid)
952        ));
953    }
954
955    #[tokio::test]
956    async fn a_stale_secret_drops_every_credential_the_user_holds() {
957        let (pool, _guard) = test_db().await;
958        let uid = seed_user(&pool, "root", "hunter2", true).await;
959        let svc = service(pool);
960
961        let stolen = svc.issue_remember(uid).await.unwrap();
962        let other_device = svc.issue_remember(uid).await.unwrap();
963        // The owner's browser uses it first, rotating the row.
964        let owner = svc
965            .consume_remember(&stolen.cookie, &RequestContext::default())
966            .await
967            .unwrap();
968
969        // The thief presents the copy they took. Same selector, stale secret:
970        // proof a cookie is in two places.
971        let selector = stolen.cookie.split_once(':').unwrap().0;
972        let forged = format!("{selector}:{}", generate_token());
973        assert!(matches!(
974            svc.consume_remember(&forged, &RequestContext::default())
975                .await,
976            Err(AuthError::SessionInvalid)
977        ));
978
979        // Everything is revoked, the owner's fresh credential included. Signing
980        // the owner out is the price of signing the thief out.
981        for credential in [owner.remember.cookie, other_device.cookie] {
982            assert!(matches!(
983                svc.consume_remember(&credential, &RequestContext::default())
984                    .await,
985                Err(AuthError::SessionInvalid)
986            ));
987        }
988    }
989
990    #[tokio::test]
991    async fn the_sessions_list_marks_the_one_asking() {
992        let (pool, _guard) = test_db().await;
993        seed_user(&pool, "root", "hunter2", true).await;
994        let svc = service(pool);
995
996        let first = svc
997            .authenticate("root", "hunter2", &RequestContext::default())
998            .await
999            .unwrap();
1000        let second = svc
1001            .authenticate("root", "hunter2", &RequestContext::default())
1002            .await
1003            .unwrap();
1004
1005        let listed = svc
1006            .list_sessions(first.user_id, &second.token)
1007            .await
1008            .unwrap();
1009        assert_eq!(listed.len(), 2);
1010        assert_eq!(listed.iter().filter(|s| s.current).count(), 1);
1011        // The public id names a row without being the row's key.
1012        assert!(listed.iter().all(|s| !s.id.is_empty()));
1013    }
1014
1015    #[tokio::test]
1016    async fn one_account_cannot_revoke_anothers_session() {
1017        let (pool, _guard) = test_db().await;
1018        seed_user(&pool, "root", "hunter2", true).await;
1019        seed_user(&pool, "mallory", "hunter2", false).await;
1020        let svc = service(pool);
1021
1022        let victim = svc
1023            .authenticate("root", "hunter2", &RequestContext::default())
1024            .await
1025            .unwrap();
1026        let attacker = svc
1027            .authenticate("mallory", "hunter2", &RequestContext::default())
1028            .await
1029            .unwrap();
1030
1031        // The attacker has somehow learned the victim's public session id.
1032        let victims_id = svc
1033            .list_sessions(victim.user_id, &victim.token)
1034            .await
1035            .unwrap()
1036            .remove(0)
1037            .id;
1038
1039        assert!(
1040            !svc.revoke_session(attacker.user_id, &victims_id)
1041                .await
1042                .unwrap(),
1043            "a revoke is scoped to its own account"
1044        );
1045        // And the victim is still signed in.
1046        svc.verify_session(&victim.token).await.unwrap();
1047    }
1048
1049    #[tokio::test]
1050    async fn signing_out_everywhere_keeps_this_one_and_drops_credentials() {
1051        let (pool, _guard) = test_db().await;
1052        let uid = seed_user(&pool, "root", "hunter2", true).await;
1053        let svc = service(pool);
1054
1055        let keep = svc
1056            .authenticate("root", "hunter2", &RequestContext::default())
1057            .await
1058            .unwrap();
1059        let other = svc
1060            .authenticate("root", "hunter2", &RequestContext::default())
1061            .await
1062            .unwrap();
1063        let credential = svc.issue_remember(uid).await.unwrap();
1064
1065        let ended = svc.sign_out_everywhere(uid, &keep.token).await.unwrap();
1066
1067        assert_eq!(ended, 1);
1068        svc.verify_session(&keep.token).await.unwrap();
1069        assert!(matches!(
1070            svc.verify_session(&other.token).await,
1071            Err(AuthError::SessionInvalid)
1072        ));
1073        // Leaving the credential would let the signed-out device mint a new
1074        // session on its very next request.
1075        assert!(matches!(
1076            svc.consume_remember(&credential.cookie, &RequestContext::default())
1077                .await,
1078            Err(AuthError::SessionInvalid)
1079        ));
1080    }
1081
1082    #[tokio::test]
1083    async fn revoking_a_credential_ends_it() {
1084        let (pool, _guard) = test_db().await;
1085        let uid = seed_user(&pool, "root", "hunter2", true).await;
1086        let svc = service(pool);
1087
1088        let credential = svc.issue_remember(uid).await.unwrap();
1089        svc.revoke_remember(&credential.cookie).await.unwrap();
1090
1091        assert!(matches!(
1092            svc.consume_remember(&credential.cookie, &RequestContext::default())
1093                .await,
1094            Err(AuthError::SessionInvalid)
1095        ));
1096    }
1097
1098    #[tokio::test]
1099    async fn deactivating_blocks_sign_in_and_ends_the_sessions() {
1100        let (pool, _guard) = test_db().await;
1101        let admin = seed_user(&pool, "root", "hunter2", true).await;
1102        let target = seed_user(&pool, "editor", "hunter2", false).await;
1103        let svc = service(pool.clone());
1104
1105        let issued = svc
1106            .authenticate("editor", "hunter2", &RequestContext::default())
1107            .await
1108            .unwrap();
1109        let credential = svc.issue_remember(target).await.unwrap();
1110
1111        svc.set_user_active(admin, target, false).await.unwrap();
1112
1113        // Cannot sign in again.
1114        assert!(matches!(
1115            svc.authenticate("editor", "hunter2", &RequestContext::default())
1116                .await,
1117            Err(AuthError::InactiveAccount)
1118        ));
1119        // And is not left signed in where they already were: an account that may
1120        // not sign in must not stay signed in.
1121        assert!(matches!(
1122            svc.verify_session(&issued.token).await,
1123            Err(AuthError::SessionInvalid)
1124        ));
1125        assert!(matches!(
1126            svc.consume_remember(&credential.cookie, &RequestContext::default())
1127                .await,
1128            Err(AuthError::SessionInvalid)
1129        ));
1130    }
1131
1132    #[tokio::test]
1133    async fn reactivating_lets_them_back_in() {
1134        let (pool, _guard) = test_db().await;
1135        let admin = seed_user(&pool, "root", "hunter2", true).await;
1136        let target = seed_user(&pool, "editor", "hunter2", false).await;
1137        let svc = service(pool);
1138
1139        svc.set_user_active(admin, target, false).await.unwrap();
1140        svc.set_user_active(admin, target, true).await.unwrap();
1141
1142        // Reversible is the whole reason this exists instead of deletion.
1143        svc.authenticate("editor", "hunter2", &RequestContext::default())
1144            .await
1145            .unwrap();
1146    }
1147
1148    #[tokio::test]
1149    async fn an_operator_cannot_deactivate_themselves() {
1150        let (pool, _guard) = test_db().await;
1151        let admin = seed_user(&pool, "root", "hunter2", true).await;
1152        let svc = service(pool);
1153
1154        // Locking yourself out of the panel you are administering is never the
1155        // intent, and the repair is a command line.
1156        assert!(matches!(
1157            svc.set_user_active(admin, admin, false).await,
1158            Err(AuthError::Refused(_))
1159        ));
1160    }
1161
1162    #[tokio::test]
1163    async fn the_last_superuser_cannot_be_deactivated() {
1164        let (pool, _guard) = test_db().await;
1165        let only = seed_user(&pool, "root", "hunter2", true).await;
1166        let editor = seed_user(&pool, "editor", "hunter2", false).await;
1167        let svc = service(pool.clone());
1168
1169        // An ordinary operator acting on the only superuser still cannot leave
1170        // the panel without one.
1171        assert!(matches!(
1172            svc.set_user_active(editor, only, false).await,
1173            Err(AuthError::Refused(_))
1174        ));
1175
1176        // With a second superuser, the first may be deactivated.
1177        let second = seed_user(&pool, "root2", "hunter2", true).await;
1178        svc.set_user_active(second, only, false).await.unwrap();
1179    }
1180
1181    #[tokio::test]
1182    async fn audit_entries_record_and_read_back_newest_first() {
1183        let (pool, _guard) = test_db().await;
1184        let actor = seed_user(&pool, "root", "hunter2", true).await;
1185        let svc = service(pool);
1186
1187        svc.record_audit(AuditEntry {
1188            actor_user_id: Some(actor),
1189            actor_username: "root",
1190            action: "backend.role.update",
1191            target_type: Some("backend_role"),
1192            target_id: Some("42"),
1193            detail: Some(r#"{"added":["a.b"]}"#),
1194        })
1195        .await
1196        .unwrap();
1197        svc.record_audit(AuditEntry {
1198            actor_user_id: Some(actor),
1199            actor_username: "root",
1200            action: "backend.plugin.disable",
1201            target_type: Some("plugin"),
1202            target_id: Some("acme.widgets"),
1203            detail: None,
1204        })
1205        .await
1206        .unwrap();
1207
1208        let entries = svc.recent_audit(10).await.unwrap();
1209        assert_eq!(entries.len(), 2);
1210        // Newest first.
1211        assert_eq!(entries[0].action, "backend.plugin.disable");
1212        assert_eq!(entries[0].target_id.as_deref(), Some("acme.widgets"));
1213        assert_eq!(entries[0].detail, None);
1214        assert_eq!(entries[1].action, "backend.role.update");
1215        assert_eq!(entries[1].actor_username, "root");
1216        assert_eq!(entries[1].actor_user_id, Some(actor));
1217        assert_eq!(entries[1].detail.as_deref(), Some(r#"{"added":["a.b"]}"#));
1218    }
1219
1220    #[tokio::test]
1221    async fn authenticate_issues_a_verifiable_session() {
1222        let (pool, _guard) = test_db().await;
1223        seed_user(&pool, "root", "hunter2", true).await;
1224        let svc = service(pool);
1225
1226        let session = svc
1227            .authenticate("root", "hunter2", &RequestContext::default())
1228            .await
1229            .expect("login should succeed");
1230        let identity = svc
1231            .verify_session(&session.token)
1232            .await
1233            .expect("session should resolve");
1234
1235        assert_eq!(identity.user.username, "root");
1236        assert_eq!(identity.user.full_name(), "Test Operator");
1237        assert!(identity.allows("anything.superuser.can.do"));
1238    }
1239
1240    #[tokio::test]
1241    async fn session_data_blob_round_trips() {
1242        let (pool, _guard) = test_db().await;
1243        seed_user(&pool, "root", "hunter2", true).await;
1244        let svc = service(pool);
1245
1246        let session = svc
1247            .authenticate("root", "hunter2", &RequestContext::default())
1248            .await
1249            .unwrap();
1250
1251        // A fresh session has no blob.
1252        let resolved = svc.resolve_session(&session.token).await.unwrap();
1253        assert_eq!(resolved.data, None);
1254        assert_eq!(resolved.identity.user.username, "root");
1255
1256        // The surface writes its opaque state; the next resolve reads it back.
1257        svc.set_session_data(&session.token, r#"{"v":1,"csrf":"abc"}"#)
1258            .await
1259            .unwrap();
1260        let resolved = svc.resolve_session(&session.token).await.unwrap();
1261        assert_eq!(resolved.data.as_deref(), Some(r#"{"v":1,"csrf":"abc"}"#));
1262    }
1263
1264    #[tokio::test]
1265    async fn username_is_case_insensitive_across_backends() {
1266        // The account is created lower-cased, and a differently-cased login
1267        // resolves to it: this must hold identically on every backend (MySQL's
1268        // collation is case-insensitive, Postgres and SQLite are not).
1269        let (pool, _guard) = test_db().await;
1270        let hash = password::hash_password("pw").unwrap();
1271        store::create_user(
1272            &pool,
1273            "Root",
1274            "Root@Example.test",
1275            "Case",
1276            None,
1277            &hash,
1278            true,
1279        )
1280        .await
1281        .unwrap();
1282        let svc = service(pool);
1283        let session = svc
1284            .authenticate("ROOT", "pw", &RequestContext::default())
1285            .await
1286            .expect("case-varied login should resolve to the same account");
1287        let identity = svc.verify_session(&session.token).await.unwrap();
1288        assert_eq!(identity.user.username, "root");
1289        assert_eq!(identity.user.email, "root@example.test");
1290    }
1291
1292    #[tokio::test]
1293    async fn full_name_falls_back_to_first_name_when_last_is_absent() {
1294        let (pool, _guard) = test_db().await;
1295        let hash = password::hash_password("pw").unwrap();
1296        store::create_user(
1297            &pool,
1298            "mono",
1299            "mono@example.test",
1300            "Prakash",
1301            None,
1302            &hash,
1303            true,
1304        )
1305        .await
1306        .unwrap();
1307        let svc = service(pool);
1308        let session = svc
1309            .authenticate("mono", "pw", &RequestContext::default())
1310            .await
1311            .unwrap();
1312        let identity = svc.verify_session(&session.token).await.unwrap();
1313        assert_eq!(identity.user.full_name(), "Prakash");
1314    }
1315
1316    #[tokio::test]
1317    async fn operator_timezone_round_trips_and_clears() {
1318        let (pool, _guard) = test_db().await;
1319        let id = seed_user(&pool, "tz", "pw", true).await;
1320
1321        // A fresh operator has no preference and inherits the default.
1322        let user = store::find_active_user_by_id(&pool, id)
1323            .await
1324            .unwrap()
1325            .unwrap();
1326        assert_eq!(user.timezone, None);
1327
1328        let svc = service(pool.clone());
1329        svc.set_user_timezone(id, Some("Asia/Kolkata"))
1330            .await
1331            .unwrap();
1332        let user = store::find_active_user_by_id(&pool, id)
1333            .await
1334            .unwrap()
1335            .unwrap();
1336        assert_eq!(user.timezone.as_deref(), Some("Asia/Kolkata"));
1337
1338        // Clearing it returns the operator to the default.
1339        svc.set_user_timezone(id, None).await.unwrap();
1340        let user = store::find_active_user_by_id(&pool, id)
1341            .await
1342            .unwrap()
1343            .unwrap();
1344        assert_eq!(user.timezone, None);
1345    }
1346
1347    #[tokio::test]
1348    async fn operator_locale_round_trips_and_clears() {
1349        let (pool, _guard) = test_db().await;
1350        let id = seed_user(&pool, "loc", "pw", true).await;
1351
1352        // A fresh operator has no preference and inherits the default.
1353        let user = store::find_active_user_by_id(&pool, id)
1354            .await
1355            .unwrap()
1356            .unwrap();
1357        assert_eq!(user.locale, None);
1358
1359        let svc = service(pool.clone());
1360        svc.set_user_locale(id, Some("kn")).await.unwrap();
1361        let user = store::find_active_user_by_id(&pool, id)
1362            .await
1363            .unwrap()
1364            .unwrap();
1365        assert_eq!(user.locale.as_deref(), Some("kn"));
1366
1367        // Clearing it returns the operator to the default.
1368        svc.set_user_locale(id, None).await.unwrap();
1369        let user = store::find_active_user_by_id(&pool, id)
1370            .await
1371            .unwrap()
1372            .unwrap();
1373        assert_eq!(user.locale, None);
1374    }
1375
1376    #[tokio::test]
1377    async fn has_any_operator_flips_after_the_first_account() {
1378        let (pool, _guard) = test_db().await;
1379        let svc = service(pool);
1380
1381        // A fresh install has no operators, so setup (not login) applies.
1382        assert!(!svc.has_any_operator().await.unwrap());
1383
1384        svc.create_superuser(NewOperator {
1385            username: "first",
1386            email: "first@example.test",
1387            first_name: "First",
1388            last_name: None,
1389            password: "hunter2",
1390            timezone: Some("Asia/Kolkata"),
1391        })
1392        .await
1393        .unwrap();
1394
1395        assert!(svc.has_any_operator().await.unwrap());
1396
1397        // The account is a usable superuser with the onboarding timezone recorded.
1398        let session = svc
1399            .authenticate("first", "hunter2", &RequestContext::default())
1400            .await
1401            .unwrap();
1402        let identity = svc.verify_session(&session.token).await.unwrap();
1403        assert!(identity.allows("anything.a.superuser.can.do"));
1404        assert_eq!(identity.user.timezone.as_deref(), Some("Asia/Kolkata"));
1405    }
1406
1407    #[tokio::test]
1408    async fn wrong_password_is_rejected() {
1409        let (pool, _guard) = test_db().await;
1410        seed_user(&pool, "root", "hunter2", false).await;
1411        let svc = service(pool);
1412
1413        let err = svc
1414            .authenticate("root", "wrong", &RequestContext::default())
1415            .await
1416            .unwrap_err();
1417        assert!(matches!(err, AuthError::InvalidCredentials));
1418    }
1419
1420    #[tokio::test]
1421    async fn unknown_user_is_rejected_without_distinction() {
1422        let (pool, _guard) = test_db().await;
1423        let svc = service(pool);
1424        let err = svc
1425            .authenticate("ghost", "whatever", &RequestContext::default())
1426            .await
1427            .unwrap_err();
1428        assert!(matches!(err, AuthError::InvalidCredentials));
1429    }
1430
1431    #[tokio::test]
1432    async fn lockout_trips_after_max_failures() {
1433        let (pool, _guard) = test_db().await;
1434        seed_user(&pool, "root", "hunter2", false).await;
1435        let svc = AuthService::new(
1436            pool,
1437            AuthConfig {
1438                max_failures: 3,
1439                ..AuthConfig::default()
1440            },
1441        );
1442        let ctx = RequestContext::default();
1443
1444        for _ in 0..3 {
1445            let err = svc.authenticate("root", "bad", &ctx).await.unwrap_err();
1446            assert!(matches!(err, AuthError::InvalidCredentials));
1447        }
1448        // The correct password is now refused: the account is locked out.
1449        let err = svc.authenticate("root", "hunter2", &ctx).await.unwrap_err();
1450        assert!(matches!(err, AuthError::TooManyAttempts));
1451    }
1452
1453    #[tokio::test]
1454    async fn permissions_come_from_assigned_roles() {
1455        let (pool, _guard) = test_db().await;
1456        let user_id = seed_user(&pool, "mod", "pw", false).await;
1457        let role_id = store::create_role(
1458            &pool,
1459            "content_editor",
1460            "Content Editor",
1461            &["posts.*".to_string()],
1462        )
1463        .await
1464        .unwrap();
1465        store::assign_role(&pool, user_id, role_id).await.unwrap();
1466
1467        let svc = service(pool);
1468        let session = svc
1469            .authenticate("mod", "pw", &RequestContext::default())
1470            .await
1471            .unwrap();
1472        let identity = svc.verify_session(&session.token).await.unwrap();
1473
1474        assert!(identity.allows("posts.approve"));
1475        assert!(!identity.allows("users.edit"));
1476        identity.require("posts.edit").unwrap();
1477        assert!(identity.require("users.edit").is_err());
1478    }
1479
1480    #[tokio::test]
1481    async fn logout_invalidates_the_session() {
1482        let (pool, _guard) = test_db().await;
1483        seed_user(&pool, "root", "pw", true).await;
1484        let svc = service(pool);
1485
1486        let session = svc
1487            .authenticate("root", "pw", &RequestContext::default())
1488            .await
1489            .unwrap();
1490        svc.verify_session(&session.token).await.unwrap();
1491        svc.logout(&session.token).await.unwrap();
1492
1493        let err = svc.verify_session(&session.token).await.unwrap_err();
1494        assert!(matches!(err, AuthError::SessionInvalid));
1495    }
1496
1497    #[tokio::test]
1498    async fn inactive_account_is_refused_after_correct_password() {
1499        let (pool, _guard) = test_db().await;
1500        let user_id = seed_user(&pool, "root", "pw", false).await;
1501        // Deactivate through the query layer so the placeholder renders per backend.
1502        let (sql, values) = laterite_core::query::build(
1503            pool.backend,
1504            sea_query::Query::update()
1505                .table(crate::schema::BackendUsers::Table)
1506                .value(crate::schema::BackendUsers::IsActive, false)
1507                .and_where(sea_query::Expr::col(crate::schema::BackendUsers::Id).eq(user_id))
1508                .to_owned(),
1509        );
1510        laterite_core::query::bind_values(sqlx::query(&sql), values)
1511            .execute(&pool.pool)
1512            .await
1513            .unwrap();
1514
1515        let svc = service(pool);
1516        let err = svc
1517            .authenticate("root", "pw", &RequestContext::default())
1518            .await
1519            .unwrap_err();
1520        assert!(matches!(err, AuthError::InactiveAccount));
1521    }
1522
1523    #[tokio::test]
1524    async fn reset_password_updates_the_hash() {
1525        let (pool, _guard) = test_db().await;
1526        seed_user(&pool, "root", "oldpw", true).await;
1527
1528        let new_hash = password::hash_password("newpw").unwrap();
1529        let affected = store::update_password_by_username(&pool, "root", &new_hash)
1530            .await
1531            .unwrap();
1532        assert_eq!(affected, 1);
1533
1534        let svc = service(pool);
1535        let ctx = RequestContext::default();
1536        assert!(matches!(
1537            svc.authenticate("root", "oldpw", &ctx).await.unwrap_err(),
1538            AuthError::InvalidCredentials
1539        ));
1540        svc.authenticate("root", "newpw", &ctx).await.unwrap();
1541    }
1542
1543    #[tokio::test]
1544    async fn reset_password_reports_unknown_user() {
1545        let (pool, _guard) = test_db().await;
1546        let hash = password::hash_password("x").unwrap();
1547        let affected = store::update_password_by_username(&pool, "ghost", &hash)
1548            .await
1549            .unwrap();
1550        assert_eq!(affected, 0);
1551    }
1552
1553    #[tokio::test]
1554    async fn list_users_returns_all_seeded() {
1555        let (pool, _guard) = test_db().await;
1556        seed_user(&pool, "alice", "pw", true).await;
1557        seed_user(&pool, "bob", "pw", false).await;
1558
1559        let users = store::list_backend_users(&pool).await.unwrap();
1560        assert_eq!(users.len(), 2);
1561        assert!(users
1562            .iter()
1563            .any(|u| u.username == "alice" && u.is_superuser));
1564        assert!(users.iter().any(|u| u.username == "bob" && !u.is_superuser));
1565    }
1566
1567    #[tokio::test]
1568    async fn unlock_clears_the_lockout() {
1569        let (pool, _guard) = test_db().await;
1570        seed_user(&pool, "root", "pw", false).await;
1571        let svc = AuthService::new(
1572            pool.clone(),
1573            AuthConfig {
1574                max_failures: 3,
1575                ..AuthConfig::default()
1576            },
1577        );
1578        let ctx = RequestContext::default();
1579
1580        for _ in 0..3 {
1581            let _ = svc.authenticate("root", "bad", &ctx).await;
1582        }
1583        assert!(matches!(
1584            svc.authenticate("root", "pw", &ctx).await.unwrap_err(),
1585            AuthError::TooManyAttempts
1586        ));
1587
1588        let cleared = store::clear_failed_attempts(&pool, "root").await.unwrap();
1589        assert!(cleared >= 3);
1590        svc.authenticate("root", "pw", &ctx).await.unwrap();
1591    }
1592}