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};
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 shortest password [`AuthService::change_password`] accepts.
276pub const MIN_PASSWORD_LENGTH: usize = 8;
277
278/// The auth service. Cheap to clone: it holds a database handle and config.
279#[derive(Clone)]
280pub struct AuthService {
281    db: Db,
282    config: AuthConfig,
283}
284
285impl AuthService {
286    pub fn new(db: Db, config: AuthConfig) -> Self {
287        Self { db, config }
288    }
289
290    /// How long a session stays valid. A caller that persists the session in a
291    /// cookie matches its lifetime to this, so the two cannot disagree.
292    pub fn session_absolute_timeout(&self) -> Duration {
293        self.config.session_absolute_timeout
294    }
295
296    /// Verifies a username and password, and on success issues a session.
297    ///
298    /// Failures are throttled per username and every outcome is logged. The
299    /// error deliberately does not reveal whether the username exists.
300    pub async fn authenticate(
301        &self,
302        username: &str,
303        password: &str,
304        ctx: &RequestContext,
305    ) -> Result<IssuedSession, AuthError> {
306        let now = Utc::now();
307        let since = now - chrono_from_std(self.config.failure_window);
308
309        if store::count_recent_failures(&self.db, username, since).await?
310            >= self.config.max_failures
311        {
312            self.log(None, username, AccessEvent::LockedOut, ctx)
313                .await?;
314            return Err(AuthError::TooManyAttempts);
315        }
316
317        let user = match store::find_user_by_username(&self.db, username).await? {
318            Some(user) => user,
319            None => {
320                self.log(None, username, AccessEvent::LoginFailure, ctx)
321                    .await?;
322                return Err(AuthError::InvalidCredentials);
323            }
324        };
325
326        if !password::verify_password(password, &user.password_hash)? {
327            self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
328                .await?;
329            return Err(AuthError::InvalidCredentials);
330        }
331
332        if !user.is_active {
333            self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
334                .await?;
335            return Err(AuthError::InactiveAccount);
336        }
337
338        let token = generate_token();
339        let expires_at = self.config.deadline(now, now);
340        store::insert_session(
341            &self.db,
342            &hash_token(&token),
343            user.id,
344            expires_at,
345            ctx.ip_address.as_deref(),
346            ctx.user_agent.as_deref(),
347        )
348        .await?;
349        self.log(Some(user.id), username, AccessEvent::LoginSuccess, ctx)
350            .await?;
351
352        Ok(IssuedSession {
353            token,
354            expires_at,
355            user_id: user.id,
356        })
357    }
358
359    /// Resolves a raw session token to an identity and its stored data blob,
360    /// refreshing last-seen. Expired sessions, and sessions whose user was
361    /// disabled or removed, resolve to [`AuthError::SessionInvalid`]. The blob
362    /// is read in the same query as the session, adding no round-trip.
363    pub async fn resolve_session(&self, token: &str) -> Result<ResolvedSession, AuthError> {
364        let token_hash = hash_token(token);
365        let now = Utc::now();
366
367        let session = store::find_valid_session(&self.db, &token_hash, now)
368            .await?
369            .ok_or(AuthError::SessionInvalid)?;
370        let user = store::find_active_user_by_id(&self.db, session.user_id)
371            .await?
372            .ok_or(AuthError::SessionInvalid)?;
373        // Renewing on every request would cost a write per request for a
374        // deadline that moves by seconds. Past the halfway mark of the idle
375        // window the write buys real time, so that is where it happens.
376        let idle = chrono_from_std(self.config.session_idle_timeout);
377        let expires_at = self.config.deadline(session.created_at, now);
378        if now - session.last_seen_at > idle / 2 {
379            store::renew_session(&self.db, &token_hash, now, expires_at).await?;
380        }
381
382        let grants = store::load_role_permissions(&self.db, user.id)
383            .await?
384            .into_iter()
385            .flatten();
386        // Split the user's per-permission overrides into allow (1) and deny (-1),
387        // which take precedence over the role grants.
388        let overrides = store::load_user_permission_overrides(&self.db, user.id).await?;
389        let (mut allow, mut deny) = (Vec::new(), Vec::new());
390        for (code, decision) in overrides {
391            match decision.signum() {
392                1 => allow.push(code),
393                -1 => deny.push(code),
394                _ => {}
395            }
396        }
397        let permissions = PermissionSet::with_overrides(user.is_superuser, grants, allow, deny);
398
399        Ok(ResolvedSession {
400            identity: AuthenticatedUser { user, permissions },
401            data: session.data,
402            expires_at,
403        })
404    }
405
406    /// Resolves a raw session token to an identity. A thin wrapper over
407    /// [`AuthService::resolve_session`] for callers that need only the identity.
408    pub async fn verify_session(&self, token: &str) -> Result<AuthenticatedUser, AuthError> {
409        Ok(self.resolve_session(token).await?.identity)
410    }
411
412    /// Overwrites the opaque per-session data blob (the surface's serialised
413    /// state, e.g. CSRF token + flash). The surface writes only when its blob
414    /// changed, so an unchanged request adds no write.
415    pub async fn set_session_data(&self, token: &str, data: &str) -> Result<(), AuthError> {
416        store::set_session_data(&self.db, &hash_token(token), data).await
417    }
418
419    /// Issues a "stay signed in" credential for `user_id`, one row per device.
420    pub async fn issue_remember(&self, user_id: i64) -> Result<RememberCredential, AuthError> {
421        let (selector, verifier) = (generate_token(), generate_token());
422        let expires_at = Utc::now() + chrono_from_std(self.config.remember_duration);
423        store::insert_remember_token(
424            &self.db,
425            &selector,
426            &hash_token(&verifier),
427            user_id,
428            expires_at,
429        )
430        .await?;
431        Ok(RememberCredential {
432            cookie: format!("{selector}:{verifier}"),
433            expires_at,
434        })
435    }
436
437    /// Trades a presented remember cookie for a fresh session and a replacement
438    /// credential, rotating the stored row so a cookie is single-use.
439    ///
440    /// A selector that resolves with the wrong verifier means a copy of the
441    /// cookie is in circulation: one of the two holders used it first and
442    /// rotated it, and this is the other. Every credential the user holds is
443    /// dropped, which signs out the thief at the cost of signing out the owner.
444    pub async fn consume_remember(
445        &self,
446        cookie: &str,
447        ctx: &RequestContext,
448    ) -> Result<RecalledSession, AuthError> {
449        let (selector, verifier) = cookie.split_once(':').ok_or(AuthError::SessionInvalid)?;
450        let now = Utc::now();
451        let found = store::find_remember_token(&self.db, selector, now)
452            .await?
453            .ok_or(AuthError::SessionInvalid)?;
454
455        if !constant_time_eq(&hash_token(verifier), &found.verifier_hash) {
456            tracing::warn!(
457                user_id = found.user_id,
458                "a stay-signed-in cookie was presented with a stale secret;                  dropping every credential for this account"
459            );
460            store::delete_user_remember_tokens(&self.db, found.user_id).await?;
461            self.log(Some(found.user_id), "", AccessEvent::LoginFailure, ctx)
462                .await?;
463            return Err(AuthError::SessionInvalid);
464        }
465
466        // A disabled or removed account must not be recalled back in.
467        let user = store::find_active_user_by_id(&self.db, found.user_id)
468            .await?
469            .ok_or(AuthError::SessionInvalid)?;
470
471        // Rotate in place. The selector stays, so a copy of the spent cookie
472        // comes back as a mismatch on a known series rather than as a stranger,
473        // which is the only thing that makes theft visible at all.
474        let replacement = generate_token();
475        let remember_expires = now + chrono_from_std(self.config.remember_duration);
476        store::rotate_remember_token(
477            &self.db,
478            selector,
479            &hash_token(&replacement),
480            remember_expires,
481        )
482        .await?;
483        let remember = RememberCredential {
484            cookie: format!("{selector}:{replacement}"),
485            expires_at: remember_expires,
486        };
487
488        let token = generate_token();
489        let expires_at = self.config.deadline(now, now);
490        store::insert_session(
491            &self.db,
492            &hash_token(&token),
493            user.id,
494            expires_at,
495            ctx.ip_address.as_deref(),
496            ctx.user_agent.as_deref(),
497        )
498        .await?;
499        self.log(
500            Some(user.id),
501            &user.username,
502            AccessEvent::LoginSuccess,
503            ctx,
504        )
505        .await?;
506
507        Ok(RecalledSession {
508            session: IssuedSession {
509                token,
510                expires_at,
511                user_id: user.id,
512            },
513            remember,
514        })
515    }
516
517    /// Drops the credential a cookie names. Unknown or malformed values are a
518    /// no-op, so a stale cookie on logout is not an error.
519    pub async fn revoke_remember(&self, cookie: &str) -> Result<(), AuthError> {
520        if let Some((selector, _)) = cookie.split_once(':') {
521            store::delete_remember_token(&self.db, selector).await?;
522        }
523        Ok(())
524    }
525
526    /// Lists the account's live sessions, newest activity first, marking the one
527    /// `current_token` belongs to.
528    pub async fn list_sessions(
529        &self,
530        user_id: i64,
531        current_token: &str,
532    ) -> Result<Vec<ActiveSession>, AuthError> {
533        let current_hash = hash_token(current_token);
534        let rows = store::list_user_sessions(&self.db, user_id, Utc::now()).await?;
535        Ok(rows
536            .into_iter()
537            .map(|s| ActiveSession {
538                id: public_session_id(&s.token_hash),
539                created_at: s.created_at,
540                last_seen_at: s.last_seen_at,
541                expires_at: s.expires_at,
542                ip_address: s.ip_address,
543                user_agent: s.user_agent,
544                current: s.token_hash == current_hash,
545            })
546            .collect())
547    }
548
549    /// Ends one of the account's sessions by its public id, with a reason so the
550    /// device is told. Returns whether a session matched. Scoped to `user_id`, so
551    /// the worst a forged id can do is end one of the caller's own sessions.
552    pub async fn revoke_session(&self, user_id: i64, id: &str) -> Result<bool, AuthError> {
553        let rows = store::list_user_sessions(&self.db, user_id, Utc::now()).await?;
554        let Some(target) = rows
555            .into_iter()
556            .find(|s| constant_time_eq(&public_session_id(&s.token_hash), id))
557        else {
558            return Ok(false);
559        };
560        let done = store::revoke_user_session(
561            &self.db,
562            user_id,
563            &target.token_hash,
564            store::RevokeReason::SignedOutElsewhere,
565        )
566        .await?;
567        Ok(done > 0)
568    }
569
570    /// Ends every session but the caller's, and drops every stay-signed-in
571    /// credential the account holds. The credentials have to go too: leaving
572    /// them would let any signed-out device mint itself a new session on its
573    /// next request, which is the opposite of what this asks for.
574    pub async fn sign_out_everywhere(
575        &self,
576        user_id: i64,
577        keep_token: &str,
578    ) -> Result<u64, AuthError> {
579        let ended = store::revoke_user_sessions(
580            &self.db,
581            user_id,
582            Some(&hash_token(keep_token)),
583            store::RevokeReason::SignedOutElsewhere,
584        )
585        .await?;
586        store::revoke_user_remember_tokens(
587            &self.db,
588            user_id,
589            store::RevokeReason::SignedOutElsewhere,
590        )
591        .await?;
592        Ok(ended)
593    }
594
595    /// Sets a new password and signs the account out everywhere else.
596    ///
597    /// A password is changed because it was forgotten, shared or stolen. In the
598    /// last two cases whoever else holds it is already signed in, so every other
599    /// session is ended and every stay-signed-in credential dropped; leaving
600    /// either would make the change decorative. `keep_token` is the session the
601    /// change was made from, which survives so nobody is thrown out mid-edit.
602    /// The sessions ended record why, so their holders are told.
603    ///
604    /// The change is written to the audit log under `backend.user.password_change`,
605    /// attributed to `actor`: the operator who made it, or the process for a
606    /// system change. The password itself is never recorded.
607    ///
608    /// Refuses a password shorter than [`MIN_PASSWORD_LENGTH`].
609    pub async fn change_password(
610        &self,
611        user_id: i64,
612        new_password: &str,
613        keep_token: Option<&str>,
614        actor: &Actor,
615    ) -> Result<(), AuthError> {
616        if new_password.chars().count() < MIN_PASSWORD_LENGTH {
617            return Err(AuthError::Refused(format!(
618                "A password needs at least {MIN_PASSWORD_LENGTH} characters."
619            )));
620        }
621        let hash = password::hash_password(new_password)?;
622        let keep = keep_token.map(hash_token);
623        let done = store::change_password(
624            &self.db,
625            user_id,
626            &hash,
627            keep.as_deref(),
628            actor.user_id(),
629            actor.label(),
630        )
631        .await?;
632        if done == 0 {
633            return Err(AuthError::SessionInvalid);
634        }
635        Ok(())
636    }
637
638    /// [`AuthService::change_password`] for the signed-in operator, after
639    /// confirming their current password.
640    ///
641    /// A wrong current password is logged as a failed sign-in and counts toward
642    /// the same lockout, so a borrowed session cannot guess it freely. The session
643    /// `keep_token` names stays signed in.
644    pub async fn change_own_password(
645        &self,
646        user_id: i64,
647        current_password: &str,
648        new_password: &str,
649        keep_token: &str,
650        ctx: &RequestContext,
651    ) -> Result<(), AuthError> {
652        let user = store::find_user_by_id(&self.db, user_id)
653            .await?
654            .ok_or(AuthError::SessionInvalid)?;
655        let since = Utc::now() - chrono_from_std(self.config.failure_window);
656        if store::count_recent_failures(&self.db, &user.username, since).await?
657            >= self.config.max_failures
658        {
659            self.log(Some(user.id), &user.username, AccessEvent::LockedOut, ctx)
660                .await?;
661            return Err(AuthError::TooManyAttempts);
662        }
663        if !password::verify_password(current_password, &user.password_hash)? {
664            self.log(
665                Some(user.id),
666                &user.username,
667                AccessEvent::LoginFailure,
668                ctx,
669            )
670            .await?;
671            return Err(AuthError::InvalidCredentials);
672        }
673        self.change_password(
674            user.id,
675            new_password,
676            Some(keep_token),
677            &Actor::user(user.id, user.username.as_str()),
678        )
679        .await
680    }
681
682    /// When the account's password was last set, `None` if never recorded.
683    pub async fn password_changed_at(
684        &self,
685        user_id: i64,
686    ) -> Result<Option<DateTime<Utc>>, AuthError> {
687        store::password_changed_at(&self.db, user_id).await
688    }
689
690    /// [`AuthService::change_password`] for an account named by username, with no
691    /// session kept: the command-line reset, run from outside every session.
692    /// `Ok(false)` when no such account exists.
693    pub async fn reset_password(
694        &self,
695        username: &str,
696        new_password: &str,
697        actor: &Actor,
698    ) -> Result<bool, AuthError> {
699        let Some(user) = store::find_user_by_username(&self.db, username).await? else {
700            return Ok(false);
701        };
702        self.change_password(user.id, new_password, None, actor)
703            .await?;
704        Ok(true)
705    }
706
707    /// Whose stay-signed-in credential a cookie is, without spending it: `None`
708    /// for one that is not current. The verifier has to match, so a stale copy
709    /// names nobody.
710    pub async fn remember_holder(&self, cookie: &str) -> Result<Option<i64>, AuthError> {
711        let Some((selector, verifier)) = cookie.split_once(':') else {
712            return Ok(None);
713        };
714        let Some(found) = store::find_remember_token(&self.db, selector, Utc::now()).await? else {
715            return Ok(None);
716        };
717        Ok(constant_time_eq(&hash_token(verifier), &found.verifier_hash).then_some(found.user_id))
718    }
719
720    /// Why a presented stay-signed-in cookie no longer works, when it was ended
721    /// on purpose.
722    ///
723    /// Only the current holder is told: the verifier has to match, so a stale
724    /// copy learns nothing. Reading the reason removes the credential, so it is
725    /// told once.
726    pub async fn remember_end(
727        &self,
728        cookie: &str,
729    ) -> Result<Option<store::RevokeReason>, AuthError> {
730        let Some((selector, verifier)) = cookie.split_once(':') else {
731            return Ok(None);
732        };
733        let Some((verifier_hash, reason)) =
734            store::revoked_remember_token(&self.db, selector).await?
735        else {
736            return Ok(None);
737        };
738        if !constant_time_eq(&hash_token(verifier), &verifier_hash) {
739            return Ok(None);
740        }
741        store::delete_remember_token(&self.db, selector).await?;
742        Ok(Some(reason))
743    }
744
745    /// Why a presented session no longer works, if it was ever issued.
746    ///
747    /// A deliberately ended session is told once: reading its reason removes it,
748    /// so a stale tab reloaded later is simply signed out.
749    pub async fn session_end(&self, token: &str) -> Result<Option<store::SessionEnd>, AuthError> {
750        let token_hash = hash_token(token);
751        let end = store::session_end(&self.db, &token_hash, Utc::now()).await?;
752        if matches!(end, Some(store::SessionEnd::Revoked(_))) {
753            store::delete_session(&self.db, &token_hash).await?;
754        }
755        Ok(end)
756    }
757
758    /// Activates or deactivates a backend user.
759    ///
760    /// Deactivating is the reversible counterpart to deleting an operator, which
761    /// the panel does not offer: the account keeps its history, its audit trail
762    /// and its permission overrides, and simply cannot sign in.
763    ///
764    /// Two deactivations are refused, because both end with a panel somebody has
765    /// to repair from the command line: your own account, and the last active
766    /// superuser. Both return [`AuthError::Refused`] with the reason.
767    ///
768    /// A deactivated account's sessions and stay-signed-in credentials are
769    /// dropped in the same call. An account that may not sign in must not remain
770    /// signed in on a machine somewhere, which is the whole point of revoking it.
771    pub async fn set_user_active(
772        &self,
773        actor_id: i64,
774        user_id: i64,
775        active: bool,
776    ) -> Result<(), AuthError> {
777        if !active {
778            if actor_id == user_id {
779                return Err(AuthError::Refused(
780                    "You cannot deactivate your own account.".to_string(),
781                ));
782            }
783            let user = store::find_user_by_id(&self.db, user_id)
784                .await?
785                .ok_or(AuthError::SessionInvalid)?;
786            if user.is_superuser && store::other_active_superusers(&self.db, user_id).await? == 0 {
787                return Err(AuthError::Refused(
788                    "This is the last active superuser; the panel would have no \
789                     administrator left."
790                        .to_string(),
791                ));
792            }
793        }
794
795        if store::set_user_active(&self.db, user_id, active).await? == 0 {
796            return Err(AuthError::SessionInvalid);
797        }
798        Ok(())
799    }
800
801    /// Invalidates a session. Unknown tokens are a no-op.
802    pub async fn logout(&self, token: &str) -> Result<(), AuthError> {
803        store::delete_session(&self.db, &hash_token(token)).await
804    }
805
806    /// Persists an operator's own display timezone. `Some(name)` sets an IANA
807    /// timezone; `None` clears it so the operator falls back to the deployment
808    /// default. Validating that `name` is a real timezone is the caller's job.
809    pub async fn set_user_timezone(
810        &self,
811        user_id: i64,
812        timezone: Option<&str>,
813    ) -> Result<(), AuthError> {
814        store::set_user_timezone(&self.db, user_id, timezone).await
815    }
816
817    /// Persists an operator's own UI locale. `Some(tag)` sets a base language tag;
818    /// `None` clears it so the operator falls back to the deployment default.
819    /// Validating that `tag` is a supported locale is the caller's job.
820    pub async fn set_user_locale(
821        &self,
822        user_id: i64,
823        locale: Option<&str>,
824    ) -> Result<(), AuthError> {
825        store::set_user_locale(&self.db, user_id, locale).await
826    }
827
828    /// Loads a user's per-permission overrides (code to `1` allow or `-1` deny).
829    pub async fn user_permission_overrides(
830        &self,
831        user_id: i64,
832    ) -> Result<std::collections::HashMap<String, i64>, AuthError> {
833        store::load_user_permission_overrides(&self.db, user_id).await
834    }
835
836    /// Replaces a user's per-permission overrides. Callers pass only `1` and `-1`
837    /// entries; an inherited permission is represented by its absence.
838    pub async fn set_user_permissions(
839        &self,
840        user_id: i64,
841        overrides: &std::collections::HashMap<String, i64>,
842    ) -> Result<(), AuthError> {
843        store::set_user_permissions(&self.db, user_id, overrides).await
844    }
845
846    /// Appends an entry to the append-only audit log. Mutating admin paths call
847    /// this after a successful change, so actions that affect privileges or data
848    /// are recorded. Self-service preference changes are not audited.
849    pub async fn record_audit(&self, entry: AuditEntry<'_>) -> Result<(), AuthError> {
850        store::insert_audit_log(
851            &self.db,
852            entry.actor_user_id,
853            entry.actor_username,
854            entry.action,
855            entry.target_type,
856            entry.target_id,
857            entry.detail,
858        )
859        .await
860    }
861
862    /// The most recent audit entries, newest first, for the admin audit view.
863    pub async fn recent_audit(&self, limit: u64) -> Result<Vec<store::AuditRecord>, AuthError> {
864        store::recent_audit(&self.db, limit).await
865    }
866
867    /// Whether any backend operator exists yet. A fresh install with none is
868    /// routed to first-run setup instead of login.
869    pub async fn has_any_operator(&self) -> Result<bool, AuthError> {
870        store::any_user_exists(&self.db).await
871    }
872
873    /// Creates a superuser operator: hashes the password, inserts the user, and
874    /// records their timezone preference. The single account-creation path,
875    /// shared by the CLI and the first-run setup screen.
876    pub async fn create_superuser(&self, new: NewOperator<'_>) -> Result<i64, AuthError> {
877        let hash = password::hash_password(new.password)?;
878        let id = store::create_user(
879            &self.db,
880            new.username,
881            new.email,
882            new.first_name,
883            new.last_name,
884            &hash,
885            true,
886        )
887        .await?;
888        if new.timezone.is_some() {
889            store::set_user_timezone(&self.db, id, new.timezone).await?;
890        }
891        // The administrator role, when the panel has written it. A superuser
892        // passes every check anyway; this is so the roles screen reads true and
893        // the account keeps working if its superuser flag is ever cleared.
894        if let Some(role_id) = store::role_id_by_code(&self.db, crate::ROLE_ADMIN).await? {
895            store::assign_role(&self.db, id, role_id).await?;
896        }
897        Ok(id)
898    }
899
900    async fn log(
901        &self,
902        user_id: Option<i64>,
903        username: &str,
904        event: AccessEvent,
905        ctx: &RequestContext,
906    ) -> Result<(), AuthError> {
907        store::insert_access_log(
908            &self.db,
909            user_id,
910            username,
911            event,
912            ctx.ip_address.as_deref(),
913            ctx.user_agent.as_deref(),
914        )
915        .await
916    }
917}
918
919/// Converts a small, in-range `std::time::Duration` to `chrono::Duration`.
920/// The auth policy durations are hours at most, well within range.
921fn chrono_from_std(d: Duration) -> chrono::Duration {
922    chrono::Duration::from_std(d).expect("auth policy duration out of range")
923}
924
925fn generate_token() -> String {
926    let mut bytes = [0u8; 32];
927    rand::rngs::OsRng.fill_bytes(&mut bytes);
928    to_hex(&bytes)
929}
930
931/// A page-safe name for a session row. Hashing the stored key again means the
932/// id identifies a session without being the key: even if a rendered page leaks,
933/// nothing in it can be presented as a session or looked up as one.
934fn public_session_id(token_hash: &str) -> String {
935    let mut hasher = Sha256::new();
936    hasher.update(b"laterite:session-id:");
937    hasher.update(token_hash.as_bytes());
938    to_hex(&hasher.finalize())
939}
940
941/// Compares two equal-length hex digests without an early return.
942fn constant_time_eq(a: &str, b: &str) -> bool {
943    let (a, b) = (a.as_bytes(), b.as_bytes());
944    if a.len() != b.len() {
945        return false;
946    }
947    a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
948}
949
950fn hash_token(token: &str) -> String {
951    let mut hasher = Sha256::new();
952    hasher.update(token.as_bytes());
953    to_hex(&hasher.finalize())
954}
955
956fn to_hex(bytes: &[u8]) -> String {
957    let mut out = String::with_capacity(bytes.len() * 2);
958    for byte in bytes {
959        let _ = write!(out, "{byte:02x}");
960    }
961    out
962}
963
964#[cfg(test)]
965mod tests {
966    use super::*;
967
968    async fn seed_user(db: &Db, username: &str, password: &str, superuser: bool) -> i64 {
969        let hash = password::hash_password(password).unwrap();
970        store::create_user(
971            db,
972            username,
973            &format!("{username}@example.test"),
974            "Test",
975            Some("Operator"),
976            &hash,
977            superuser,
978        )
979        .await
980        .unwrap()
981    }
982
983    fn service(db: Db) -> AuthService {
984        AuthService::new(db, AuthConfig::default())
985    }
986
987    /// A fresh test database with this module's migrations applied through the
988    /// framework runner, on whichever backend the run targets (see
989    /// `laterite_core::testing`). Hold the returned guard for the test's lifetime.
990    async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
991        laterite_core::testing::connect_test(&[crate::migrations()]).await
992    }
993
994    /// Backdates a session's idle clock, standing in for time passing.
995    async fn set_last_seen(db: &Db, token: &str, seen: DateTime<Utc>) {
996        store::renew_session(
997            db,
998            &hash_token(token),
999            seen,
1000            seen + chrono::Duration::hours(2),
1001        )
1002        .await
1003        .unwrap();
1004    }
1005
1006    async fn last_seen(db: &Db, token: &str) -> DateTime<Utc> {
1007        store::find_valid_session(db, &hash_token(token), Utc::now())
1008            .await
1009            .unwrap()
1010            .expect("session still valid")
1011            .last_seen_at
1012    }
1013
1014    #[tokio::test]
1015    async fn a_request_past_the_halfway_mark_renews_the_session() {
1016        let (pool, _guard) = test_db().await;
1017        seed_user(&pool, "root", "hunter2", true).await;
1018        let svc = service(pool.clone());
1019
1020        let issued = svc
1021            .authenticate("root", "hunter2", &RequestContext::default())
1022            .await
1023            .unwrap();
1024        // Ninety minutes of quiet, past the one-hour halfway mark of the
1025        // two-hour idle window.
1026        let stale = Utc::now() - chrono::Duration::minutes(90);
1027        set_last_seen(&pool, &issued.token, stale).await;
1028
1029        let resolved = svc.resolve_session(&issued.token).await.unwrap();
1030
1031        assert!(
1032            last_seen(&pool, &issued.token).await > stale,
1033            "a request past the halfway mark should push the idle clock"
1034        );
1035        assert!(
1036            resolved.expires_at > Utc::now() + chrono::Duration::minutes(115),
1037            "renewal should restore very nearly the full idle window"
1038        );
1039    }
1040
1041    #[tokio::test]
1042    async fn verify_session_renews_like_the_path_it_wraps() {
1043        let (pool, _guard) = test_db().await;
1044        seed_user(&pool, "root", "hunter2", true).await;
1045        let svc = service(pool.clone());
1046
1047        let issued = svc
1048            .authenticate("root", "hunter2", &RequestContext::default())
1049            .await
1050            .unwrap();
1051        let stale = Utc::now() - chrono::Duration::minutes(90);
1052        set_last_seen(&pool, &issued.token, stale).await;
1053
1054        svc.verify_session(&issued.token).await.unwrap();
1055
1056        // The thin wrapper must not become a way to read a session without
1057        // ageing it; that is the whole reason the store's session functions
1058        // are crate-visible.
1059        assert!(last_seen(&pool, &issued.token).await > stale);
1060    }
1061
1062    #[tokio::test]
1063    async fn a_request_before_the_halfway_mark_writes_nothing() {
1064        let (pool, _guard) = test_db().await;
1065        seed_user(&pool, "root", "hunter2", true).await;
1066        let svc = service(pool.clone());
1067
1068        let issued = svc
1069            .authenticate("root", "hunter2", &RequestContext::default())
1070            .await
1071            .unwrap();
1072        // Ten minutes of quiet: nowhere near the halfway mark, so the write
1073        // would buy minutes and cost a round-trip on every request.
1074        let recent = Utc::now() - chrono::Duration::minutes(10);
1075        set_last_seen(&pool, &issued.token, recent).await;
1076
1077        svc.resolve_session(&issued.token).await.unwrap();
1078
1079        assert_eq!(
1080            last_seen(&pool, &issued.token).await.timestamp(),
1081            recent.timestamp(),
1082            "a request inside the halfway mark should leave the row alone"
1083        );
1084    }
1085
1086    #[tokio::test]
1087    async fn a_session_past_its_idle_window_no_longer_resolves() {
1088        let (pool, _guard) = test_db().await;
1089        seed_user(&pool, "root", "hunter2", true).await;
1090        let svc = service(pool.clone());
1091
1092        let issued = svc
1093            .authenticate("root", "hunter2", &RequestContext::default())
1094            .await
1095            .unwrap();
1096        // renew_session writes last-seen and the deadline together, so a
1097        // backdated pair is exactly what an abandoned session looks like.
1098        let long_ago = Utc::now() - chrono::Duration::hours(3);
1099        set_last_seen(&pool, &issued.token, long_ago).await;
1100
1101        assert!(matches!(
1102            svc.resolve_session(&issued.token).await,
1103            Err(AuthError::SessionInvalid)
1104        ));
1105    }
1106
1107    #[tokio::test]
1108    async fn a_remember_cookie_is_single_use_and_rotates() {
1109        let (pool, _guard) = test_db().await;
1110        let uid = seed_user(&pool, "root", "hunter2", true).await;
1111        let svc = service(pool);
1112
1113        let first = svc.issue_remember(uid).await.unwrap();
1114        let recalled = svc
1115            .consume_remember(&first.cookie, &RequestContext::default())
1116            .await
1117            .unwrap();
1118
1119        assert_ne!(
1120            recalled.remember.cookie, first.cookie,
1121            "using a credential must replace its secret"
1122        );
1123        assert_eq!(
1124            recalled.remember.cookie.split_once(':').unwrap().0,
1125            first.cookie.split_once(':').unwrap().0,
1126            "the selector is the series and must survive rotation"
1127        );
1128        // The session it minted is real.
1129        svc.verify_session(&recalled.session.token).await.unwrap();
1130        // And the spent cookie is dead, so a copy taken from a stolen laptop
1131        // buys nothing once the owner's browser has used it.
1132        assert!(matches!(
1133            svc.consume_remember(&first.cookie, &RequestContext::default())
1134                .await,
1135            Err(AuthError::SessionInvalid)
1136        ));
1137    }
1138
1139    #[tokio::test]
1140    async fn a_stale_secret_drops_every_credential_the_user_holds() {
1141        let (pool, _guard) = test_db().await;
1142        let uid = seed_user(&pool, "root", "hunter2", true).await;
1143        let svc = service(pool);
1144
1145        let stolen = svc.issue_remember(uid).await.unwrap();
1146        let other_device = svc.issue_remember(uid).await.unwrap();
1147        // The owner's browser uses it first, rotating the row.
1148        let owner = svc
1149            .consume_remember(&stolen.cookie, &RequestContext::default())
1150            .await
1151            .unwrap();
1152
1153        // The thief presents the copy they took. Same selector, stale secret:
1154        // proof a cookie is in two places.
1155        let selector = stolen.cookie.split_once(':').unwrap().0;
1156        let forged = format!("{selector}:{}", generate_token());
1157        assert!(matches!(
1158            svc.consume_remember(&forged, &RequestContext::default())
1159                .await,
1160            Err(AuthError::SessionInvalid)
1161        ));
1162
1163        // Everything is revoked, the owner's fresh credential included. Signing
1164        // the owner out is the price of signing the thief out.
1165        for credential in [owner.remember.cookie, other_device.cookie] {
1166            assert!(matches!(
1167                svc.consume_remember(&credential, &RequestContext::default())
1168                    .await,
1169                Err(AuthError::SessionInvalid)
1170            ));
1171        }
1172    }
1173
1174    #[tokio::test]
1175    async fn the_sessions_list_marks_the_one_asking() {
1176        let (pool, _guard) = test_db().await;
1177        seed_user(&pool, "root", "hunter2", true).await;
1178        let svc = service(pool);
1179
1180        let first = svc
1181            .authenticate("root", "hunter2", &RequestContext::default())
1182            .await
1183            .unwrap();
1184        let second = svc
1185            .authenticate("root", "hunter2", &RequestContext::default())
1186            .await
1187            .unwrap();
1188
1189        let listed = svc
1190            .list_sessions(first.user_id, &second.token)
1191            .await
1192            .unwrap();
1193        assert_eq!(listed.len(), 2);
1194        assert_eq!(listed.iter().filter(|s| s.current).count(), 1);
1195        // The public id names a row without being the row's key.
1196        assert!(listed.iter().all(|s| !s.id.is_empty()));
1197    }
1198
1199    #[tokio::test]
1200    async fn one_account_cannot_revoke_anothers_session() {
1201        let (pool, _guard) = test_db().await;
1202        seed_user(&pool, "root", "hunter2", true).await;
1203        seed_user(&pool, "mallory", "hunter2", false).await;
1204        let svc = service(pool);
1205
1206        let victim = svc
1207            .authenticate("root", "hunter2", &RequestContext::default())
1208            .await
1209            .unwrap();
1210        let attacker = svc
1211            .authenticate("mallory", "hunter2", &RequestContext::default())
1212            .await
1213            .unwrap();
1214
1215        // The attacker has somehow learned the victim's public session id.
1216        let victims_id = svc
1217            .list_sessions(victim.user_id, &victim.token)
1218            .await
1219            .unwrap()
1220            .remove(0)
1221            .id;
1222
1223        assert!(
1224            !svc.revoke_session(attacker.user_id, &victims_id)
1225                .await
1226                .unwrap(),
1227            "a revoke is scoped to its own account"
1228        );
1229        // And the victim is still signed in.
1230        svc.verify_session(&victim.token).await.unwrap();
1231    }
1232
1233    #[tokio::test]
1234    async fn signing_out_everywhere_keeps_this_one_and_drops_credentials() {
1235        let (pool, _guard) = test_db().await;
1236        let uid = seed_user(&pool, "root", "hunter2", true).await;
1237        let svc = service(pool);
1238
1239        let keep = svc
1240            .authenticate("root", "hunter2", &RequestContext::default())
1241            .await
1242            .unwrap();
1243        let other = svc
1244            .authenticate("root", "hunter2", &RequestContext::default())
1245            .await
1246            .unwrap();
1247        let credential = svc.issue_remember(uid).await.unwrap();
1248
1249        let ended = svc.sign_out_everywhere(uid, &keep.token).await.unwrap();
1250
1251        assert_eq!(ended, 1);
1252        svc.verify_session(&keep.token).await.unwrap();
1253        assert!(matches!(
1254            svc.verify_session(&other.token).await,
1255            Err(AuthError::SessionInvalid)
1256        ));
1257        // Leaving the credential would let the signed-out device mint a new
1258        // session on its very next request.
1259        assert!(matches!(
1260            svc.consume_remember(&credential.cookie, &RequestContext::default())
1261                .await,
1262            Err(AuthError::SessionInvalid)
1263        ));
1264    }
1265
1266    #[tokio::test]
1267    async fn revoking_a_credential_ends_it() {
1268        let (pool, _guard) = test_db().await;
1269        let uid = seed_user(&pool, "root", "hunter2", true).await;
1270        let svc = service(pool);
1271
1272        let credential = svc.issue_remember(uid).await.unwrap();
1273        svc.revoke_remember(&credential.cookie).await.unwrap();
1274
1275        assert!(matches!(
1276            svc.consume_remember(&credential.cookie, &RequestContext::default())
1277                .await,
1278            Err(AuthError::SessionInvalid)
1279        ));
1280    }
1281
1282    #[tokio::test]
1283    async fn deactivating_blocks_sign_in_and_ends_the_sessions() {
1284        let (pool, _guard) = test_db().await;
1285        let admin = seed_user(&pool, "root", "hunter2", true).await;
1286        let target = seed_user(&pool, "editor", "hunter2", false).await;
1287        let svc = service(pool.clone());
1288
1289        let issued = svc
1290            .authenticate("editor", "hunter2", &RequestContext::default())
1291            .await
1292            .unwrap();
1293        let credential = svc.issue_remember(target).await.unwrap();
1294
1295        svc.set_user_active(admin, target, false).await.unwrap();
1296
1297        // Cannot sign in again.
1298        assert!(matches!(
1299            svc.authenticate("editor", "hunter2", &RequestContext::default())
1300                .await,
1301            Err(AuthError::InactiveAccount)
1302        ));
1303        // And is not left signed in where they already were: an account that may
1304        // not sign in must not stay signed in.
1305        assert!(matches!(
1306            svc.verify_session(&issued.token).await,
1307            Err(AuthError::SessionInvalid)
1308        ));
1309        assert!(matches!(
1310            svc.consume_remember(&credential.cookie, &RequestContext::default())
1311                .await,
1312            Err(AuthError::SessionInvalid)
1313        ));
1314    }
1315
1316    #[tokio::test]
1317    async fn reactivating_lets_them_back_in() {
1318        let (pool, _guard) = test_db().await;
1319        let admin = seed_user(&pool, "root", "hunter2", true).await;
1320        let target = seed_user(&pool, "editor", "hunter2", false).await;
1321        let svc = service(pool);
1322
1323        svc.set_user_active(admin, target, false).await.unwrap();
1324        svc.set_user_active(admin, target, true).await.unwrap();
1325
1326        // Reversible is the whole reason this exists instead of deletion.
1327        svc.authenticate("editor", "hunter2", &RequestContext::default())
1328            .await
1329            .unwrap();
1330    }
1331
1332    #[tokio::test]
1333    async fn an_operator_cannot_deactivate_themselves() {
1334        let (pool, _guard) = test_db().await;
1335        let admin = seed_user(&pool, "root", "hunter2", true).await;
1336        let svc = service(pool);
1337
1338        // Locking yourself out of the panel you are administering is never the
1339        // intent, and the repair is a command line.
1340        assert!(matches!(
1341            svc.set_user_active(admin, admin, false).await,
1342            Err(AuthError::Refused(_))
1343        ));
1344    }
1345
1346    #[tokio::test]
1347    async fn the_last_superuser_cannot_be_deactivated() {
1348        let (pool, _guard) = test_db().await;
1349        let only = seed_user(&pool, "root", "hunter2", true).await;
1350        let editor = seed_user(&pool, "editor", "hunter2", false).await;
1351        let svc = service(pool.clone());
1352
1353        // An ordinary operator acting on the only superuser still cannot leave
1354        // the panel without one.
1355        assert!(matches!(
1356            svc.set_user_active(editor, only, false).await,
1357            Err(AuthError::Refused(_))
1358        ));
1359
1360        // With a second superuser, the first may be deactivated.
1361        let second = seed_user(&pool, "root2", "hunter2", true).await;
1362        svc.set_user_active(second, only, false).await.unwrap();
1363    }
1364
1365    #[tokio::test]
1366    async fn audit_entries_record_and_read_back_newest_first() {
1367        let (pool, _guard) = test_db().await;
1368        let actor = seed_user(&pool, "root", "hunter2", true).await;
1369        let svc = service(pool);
1370
1371        svc.record_audit(AuditEntry {
1372            actor_user_id: Some(actor),
1373            actor_username: "root",
1374            action: "backend.role.update",
1375            target_type: Some("backend_role"),
1376            target_id: Some("42"),
1377            detail: Some(r#"{"added":["a.b"]}"#),
1378        })
1379        .await
1380        .unwrap();
1381        svc.record_audit(AuditEntry {
1382            actor_user_id: Some(actor),
1383            actor_username: "root",
1384            action: "backend.plugin.disable",
1385            target_type: Some("plugin"),
1386            target_id: Some("acme.widgets"),
1387            detail: None,
1388        })
1389        .await
1390        .unwrap();
1391
1392        let entries = svc.recent_audit(10).await.unwrap();
1393        assert_eq!(entries.len(), 2);
1394        // Newest first.
1395        assert_eq!(entries[0].action, "backend.plugin.disable");
1396        assert_eq!(entries[0].target_id.as_deref(), Some("acme.widgets"));
1397        assert_eq!(entries[0].detail, None);
1398        assert_eq!(entries[1].action, "backend.role.update");
1399        assert_eq!(entries[1].actor_username, "root");
1400        assert_eq!(entries[1].actor_user_id, Some(actor));
1401        assert_eq!(entries[1].detail.as_deref(), Some(r#"{"added":["a.b"]}"#));
1402    }
1403
1404    #[tokio::test]
1405    async fn authenticate_issues_a_verifiable_session() {
1406        let (pool, _guard) = test_db().await;
1407        seed_user(&pool, "root", "hunter2", true).await;
1408        let svc = service(pool);
1409
1410        let session = svc
1411            .authenticate("root", "hunter2", &RequestContext::default())
1412            .await
1413            .expect("login should succeed");
1414        let identity = svc
1415            .verify_session(&session.token)
1416            .await
1417            .expect("session should resolve");
1418
1419        assert_eq!(identity.user.username, "root");
1420        assert_eq!(identity.user.full_name(), "Test Operator");
1421        assert!(identity.allows("anything.superuser.can.do"));
1422    }
1423
1424    #[tokio::test]
1425    async fn session_data_blob_round_trips() {
1426        let (pool, _guard) = test_db().await;
1427        seed_user(&pool, "root", "hunter2", true).await;
1428        let svc = service(pool);
1429
1430        let session = svc
1431            .authenticate("root", "hunter2", &RequestContext::default())
1432            .await
1433            .unwrap();
1434
1435        // A fresh session has no blob.
1436        let resolved = svc.resolve_session(&session.token).await.unwrap();
1437        assert_eq!(resolved.data, None);
1438        assert_eq!(resolved.identity.user.username, "root");
1439
1440        // The surface writes its opaque state; the next resolve reads it back.
1441        svc.set_session_data(&session.token, r#"{"v":1,"csrf":"abc"}"#)
1442            .await
1443            .unwrap();
1444        let resolved = svc.resolve_session(&session.token).await.unwrap();
1445        assert_eq!(resolved.data.as_deref(), Some(r#"{"v":1,"csrf":"abc"}"#));
1446    }
1447
1448    #[tokio::test]
1449    async fn username_is_case_insensitive_across_backends() {
1450        // The account is created lower-cased, and a differently-cased login
1451        // resolves to it: this must hold identically on every backend (MySQL's
1452        // collation is case-insensitive, Postgres and SQLite are not).
1453        let (pool, _guard) = test_db().await;
1454        let hash = password::hash_password("pw").unwrap();
1455        store::create_user(
1456            &pool,
1457            "Root",
1458            "Root@Example.test",
1459            "Case",
1460            None,
1461            &hash,
1462            true,
1463        )
1464        .await
1465        .unwrap();
1466        let svc = service(pool);
1467        let session = svc
1468            .authenticate("ROOT", "pw", &RequestContext::default())
1469            .await
1470            .expect("case-varied login should resolve to the same account");
1471        let identity = svc.verify_session(&session.token).await.unwrap();
1472        assert_eq!(identity.user.username, "root");
1473        assert_eq!(identity.user.email, "root@example.test");
1474    }
1475
1476    #[tokio::test]
1477    async fn full_name_falls_back_to_first_name_when_last_is_absent() {
1478        let (pool, _guard) = test_db().await;
1479        let hash = password::hash_password("pw").unwrap();
1480        store::create_user(
1481            &pool,
1482            "mono",
1483            "mono@example.test",
1484            "Prakash",
1485            None,
1486            &hash,
1487            true,
1488        )
1489        .await
1490        .unwrap();
1491        let svc = service(pool);
1492        let session = svc
1493            .authenticate("mono", "pw", &RequestContext::default())
1494            .await
1495            .unwrap();
1496        let identity = svc.verify_session(&session.token).await.unwrap();
1497        assert_eq!(identity.user.full_name(), "Prakash");
1498    }
1499
1500    #[tokio::test]
1501    async fn operator_timezone_round_trips_and_clears() {
1502        let (pool, _guard) = test_db().await;
1503        let id = seed_user(&pool, "tz", "pw", true).await;
1504
1505        // A fresh operator has no preference and inherits the default.
1506        let user = store::find_active_user_by_id(&pool, id)
1507            .await
1508            .unwrap()
1509            .unwrap();
1510        assert_eq!(user.timezone, None);
1511
1512        let svc = service(pool.clone());
1513        svc.set_user_timezone(id, Some("Asia/Kolkata"))
1514            .await
1515            .unwrap();
1516        let user = store::find_active_user_by_id(&pool, id)
1517            .await
1518            .unwrap()
1519            .unwrap();
1520        assert_eq!(user.timezone.as_deref(), Some("Asia/Kolkata"));
1521
1522        // Clearing it returns the operator to the default.
1523        svc.set_user_timezone(id, None).await.unwrap();
1524        let user = store::find_active_user_by_id(&pool, id)
1525            .await
1526            .unwrap()
1527            .unwrap();
1528        assert_eq!(user.timezone, None);
1529    }
1530
1531    #[tokio::test]
1532    async fn operator_locale_round_trips_and_clears() {
1533        let (pool, _guard) = test_db().await;
1534        let id = seed_user(&pool, "loc", "pw", true).await;
1535
1536        // A fresh operator has no preference and inherits the default.
1537        let user = store::find_active_user_by_id(&pool, id)
1538            .await
1539            .unwrap()
1540            .unwrap();
1541        assert_eq!(user.locale, None);
1542
1543        let svc = service(pool.clone());
1544        svc.set_user_locale(id, Some("kn")).await.unwrap();
1545        let user = store::find_active_user_by_id(&pool, id)
1546            .await
1547            .unwrap()
1548            .unwrap();
1549        assert_eq!(user.locale.as_deref(), Some("kn"));
1550
1551        // Clearing it returns the operator to the default.
1552        svc.set_user_locale(id, None).await.unwrap();
1553        let user = store::find_active_user_by_id(&pool, id)
1554            .await
1555            .unwrap()
1556            .unwrap();
1557        assert_eq!(user.locale, None);
1558    }
1559
1560    #[tokio::test]
1561    async fn has_any_operator_flips_after_the_first_account() {
1562        let (pool, _guard) = test_db().await;
1563        let svc = service(pool);
1564
1565        // A fresh install has no operators, so setup (not login) applies.
1566        assert!(!svc.has_any_operator().await.unwrap());
1567
1568        svc.create_superuser(NewOperator {
1569            username: "first",
1570            email: "first@example.test",
1571            first_name: "First",
1572            last_name: None,
1573            password: "hunter2",
1574            timezone: Some("Asia/Kolkata"),
1575        })
1576        .await
1577        .unwrap();
1578
1579        assert!(svc.has_any_operator().await.unwrap());
1580
1581        // The account is a usable superuser with the onboarding timezone recorded.
1582        let session = svc
1583            .authenticate("first", "hunter2", &RequestContext::default())
1584            .await
1585            .unwrap();
1586        let identity = svc.verify_session(&session.token).await.unwrap();
1587        assert!(identity.allows("anything.a.superuser.can.do"));
1588        assert_eq!(identity.user.timezone.as_deref(), Some("Asia/Kolkata"));
1589    }
1590
1591    #[tokio::test]
1592    async fn wrong_password_is_rejected() {
1593        let (pool, _guard) = test_db().await;
1594        seed_user(&pool, "root", "hunter2", false).await;
1595        let svc = service(pool);
1596
1597        let err = svc
1598            .authenticate("root", "wrong", &RequestContext::default())
1599            .await
1600            .unwrap_err();
1601        assert!(matches!(err, AuthError::InvalidCredentials));
1602    }
1603
1604    #[tokio::test]
1605    async fn unknown_user_is_rejected_without_distinction() {
1606        let (pool, _guard) = test_db().await;
1607        let svc = service(pool);
1608        let err = svc
1609            .authenticate("ghost", "whatever", &RequestContext::default())
1610            .await
1611            .unwrap_err();
1612        assert!(matches!(err, AuthError::InvalidCredentials));
1613    }
1614
1615    #[tokio::test]
1616    async fn lockout_trips_after_max_failures() {
1617        let (pool, _guard) = test_db().await;
1618        seed_user(&pool, "root", "hunter2", false).await;
1619        let svc = AuthService::new(
1620            pool,
1621            AuthConfig {
1622                max_failures: 3,
1623                ..AuthConfig::default()
1624            },
1625        );
1626        let ctx = RequestContext::default();
1627
1628        for _ in 0..3 {
1629            let err = svc.authenticate("root", "bad", &ctx).await.unwrap_err();
1630            assert!(matches!(err, AuthError::InvalidCredentials));
1631        }
1632        // The correct password is now refused: the account is locked out.
1633        let err = svc.authenticate("root", "hunter2", &ctx).await.unwrap_err();
1634        assert!(matches!(err, AuthError::TooManyAttempts));
1635    }
1636
1637    #[tokio::test]
1638    async fn permissions_come_from_assigned_roles() {
1639        let (pool, _guard) = test_db().await;
1640        let user_id = seed_user(&pool, "mod", "pw", false).await;
1641        let role_id = store::create_role(
1642            &pool,
1643            "content_editor",
1644            "Content Editor",
1645            &["posts.*".to_string()],
1646        )
1647        .await
1648        .unwrap();
1649        store::assign_role(&pool, user_id, role_id).await.unwrap();
1650
1651        let svc = service(pool);
1652        let session = svc
1653            .authenticate("mod", "pw", &RequestContext::default())
1654            .await
1655            .unwrap();
1656        let identity = svc.verify_session(&session.token).await.unwrap();
1657
1658        assert!(identity.allows("posts.approve"));
1659        assert!(!identity.allows("users.edit"));
1660        identity.require("posts.edit").unwrap();
1661        assert!(identity.require("users.edit").is_err());
1662    }
1663
1664    #[tokio::test]
1665    async fn logout_invalidates_the_session() {
1666        let (pool, _guard) = test_db().await;
1667        seed_user(&pool, "root", "pw", true).await;
1668        let svc = service(pool);
1669
1670        let session = svc
1671            .authenticate("root", "pw", &RequestContext::default())
1672            .await
1673            .unwrap();
1674        svc.verify_session(&session.token).await.unwrap();
1675        svc.logout(&session.token).await.unwrap();
1676
1677        let err = svc.verify_session(&session.token).await.unwrap_err();
1678        assert!(matches!(err, AuthError::SessionInvalid));
1679    }
1680
1681    #[tokio::test]
1682    async fn inactive_account_is_refused_after_correct_password() {
1683        let (pool, _guard) = test_db().await;
1684        let user_id = seed_user(&pool, "root", "pw", false).await;
1685        // Deactivate through the query layer so the placeholder renders per backend.
1686        let (sql, values) = laterite_core::query::build(
1687            pool.backend,
1688            sea_query::Query::update()
1689                .table(crate::schema::BackendUsers::Table)
1690                .value(crate::schema::BackendUsers::IsActive, false)
1691                .and_where(sea_query::Expr::col(crate::schema::BackendUsers::Id).eq(user_id))
1692                .to_owned(),
1693        );
1694        laterite_core::query::bind_values(sqlx::query(&sql), values)
1695            .execute(&pool.pool)
1696            .await
1697            .unwrap();
1698
1699        let svc = service(pool);
1700        let err = svc
1701            .authenticate("root", "pw", &RequestContext::default())
1702            .await
1703            .unwrap_err();
1704        assert!(matches!(err, AuthError::InactiveAccount));
1705    }
1706
1707    #[tokio::test]
1708    async fn reset_password_updates_the_hash() {
1709        let (pool, _guard) = test_db().await;
1710        seed_user(&pool, "root", "oldpw", true).await;
1711
1712        let new_hash = password::hash_password("newpw").unwrap();
1713        let affected = store::update_password_by_username(&pool, "root", &new_hash)
1714            .await
1715            .unwrap();
1716        assert_eq!(affected, 1);
1717
1718        let svc = service(pool);
1719        let ctx = RequestContext::default();
1720        assert!(matches!(
1721            svc.authenticate("root", "oldpw", &ctx).await.unwrap_err(),
1722            AuthError::InvalidCredentials
1723        ));
1724        svc.authenticate("root", "newpw", &ctx).await.unwrap();
1725    }
1726
1727    #[tokio::test]
1728    async fn reset_password_reports_unknown_user() {
1729        let (pool, _guard) = test_db().await;
1730        let hash = password::hash_password("x").unwrap();
1731        let affected = store::update_password_by_username(&pool, "ghost", &hash)
1732            .await
1733            .unwrap();
1734        assert_eq!(affected, 0);
1735    }
1736
1737    #[tokio::test]
1738    async fn list_users_returns_all_seeded() {
1739        let (pool, _guard) = test_db().await;
1740        seed_user(&pool, "alice", "pw", true).await;
1741        seed_user(&pool, "bob", "pw", false).await;
1742
1743        let users = store::list_backend_users(&pool).await.unwrap();
1744        assert_eq!(users.len(), 2);
1745        assert!(users
1746            .iter()
1747            .any(|u| u.username == "alice" && u.is_superuser));
1748        assert!(users.iter().any(|u| u.username == "bob" && !u.is_superuser));
1749    }
1750
1751    #[tokio::test]
1752    async fn unlock_clears_the_lockout() {
1753        let (pool, _guard) = test_db().await;
1754        seed_user(&pool, "root", "pw", false).await;
1755        let svc = AuthService::new(
1756            pool.clone(),
1757            AuthConfig {
1758                max_failures: 3,
1759                ..AuthConfig::default()
1760            },
1761        );
1762        let ctx = RequestContext::default();
1763
1764        for _ in 0..3 {
1765            let _ = svc.authenticate("root", "bad", &ctx).await;
1766        }
1767        assert!(matches!(
1768            svc.authenticate("root", "pw", &ctx).await.unwrap_err(),
1769            AuthError::TooManyAttempts
1770        ));
1771
1772        let cleared = store::clear_failed_attempts(&pool, "root").await.unwrap();
1773        assert!(cleared >= 3);
1774        svc.authenticate("root", "pw", &ctx).await.unwrap();
1775    }
1776}