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_ttl_secs = 43200
29/// max_failures = 5
30/// failure_window_secs = 900
31/// ```
32#[derive(Debug, Clone, Deserialize)]
33#[serde(default)]
34pub struct AuthConfig {
35    /// How long an issued session remains valid.
36    #[serde(rename = "session_ttl_secs", deserialize_with = "de_secs")]
37    pub session_ttl: Duration,
38    /// Failed attempts within `failure_window` before a username is locked out.
39    pub max_failures: i64,
40    /// The window over which failed attempts are counted.
41    #[serde(rename = "failure_window_secs", deserialize_with = "de_secs")]
42    pub failure_window: Duration,
43}
44
45impl Default for AuthConfig {
46    fn default() -> Self {
47        Self {
48            session_ttl: Duration::from_secs(60 * 60 * 12),
49            max_failures: 5,
50            failure_window: Duration::from_secs(60 * 15),
51        }
52    }
53}
54
55/// Deserializes a whole-second count into a `Duration`.
56fn de_secs<'de, D: serde::Deserializer<'de>>(de: D) -> Result<Duration, D::Error> {
57    Ok(Duration::from_secs(u64::deserialize(de)?))
58}
59
60#[cfg(test)]
61mod auth_config_tests {
62    use super::AuthConfig;
63    use std::time::Duration;
64
65    #[test]
66    fn unset_keys_keep_defaults() {
67        let cfg: AuthConfig = serde_json::from_str(r#"{"max_failures": 3}"#).unwrap();
68        assert_eq!(cfg.max_failures, 3);
69        assert_eq!(cfg.session_ttl, Duration::from_secs(60 * 60 * 12));
70        assert_eq!(cfg.failure_window, Duration::from_secs(60 * 15));
71    }
72
73    #[test]
74    fn seconds_map_to_durations() {
75        let cfg: AuthConfig = serde_json::from_str(
76            r#"{"session_ttl_secs": 3600, "max_failures": 7, "failure_window_secs": 120}"#,
77        )
78        .unwrap();
79        assert_eq!(cfg.session_ttl, Duration::from_secs(3600));
80        assert_eq!(cfg.max_failures, 7);
81        assert_eq!(cfg.failure_window, Duration::from_secs(120));
82    }
83}
84
85/// Per-request context recorded in the access log.
86#[derive(Debug, Clone, Default)]
87pub struct RequestContext {
88    pub ip_address: Option<String>,
89    pub user_agent: Option<String>,
90}
91
92/// A freshly issued session. `token` is the raw bearer value for the client
93/// cookie; only its hash is persisted.
94#[derive(Debug, Clone)]
95pub struct IssuedSession {
96    pub token: String,
97    pub expires_at: DateTime<Utc>,
98}
99
100/// An authenticated backend user together with the permissions in force.
101#[derive(Debug, Clone)]
102pub struct AuthenticatedUser {
103    pub user: BackendUser,
104    pub permissions: PermissionSet,
105}
106
107impl AuthenticatedUser {
108    /// Whether this identity holds `permission`.
109    pub fn allows(&self, permission: &str) -> bool {
110        self.permissions.allows(permission)
111    }
112
113    /// Returns an error unless this identity holds `permission`.
114    pub fn require(&self, permission: &str) -> Result<(), AuthError> {
115        if self.allows(permission) {
116            Ok(())
117        } else {
118            Err(AuthError::PermissionDenied(permission.to_string()))
119        }
120    }
121}
122
123/// The details for creating a backend operator. Used by the CLI and the
124/// first-run setup screen so account creation has one code path.
125#[derive(Debug, Clone)]
126pub struct NewOperator<'a> {
127    pub username: &'a str,
128    pub email: &'a str,
129    pub first_name: &'a str,
130    pub last_name: Option<&'a str>,
131    pub password: &'a str,
132    /// The operator's display timezone (an IANA name), or `None` to inherit the
133    /// deployment default.
134    pub timezone: Option<&'a str>,
135}
136
137/// The auth service. Cheap to clone: it holds a database handle and config.
138#[derive(Clone)]
139pub struct AuthService {
140    db: Db,
141    config: AuthConfig,
142}
143
144impl AuthService {
145    pub fn new(db: Db, config: AuthConfig) -> Self {
146        Self { db, config }
147    }
148
149    /// Verifies a username and password, and on success issues a session.
150    ///
151    /// Failures are throttled per username and every outcome is logged. The
152    /// error deliberately does not reveal whether the username exists.
153    pub async fn authenticate(
154        &self,
155        username: &str,
156        password: &str,
157        ctx: &RequestContext,
158    ) -> Result<IssuedSession, AuthError> {
159        let now = Utc::now();
160        let since = now - chrono_from_std(self.config.failure_window);
161
162        if store::count_recent_failures(&self.db, username, since).await?
163            >= self.config.max_failures
164        {
165            self.log(None, username, AccessEvent::LockedOut, ctx)
166                .await?;
167            return Err(AuthError::TooManyAttempts);
168        }
169
170        let user = match store::find_user_by_username(&self.db, username).await? {
171            Some(user) => user,
172            None => {
173                self.log(None, username, AccessEvent::LoginFailure, ctx)
174                    .await?;
175                return Err(AuthError::InvalidCredentials);
176            }
177        };
178
179        if !password::verify_password(password, &user.password_hash)? {
180            self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
181                .await?;
182            return Err(AuthError::InvalidCredentials);
183        }
184
185        if !user.is_active {
186            self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
187                .await?;
188            return Err(AuthError::InactiveAccount);
189        }
190
191        let token = generate_token();
192        let expires_at = now + chrono_from_std(self.config.session_ttl);
193        store::insert_session(&self.db, &hash_token(&token), user.id, expires_at).await?;
194        self.log(Some(user.id), username, AccessEvent::LoginSuccess, ctx)
195            .await?;
196
197        Ok(IssuedSession { token, expires_at })
198    }
199
200    /// Resolves a raw session token to an identity, refreshing its last-seen
201    /// time. Expired sessions, and sessions whose user was disabled or removed,
202    /// resolve to [`AuthError::SessionInvalid`].
203    pub async fn verify_session(&self, token: &str) -> Result<AuthenticatedUser, AuthError> {
204        let token_hash = hash_token(token);
205        let now = Utc::now();
206
207        let user_id = store::find_valid_session(&self.db, &token_hash, now)
208            .await?
209            .ok_or(AuthError::SessionInvalid)?;
210        let user = store::find_active_user_by_id(&self.db, user_id)
211            .await?
212            .ok_or(AuthError::SessionInvalid)?;
213        store::touch_session(&self.db, &token_hash, now).await?;
214
215        let grants = store::load_role_permissions(&self.db, user.id)
216            .await?
217            .into_iter()
218            .flatten();
219        // Split the user's per-permission overrides into allow (1) and deny (-1),
220        // which take precedence over the role grants.
221        let overrides = store::load_user_permission_overrides(&self.db, user.id).await?;
222        let (mut allow, mut deny) = (Vec::new(), Vec::new());
223        for (code, decision) in overrides {
224            match decision.signum() {
225                1 => allow.push(code),
226                -1 => deny.push(code),
227                _ => {}
228            }
229        }
230        let permissions = PermissionSet::with_overrides(user.is_superuser, grants, allow, deny);
231
232        Ok(AuthenticatedUser { user, permissions })
233    }
234
235    /// Invalidates a session. Unknown tokens are a no-op.
236    pub async fn logout(&self, token: &str) -> Result<(), AuthError> {
237        store::delete_session(&self.db, &hash_token(token)).await
238    }
239
240    /// Persists an operator's own display timezone. `Some(name)` sets an IANA
241    /// timezone; `None` clears it so the operator falls back to the deployment
242    /// default. Validating that `name` is a real timezone is the caller's job.
243    pub async fn set_user_timezone(
244        &self,
245        user_id: i64,
246        timezone: Option<&str>,
247    ) -> Result<(), AuthError> {
248        store::set_user_timezone(&self.db, user_id, timezone).await
249    }
250
251    /// Loads a user's per-permission overrides (code to `1` allow or `-1` deny).
252    pub async fn user_permission_overrides(
253        &self,
254        user_id: i64,
255    ) -> Result<std::collections::HashMap<String, i64>, AuthError> {
256        store::load_user_permission_overrides(&self.db, user_id).await
257    }
258
259    /// Replaces a user's per-permission overrides. Callers pass only `1` and `-1`
260    /// entries; an inherited permission is represented by its absence.
261    pub async fn set_user_permissions(
262        &self,
263        user_id: i64,
264        overrides: &std::collections::HashMap<String, i64>,
265    ) -> Result<(), AuthError> {
266        store::set_user_permissions(&self.db, user_id, overrides).await
267    }
268
269    /// Whether any backend operator exists yet. A fresh install with none is
270    /// routed to first-run setup instead of login.
271    pub async fn has_any_operator(&self) -> Result<bool, AuthError> {
272        store::any_user_exists(&self.db).await
273    }
274
275    /// Creates a superuser operator: hashes the password, inserts the user, and
276    /// records their timezone preference. The single account-creation path,
277    /// shared by the CLI and the first-run setup screen.
278    pub async fn create_superuser(&self, new: NewOperator<'_>) -> Result<i64, AuthError> {
279        let hash = password::hash_password(new.password)?;
280        let id = store::create_user(
281            &self.db,
282            new.username,
283            new.email,
284            new.first_name,
285            new.last_name,
286            &hash,
287            true,
288        )
289        .await?;
290        if new.timezone.is_some() {
291            store::set_user_timezone(&self.db, id, new.timezone).await?;
292        }
293        Ok(id)
294    }
295
296    async fn log(
297        &self,
298        user_id: Option<i64>,
299        username: &str,
300        event: AccessEvent,
301        ctx: &RequestContext,
302    ) -> Result<(), AuthError> {
303        store::insert_access_log(
304            &self.db,
305            user_id,
306            username,
307            event,
308            ctx.ip_address.as_deref(),
309            ctx.user_agent.as_deref(),
310        )
311        .await
312    }
313}
314
315/// Converts a small, in-range `std::time::Duration` to `chrono::Duration`.
316/// The auth policy durations are hours at most, well within range.
317fn chrono_from_std(d: Duration) -> chrono::Duration {
318    chrono::Duration::from_std(d).expect("auth policy duration out of range")
319}
320
321fn generate_token() -> String {
322    let mut bytes = [0u8; 32];
323    rand::rngs::OsRng.fill_bytes(&mut bytes);
324    to_hex(&bytes)
325}
326
327fn hash_token(token: &str) -> String {
328    let mut hasher = Sha256::new();
329    hasher.update(token.as_bytes());
330    to_hex(&hasher.finalize())
331}
332
333fn to_hex(bytes: &[u8]) -> String {
334    let mut out = String::with_capacity(bytes.len() * 2);
335    for byte in bytes {
336        let _ = write!(out, "{byte:02x}");
337    }
338    out
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    async fn seed_user(db: &Db, username: &str, password: &str, superuser: bool) -> i64 {
346        let hash = password::hash_password(password).unwrap();
347        store::create_user(
348            db,
349            username,
350            &format!("{username}@example.test"),
351            "Test",
352            Some("Operator"),
353            &hash,
354            superuser,
355        )
356        .await
357        .unwrap()
358    }
359
360    fn service(db: Db) -> AuthService {
361        AuthService::new(db, AuthConfig::default())
362    }
363
364    /// A fresh test database with this module's migrations applied through the
365    /// framework runner, on whichever backend the run targets (see
366    /// `laterite_core::testing`). Hold the returned guard for the test's lifetime.
367    async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
368        laterite_core::testing::connect_test(&[crate::migrations()]).await
369    }
370
371    #[tokio::test]
372    async fn authenticate_issues_a_verifiable_session() {
373        let (pool, _guard) = test_db().await;
374        seed_user(&pool, "root", "hunter2", true).await;
375        let svc = service(pool);
376
377        let session = svc
378            .authenticate("root", "hunter2", &RequestContext::default())
379            .await
380            .expect("login should succeed");
381        let identity = svc
382            .verify_session(&session.token)
383            .await
384            .expect("session should resolve");
385
386        assert_eq!(identity.user.username, "root");
387        assert_eq!(identity.user.full_name(), "Test Operator");
388        assert!(identity.allows("anything.superuser.can.do"));
389    }
390
391    #[tokio::test]
392    async fn username_is_case_insensitive_across_backends() {
393        // The account is created lower-cased, and a differently-cased login
394        // resolves to it: this must hold identically on every backend (MySQL's
395        // collation is case-insensitive, Postgres and SQLite are not).
396        let (pool, _guard) = test_db().await;
397        let hash = password::hash_password("pw").unwrap();
398        store::create_user(
399            &pool,
400            "Root",
401            "Root@Example.test",
402            "Case",
403            None,
404            &hash,
405            true,
406        )
407        .await
408        .unwrap();
409        let svc = service(pool);
410        let session = svc
411            .authenticate("ROOT", "pw", &RequestContext::default())
412            .await
413            .expect("case-varied login should resolve to the same account");
414        let identity = svc.verify_session(&session.token).await.unwrap();
415        assert_eq!(identity.user.username, "root");
416        assert_eq!(identity.user.email, "root@example.test");
417    }
418
419    #[tokio::test]
420    async fn full_name_falls_back_to_first_name_when_last_is_absent() {
421        let (pool, _guard) = test_db().await;
422        let hash = password::hash_password("pw").unwrap();
423        store::create_user(
424            &pool,
425            "mono",
426            "mono@example.test",
427            "Prakash",
428            None,
429            &hash,
430            true,
431        )
432        .await
433        .unwrap();
434        let svc = service(pool);
435        let session = svc
436            .authenticate("mono", "pw", &RequestContext::default())
437            .await
438            .unwrap();
439        let identity = svc.verify_session(&session.token).await.unwrap();
440        assert_eq!(identity.user.full_name(), "Prakash");
441    }
442
443    #[tokio::test]
444    async fn operator_timezone_round_trips_and_clears() {
445        let (pool, _guard) = test_db().await;
446        let id = seed_user(&pool, "tz", "pw", true).await;
447
448        // A fresh operator has no preference and inherits the default.
449        let user = store::find_active_user_by_id(&pool, id)
450            .await
451            .unwrap()
452            .unwrap();
453        assert_eq!(user.timezone, None);
454
455        let svc = service(pool.clone());
456        svc.set_user_timezone(id, Some("Asia/Kolkata"))
457            .await
458            .unwrap();
459        let user = store::find_active_user_by_id(&pool, id)
460            .await
461            .unwrap()
462            .unwrap();
463        assert_eq!(user.timezone.as_deref(), Some("Asia/Kolkata"));
464
465        // Clearing it returns the operator to the default.
466        svc.set_user_timezone(id, None).await.unwrap();
467        let user = store::find_active_user_by_id(&pool, id)
468            .await
469            .unwrap()
470            .unwrap();
471        assert_eq!(user.timezone, None);
472    }
473
474    #[tokio::test]
475    async fn has_any_operator_flips_after_the_first_account() {
476        let (pool, _guard) = test_db().await;
477        let svc = service(pool);
478
479        // A fresh install has no operators, so setup (not login) applies.
480        assert!(!svc.has_any_operator().await.unwrap());
481
482        svc.create_superuser(NewOperator {
483            username: "first",
484            email: "first@example.test",
485            first_name: "First",
486            last_name: None,
487            password: "hunter2",
488            timezone: Some("Asia/Kolkata"),
489        })
490        .await
491        .unwrap();
492
493        assert!(svc.has_any_operator().await.unwrap());
494
495        // The account is a usable superuser with the onboarding timezone recorded.
496        let session = svc
497            .authenticate("first", "hunter2", &RequestContext::default())
498            .await
499            .unwrap();
500        let identity = svc.verify_session(&session.token).await.unwrap();
501        assert!(identity.allows("anything.a.superuser.can.do"));
502        assert_eq!(identity.user.timezone.as_deref(), Some("Asia/Kolkata"));
503    }
504
505    #[tokio::test]
506    async fn wrong_password_is_rejected() {
507        let (pool, _guard) = test_db().await;
508        seed_user(&pool, "root", "hunter2", false).await;
509        let svc = service(pool);
510
511        let err = svc
512            .authenticate("root", "wrong", &RequestContext::default())
513            .await
514            .unwrap_err();
515        assert!(matches!(err, AuthError::InvalidCredentials));
516    }
517
518    #[tokio::test]
519    async fn unknown_user_is_rejected_without_distinction() {
520        let (pool, _guard) = test_db().await;
521        let svc = service(pool);
522        let err = svc
523            .authenticate("ghost", "whatever", &RequestContext::default())
524            .await
525            .unwrap_err();
526        assert!(matches!(err, AuthError::InvalidCredentials));
527    }
528
529    #[tokio::test]
530    async fn lockout_trips_after_max_failures() {
531        let (pool, _guard) = test_db().await;
532        seed_user(&pool, "root", "hunter2", false).await;
533        let svc = AuthService::new(
534            pool,
535            AuthConfig {
536                max_failures: 3,
537                ..AuthConfig::default()
538            },
539        );
540        let ctx = RequestContext::default();
541
542        for _ in 0..3 {
543            let err = svc.authenticate("root", "bad", &ctx).await.unwrap_err();
544            assert!(matches!(err, AuthError::InvalidCredentials));
545        }
546        // The correct password is now refused: the account is locked out.
547        let err = svc.authenticate("root", "hunter2", &ctx).await.unwrap_err();
548        assert!(matches!(err, AuthError::TooManyAttempts));
549    }
550
551    #[tokio::test]
552    async fn permissions_come_from_assigned_roles() {
553        let (pool, _guard) = test_db().await;
554        let user_id = seed_user(&pool, "mod", "pw", false).await;
555        let role_id = store::create_role(
556            &pool,
557            "content_editor",
558            "Content Editor",
559            &["posts.*".to_string()],
560        )
561        .await
562        .unwrap();
563        store::assign_role(&pool, user_id, role_id).await.unwrap();
564
565        let svc = service(pool);
566        let session = svc
567            .authenticate("mod", "pw", &RequestContext::default())
568            .await
569            .unwrap();
570        let identity = svc.verify_session(&session.token).await.unwrap();
571
572        assert!(identity.allows("posts.approve"));
573        assert!(!identity.allows("users.edit"));
574        identity.require("posts.edit").unwrap();
575        assert!(identity.require("users.edit").is_err());
576    }
577
578    #[tokio::test]
579    async fn logout_invalidates_the_session() {
580        let (pool, _guard) = test_db().await;
581        seed_user(&pool, "root", "pw", true).await;
582        let svc = service(pool);
583
584        let session = svc
585            .authenticate("root", "pw", &RequestContext::default())
586            .await
587            .unwrap();
588        svc.verify_session(&session.token).await.unwrap();
589        svc.logout(&session.token).await.unwrap();
590
591        let err = svc.verify_session(&session.token).await.unwrap_err();
592        assert!(matches!(err, AuthError::SessionInvalid));
593    }
594
595    #[tokio::test]
596    async fn inactive_account_is_refused_after_correct_password() {
597        let (pool, _guard) = test_db().await;
598        let user_id = seed_user(&pool, "root", "pw", false).await;
599        // Deactivate through the query layer so the placeholder renders per backend.
600        let (sql, values) = laterite_core::query::build(
601            pool.backend,
602            sea_query::Query::update()
603                .table(crate::schema::BackendUsers::Table)
604                .value(crate::schema::BackendUsers::IsActive, false)
605                .and_where(sea_query::Expr::col(crate::schema::BackendUsers::Id).eq(user_id))
606                .to_owned(),
607        );
608        laterite_core::query::bind_values(sqlx::query(&sql), values)
609            .execute(&pool.pool)
610            .await
611            .unwrap();
612
613        let svc = service(pool);
614        let err = svc
615            .authenticate("root", "pw", &RequestContext::default())
616            .await
617            .unwrap_err();
618        assert!(matches!(err, AuthError::InactiveAccount));
619    }
620
621    #[tokio::test]
622    async fn reset_password_updates_the_hash() {
623        let (pool, _guard) = test_db().await;
624        seed_user(&pool, "root", "oldpw", true).await;
625
626        let new_hash = password::hash_password("newpw").unwrap();
627        let affected = store::update_password_by_username(&pool, "root", &new_hash)
628            .await
629            .unwrap();
630        assert_eq!(affected, 1);
631
632        let svc = service(pool);
633        let ctx = RequestContext::default();
634        assert!(matches!(
635            svc.authenticate("root", "oldpw", &ctx).await.unwrap_err(),
636            AuthError::InvalidCredentials
637        ));
638        svc.authenticate("root", "newpw", &ctx).await.unwrap();
639    }
640
641    #[tokio::test]
642    async fn reset_password_reports_unknown_user() {
643        let (pool, _guard) = test_db().await;
644        let hash = password::hash_password("x").unwrap();
645        let affected = store::update_password_by_username(&pool, "ghost", &hash)
646            .await
647            .unwrap();
648        assert_eq!(affected, 0);
649    }
650
651    #[tokio::test]
652    async fn list_users_returns_all_seeded() {
653        let (pool, _guard) = test_db().await;
654        seed_user(&pool, "alice", "pw", true).await;
655        seed_user(&pool, "bob", "pw", false).await;
656
657        let users = store::list_backend_users(&pool).await.unwrap();
658        assert_eq!(users.len(), 2);
659        assert!(users
660            .iter()
661            .any(|u| u.username == "alice" && u.is_superuser));
662        assert!(users.iter().any(|u| u.username == "bob" && !u.is_superuser));
663    }
664
665    #[tokio::test]
666    async fn unlock_clears_the_lockout() {
667        let (pool, _guard) = test_db().await;
668        seed_user(&pool, "root", "pw", false).await;
669        let svc = AuthService::new(
670            pool.clone(),
671            AuthConfig {
672                max_failures: 3,
673                ..AuthConfig::default()
674            },
675        );
676        let ctx = RequestContext::default();
677
678        for _ in 0..3 {
679            let _ = svc.authenticate("root", "bad", &ctx).await;
680        }
681        assert!(matches!(
682            svc.authenticate("root", "pw", &ctx).await.unwrap_err(),
683            AuthError::TooManyAttempts
684        ));
685
686        let cleared = store::clear_failed_attempts(&pool, "root").await.unwrap();
687        assert!(cleared >= 3);
688        svc.authenticate("root", "pw", &ctx).await.unwrap();
689    }
690}