1use 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#[derive(Debug, Clone, Deserialize)]
35#[serde(default)]
36pub struct AuthConfig {
37 #[serde(rename = "session_idle_timeout_secs", deserialize_with = "de_secs")]
40 pub session_idle_timeout: Duration,
41 #[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 #[serde(rename = "remember_duration_secs", deserialize_with = "de_secs")]
53 pub remember_duration: Duration,
54 pub max_failures: i64,
56 #[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 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
84fn 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 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 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#[derive(Debug, Clone, Default)]
152pub struct RequestContext {
153 pub ip_address: Option<String>,
154 pub user_agent: Option<String>,
155}
156
157#[derive(Debug, Clone)]
160pub struct IssuedSession {
161 pub token: String,
162 pub expires_at: DateTime<Utc>,
163 pub user_id: i64,
166}
167
168#[derive(Debug, Clone)]
171pub struct RememberCredential {
172 pub cookie: String,
173 pub expires_at: DateTime<Utc>,
174}
175
176#[derive(Debug, Clone)]
179pub struct RecalledSession {
180 pub session: IssuedSession,
181 pub remember: RememberCredential,
182}
183
184#[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 pub ip_address: Option<String>,
195 pub user_agent: Option<String>,
197 pub current: bool,
199}
200
201#[derive(Debug, Clone)]
203pub struct AuthenticatedUser {
204 pub user: BackendUser,
205 pub permissions: PermissionSet,
206}
207
208impl 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#[derive(Debug, Clone)]
220pub struct ResolvedSession {
221 pub identity: AuthenticatedUser,
222 pub data: Option<String>,
223 pub expires_at: DateTime<Utc>,
226}
227
228impl AuthenticatedUser {
229 pub fn allows(&self, permission: &str) -> bool {
231 self.permissions.allows(permission)
232 }
233
234 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#[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 pub timezone: Option<&'a str>,
256}
257
258pub struct AuditEntry<'a> {
262 pub actor_user_id: Option<i64>,
264 pub actor_username: &'a str,
266 pub action: &'a str,
268 pub target_type: Option<&'a str>,
270 pub target_id: Option<&'a str>,
271 pub detail: Option<&'a str>,
273}
274
275#[derive(Clone)]
277pub struct AuthService {
278 db: Db,
279 config: AuthConfig,
280}
281
282impl AuthService {
283 pub fn new(db: Db, config: AuthConfig) -> Self {
284 Self { db, config }
285 }
286
287 pub fn session_absolute_timeout(&self) -> Duration {
290 self.config.session_absolute_timeout
291 }
292
293 pub async fn authenticate(
298 &self,
299 username: &str,
300 password: &str,
301 ctx: &RequestContext,
302 ) -> Result<IssuedSession, AuthError> {
303 let now = Utc::now();
304 let since = now - chrono_from_std(self.config.failure_window);
305
306 if store::count_recent_failures(&self.db, username, since).await?
307 >= self.config.max_failures
308 {
309 self.log(None, username, AccessEvent::LockedOut, ctx)
310 .await?;
311 return Err(AuthError::TooManyAttempts);
312 }
313
314 let user = match store::find_user_by_username(&self.db, username).await? {
315 Some(user) => user,
316 None => {
317 self.log(None, username, AccessEvent::LoginFailure, ctx)
318 .await?;
319 return Err(AuthError::InvalidCredentials);
320 }
321 };
322
323 if !password::verify_password(password, &user.password_hash)? {
324 self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
325 .await?;
326 return Err(AuthError::InvalidCredentials);
327 }
328
329 if !user.is_active {
330 self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
331 .await?;
332 return Err(AuthError::InactiveAccount);
333 }
334
335 let token = generate_token();
336 let expires_at = self.config.deadline(now, now);
337 store::insert_session(
338 &self.db,
339 &hash_token(&token),
340 user.id,
341 expires_at,
342 ctx.ip_address.as_deref(),
343 ctx.user_agent.as_deref(),
344 )
345 .await?;
346 self.log(Some(user.id), username, AccessEvent::LoginSuccess, ctx)
347 .await?;
348
349 Ok(IssuedSession {
350 token,
351 expires_at,
352 user_id: user.id,
353 })
354 }
355
356 pub async fn resolve_session(&self, token: &str) -> Result<ResolvedSession, AuthError> {
361 let token_hash = hash_token(token);
362 let now = Utc::now();
363
364 let session = store::find_valid_session(&self.db, &token_hash, now)
365 .await?
366 .ok_or(AuthError::SessionInvalid)?;
367 let user = store::find_active_user_by_id(&self.db, session.user_id)
368 .await?
369 .ok_or(AuthError::SessionInvalid)?;
370 let idle = chrono_from_std(self.config.session_idle_timeout);
374 let expires_at = self.config.deadline(session.created_at, now);
375 if now - session.last_seen_at > idle / 2 {
376 store::renew_session(&self.db, &token_hash, now, expires_at).await?;
377 }
378
379 let grants = store::load_role_permissions(&self.db, user.id)
380 .await?
381 .into_iter()
382 .flatten();
383 let overrides = store::load_user_permission_overrides(&self.db, user.id).await?;
386 let (mut allow, mut deny) = (Vec::new(), Vec::new());
387 for (code, decision) in overrides {
388 match decision.signum() {
389 1 => allow.push(code),
390 -1 => deny.push(code),
391 _ => {}
392 }
393 }
394 let permissions = PermissionSet::with_overrides(user.is_superuser, grants, allow, deny);
395
396 Ok(ResolvedSession {
397 identity: AuthenticatedUser { user, permissions },
398 data: session.data,
399 expires_at,
400 })
401 }
402
403 pub async fn verify_session(&self, token: &str) -> Result<AuthenticatedUser, AuthError> {
406 Ok(self.resolve_session(token).await?.identity)
407 }
408
409 pub async fn set_session_data(&self, token: &str, data: &str) -> Result<(), AuthError> {
413 store::set_session_data(&self.db, &hash_token(token), data).await
414 }
415
416 pub async fn issue_remember(&self, user_id: i64) -> Result<RememberCredential, AuthError> {
418 let (selector, verifier) = (generate_token(), generate_token());
419 let expires_at = Utc::now() + chrono_from_std(self.config.remember_duration);
420 store::insert_remember_token(
421 &self.db,
422 &selector,
423 &hash_token(&verifier),
424 user_id,
425 expires_at,
426 )
427 .await?;
428 Ok(RememberCredential {
429 cookie: format!("{selector}:{verifier}"),
430 expires_at,
431 })
432 }
433
434 pub async fn consume_remember(
442 &self,
443 cookie: &str,
444 ctx: &RequestContext,
445 ) -> Result<RecalledSession, AuthError> {
446 let (selector, verifier) = cookie.split_once(':').ok_or(AuthError::SessionInvalid)?;
447 let now = Utc::now();
448 let found = store::find_remember_token(&self.db, selector, now)
449 .await?
450 .ok_or(AuthError::SessionInvalid)?;
451
452 if !constant_time_eq(&hash_token(verifier), &found.verifier_hash) {
453 tracing::warn!(
454 user_id = found.user_id,
455 "a stay-signed-in cookie was presented with a stale secret; dropping every credential for this account"
456 );
457 store::delete_user_remember_tokens(&self.db, found.user_id).await?;
458 self.log(Some(found.user_id), "", AccessEvent::LoginFailure, ctx)
459 .await?;
460 return Err(AuthError::SessionInvalid);
461 }
462
463 let user = store::find_active_user_by_id(&self.db, found.user_id)
465 .await?
466 .ok_or(AuthError::SessionInvalid)?;
467
468 let replacement = generate_token();
472 let remember_expires = now + chrono_from_std(self.config.remember_duration);
473 store::rotate_remember_token(
474 &self.db,
475 selector,
476 &hash_token(&replacement),
477 remember_expires,
478 )
479 .await?;
480 let remember = RememberCredential {
481 cookie: format!("{selector}:{replacement}"),
482 expires_at: remember_expires,
483 };
484
485 let token = generate_token();
486 let expires_at = self.config.deadline(now, now);
487 store::insert_session(
488 &self.db,
489 &hash_token(&token),
490 user.id,
491 expires_at,
492 ctx.ip_address.as_deref(),
493 ctx.user_agent.as_deref(),
494 )
495 .await?;
496 self.log(
497 Some(user.id),
498 &user.username,
499 AccessEvent::LoginSuccess,
500 ctx,
501 )
502 .await?;
503
504 Ok(RecalledSession {
505 session: IssuedSession {
506 token,
507 expires_at,
508 user_id: user.id,
509 },
510 remember,
511 })
512 }
513
514 pub async fn revoke_remember(&self, cookie: &str) -> Result<(), AuthError> {
517 if let Some((selector, _)) = cookie.split_once(':') {
518 store::delete_remember_token(&self.db, selector).await?;
519 }
520 Ok(())
521 }
522
523 pub async fn list_sessions(
526 &self,
527 user_id: i64,
528 current_token: &str,
529 ) -> Result<Vec<ActiveSession>, AuthError> {
530 let current_hash = hash_token(current_token);
531 let rows = store::list_user_sessions(&self.db, user_id, Utc::now()).await?;
532 Ok(rows
533 .into_iter()
534 .map(|s| ActiveSession {
535 id: public_session_id(&s.token_hash),
536 created_at: s.created_at,
537 last_seen_at: s.last_seen_at,
538 expires_at: s.expires_at,
539 ip_address: s.ip_address,
540 user_agent: s.user_agent,
541 current: s.token_hash == current_hash,
542 })
543 .collect())
544 }
545
546 pub async fn revoke_session(&self, user_id: i64, id: &str) -> Result<bool, AuthError> {
550 let rows = store::list_user_sessions(&self.db, user_id, Utc::now()).await?;
551 let Some(target) = rows
552 .into_iter()
553 .find(|s| constant_time_eq(&public_session_id(&s.token_hash), id))
554 else {
555 return Ok(false);
556 };
557 let done = store::delete_user_session(&self.db, user_id, &target.token_hash).await?;
558 Ok(done > 0)
559 }
560
561 pub async fn sign_out_everywhere(
566 &self,
567 user_id: i64,
568 keep_token: &str,
569 ) -> Result<u64, AuthError> {
570 let ended =
571 store::delete_user_sessions_except(&self.db, user_id, &hash_token(keep_token)).await?;
572 store::delete_user_remember_tokens(&self.db, user_id).await?;
573 Ok(ended)
574 }
575
576 pub async fn set_user_active(
590 &self,
591 actor_id: i64,
592 user_id: i64,
593 active: bool,
594 ) -> Result<(), AuthError> {
595 if !active {
596 if actor_id == user_id {
597 return Err(AuthError::Refused(
598 "You cannot deactivate your own account.".to_string(),
599 ));
600 }
601 let user = store::find_user_by_id(&self.db, user_id)
602 .await?
603 .ok_or(AuthError::SessionInvalid)?;
604 if user.is_superuser && store::other_active_superusers(&self.db, user_id).await? == 0 {
605 return Err(AuthError::Refused(
606 "This is the last active superuser; the panel would have no \
607 administrator left."
608 .to_string(),
609 ));
610 }
611 }
612
613 if store::set_user_active(&self.db, user_id, active).await? == 0 {
614 return Err(AuthError::SessionInvalid);
615 }
616 if !active {
617 store::delete_all_user_sessions(&self.db, user_id).await?;
618 store::delete_user_remember_tokens(&self.db, user_id).await?;
619 }
620 Ok(())
621 }
622
623 pub async fn logout(&self, token: &str) -> Result<(), AuthError> {
625 store::delete_session(&self.db, &hash_token(token)).await
626 }
627
628 pub async fn set_user_timezone(
632 &self,
633 user_id: i64,
634 timezone: Option<&str>,
635 ) -> Result<(), AuthError> {
636 store::set_user_timezone(&self.db, user_id, timezone).await
637 }
638
639 pub async fn set_user_locale(
643 &self,
644 user_id: i64,
645 locale: Option<&str>,
646 ) -> Result<(), AuthError> {
647 store::set_user_locale(&self.db, user_id, locale).await
648 }
649
650 pub async fn user_permission_overrides(
652 &self,
653 user_id: i64,
654 ) -> Result<std::collections::HashMap<String, i64>, AuthError> {
655 store::load_user_permission_overrides(&self.db, user_id).await
656 }
657
658 pub async fn set_user_permissions(
661 &self,
662 user_id: i64,
663 overrides: &std::collections::HashMap<String, i64>,
664 ) -> Result<(), AuthError> {
665 store::set_user_permissions(&self.db, user_id, overrides).await
666 }
667
668 pub async fn record_audit(&self, entry: AuditEntry<'_>) -> Result<(), AuthError> {
672 store::insert_audit_log(
673 &self.db,
674 entry.actor_user_id,
675 entry.actor_username,
676 entry.action,
677 entry.target_type,
678 entry.target_id,
679 entry.detail,
680 )
681 .await
682 }
683
684 pub async fn recent_audit(&self, limit: u64) -> Result<Vec<store::AuditRecord>, AuthError> {
686 store::recent_audit(&self.db, limit).await
687 }
688
689 pub async fn has_any_operator(&self) -> Result<bool, AuthError> {
692 store::any_user_exists(&self.db).await
693 }
694
695 pub async fn create_superuser(&self, new: NewOperator<'_>) -> Result<i64, AuthError> {
699 let hash = password::hash_password(new.password)?;
700 let id = store::create_user(
701 &self.db,
702 new.username,
703 new.email,
704 new.first_name,
705 new.last_name,
706 &hash,
707 true,
708 )
709 .await?;
710 if new.timezone.is_some() {
711 store::set_user_timezone(&self.db, id, new.timezone).await?;
712 }
713 Ok(id)
714 }
715
716 async fn log(
717 &self,
718 user_id: Option<i64>,
719 username: &str,
720 event: AccessEvent,
721 ctx: &RequestContext,
722 ) -> Result<(), AuthError> {
723 store::insert_access_log(
724 &self.db,
725 user_id,
726 username,
727 event,
728 ctx.ip_address.as_deref(),
729 ctx.user_agent.as_deref(),
730 )
731 .await
732 }
733}
734
735fn chrono_from_std(d: Duration) -> chrono::Duration {
738 chrono::Duration::from_std(d).expect("auth policy duration out of range")
739}
740
741fn generate_token() -> String {
742 let mut bytes = [0u8; 32];
743 rand::rngs::OsRng.fill_bytes(&mut bytes);
744 to_hex(&bytes)
745}
746
747fn public_session_id(token_hash: &str) -> String {
751 let mut hasher = Sha256::new();
752 hasher.update(b"laterite:session-id:");
753 hasher.update(token_hash.as_bytes());
754 to_hex(&hasher.finalize())
755}
756
757fn constant_time_eq(a: &str, b: &str) -> bool {
759 let (a, b) = (a.as_bytes(), b.as_bytes());
760 if a.len() != b.len() {
761 return false;
762 }
763 a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
764}
765
766fn hash_token(token: &str) -> String {
767 let mut hasher = Sha256::new();
768 hasher.update(token.as_bytes());
769 to_hex(&hasher.finalize())
770}
771
772fn to_hex(bytes: &[u8]) -> String {
773 let mut out = String::with_capacity(bytes.len() * 2);
774 for byte in bytes {
775 let _ = write!(out, "{byte:02x}");
776 }
777 out
778}
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783
784 async fn seed_user(db: &Db, username: &str, password: &str, superuser: bool) -> i64 {
785 let hash = password::hash_password(password).unwrap();
786 store::create_user(
787 db,
788 username,
789 &format!("{username}@example.test"),
790 "Test",
791 Some("Operator"),
792 &hash,
793 superuser,
794 )
795 .await
796 .unwrap()
797 }
798
799 fn service(db: Db) -> AuthService {
800 AuthService::new(db, AuthConfig::default())
801 }
802
803 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
807 laterite_core::testing::connect_test(&[crate::migrations()]).await
808 }
809
810 async fn set_last_seen(db: &Db, token: &str, seen: DateTime<Utc>) {
812 store::renew_session(
813 db,
814 &hash_token(token),
815 seen,
816 seen + chrono::Duration::hours(2),
817 )
818 .await
819 .unwrap();
820 }
821
822 async fn last_seen(db: &Db, token: &str) -> DateTime<Utc> {
823 store::find_valid_session(db, &hash_token(token), Utc::now())
824 .await
825 .unwrap()
826 .expect("session still valid")
827 .last_seen_at
828 }
829
830 #[tokio::test]
831 async fn a_request_past_the_halfway_mark_renews_the_session() {
832 let (pool, _guard) = test_db().await;
833 seed_user(&pool, "root", "hunter2", true).await;
834 let svc = service(pool.clone());
835
836 let issued = svc
837 .authenticate("root", "hunter2", &RequestContext::default())
838 .await
839 .unwrap();
840 let stale = Utc::now() - chrono::Duration::minutes(90);
843 set_last_seen(&pool, &issued.token, stale).await;
844
845 let resolved = svc.resolve_session(&issued.token).await.unwrap();
846
847 assert!(
848 last_seen(&pool, &issued.token).await > stale,
849 "a request past the halfway mark should push the idle clock"
850 );
851 assert!(
852 resolved.expires_at > Utc::now() + chrono::Duration::minutes(115),
853 "renewal should restore very nearly the full idle window"
854 );
855 }
856
857 #[tokio::test]
858 async fn verify_session_renews_like_the_path_it_wraps() {
859 let (pool, _guard) = test_db().await;
860 seed_user(&pool, "root", "hunter2", true).await;
861 let svc = service(pool.clone());
862
863 let issued = svc
864 .authenticate("root", "hunter2", &RequestContext::default())
865 .await
866 .unwrap();
867 let stale = Utc::now() - chrono::Duration::minutes(90);
868 set_last_seen(&pool, &issued.token, stale).await;
869
870 svc.verify_session(&issued.token).await.unwrap();
871
872 assert!(last_seen(&pool, &issued.token).await > stale);
876 }
877
878 #[tokio::test]
879 async fn a_request_before_the_halfway_mark_writes_nothing() {
880 let (pool, _guard) = test_db().await;
881 seed_user(&pool, "root", "hunter2", true).await;
882 let svc = service(pool.clone());
883
884 let issued = svc
885 .authenticate("root", "hunter2", &RequestContext::default())
886 .await
887 .unwrap();
888 let recent = Utc::now() - chrono::Duration::minutes(10);
891 set_last_seen(&pool, &issued.token, recent).await;
892
893 svc.resolve_session(&issued.token).await.unwrap();
894
895 assert_eq!(
896 last_seen(&pool, &issued.token).await.timestamp(),
897 recent.timestamp(),
898 "a request inside the halfway mark should leave the row alone"
899 );
900 }
901
902 #[tokio::test]
903 async fn a_session_past_its_idle_window_no_longer_resolves() {
904 let (pool, _guard) = test_db().await;
905 seed_user(&pool, "root", "hunter2", true).await;
906 let svc = service(pool.clone());
907
908 let issued = svc
909 .authenticate("root", "hunter2", &RequestContext::default())
910 .await
911 .unwrap();
912 let long_ago = Utc::now() - chrono::Duration::hours(3);
915 set_last_seen(&pool, &issued.token, long_ago).await;
916
917 assert!(matches!(
918 svc.resolve_session(&issued.token).await,
919 Err(AuthError::SessionInvalid)
920 ));
921 }
922
923 #[tokio::test]
924 async fn a_remember_cookie_is_single_use_and_rotates() {
925 let (pool, _guard) = test_db().await;
926 let uid = seed_user(&pool, "root", "hunter2", true).await;
927 let svc = service(pool);
928
929 let first = svc.issue_remember(uid).await.unwrap();
930 let recalled = svc
931 .consume_remember(&first.cookie, &RequestContext::default())
932 .await
933 .unwrap();
934
935 assert_ne!(
936 recalled.remember.cookie, first.cookie,
937 "using a credential must replace its secret"
938 );
939 assert_eq!(
940 recalled.remember.cookie.split_once(':').unwrap().0,
941 first.cookie.split_once(':').unwrap().0,
942 "the selector is the series and must survive rotation"
943 );
944 svc.verify_session(&recalled.session.token).await.unwrap();
946 assert!(matches!(
949 svc.consume_remember(&first.cookie, &RequestContext::default())
950 .await,
951 Err(AuthError::SessionInvalid)
952 ));
953 }
954
955 #[tokio::test]
956 async fn a_stale_secret_drops_every_credential_the_user_holds() {
957 let (pool, _guard) = test_db().await;
958 let uid = seed_user(&pool, "root", "hunter2", true).await;
959 let svc = service(pool);
960
961 let stolen = svc.issue_remember(uid).await.unwrap();
962 let other_device = svc.issue_remember(uid).await.unwrap();
963 let owner = svc
965 .consume_remember(&stolen.cookie, &RequestContext::default())
966 .await
967 .unwrap();
968
969 let selector = stolen.cookie.split_once(':').unwrap().0;
972 let forged = format!("{selector}:{}", generate_token());
973 assert!(matches!(
974 svc.consume_remember(&forged, &RequestContext::default())
975 .await,
976 Err(AuthError::SessionInvalid)
977 ));
978
979 for credential in [owner.remember.cookie, other_device.cookie] {
982 assert!(matches!(
983 svc.consume_remember(&credential, &RequestContext::default())
984 .await,
985 Err(AuthError::SessionInvalid)
986 ));
987 }
988 }
989
990 #[tokio::test]
991 async fn the_sessions_list_marks_the_one_asking() {
992 let (pool, _guard) = test_db().await;
993 seed_user(&pool, "root", "hunter2", true).await;
994 let svc = service(pool);
995
996 let first = svc
997 .authenticate("root", "hunter2", &RequestContext::default())
998 .await
999 .unwrap();
1000 let second = svc
1001 .authenticate("root", "hunter2", &RequestContext::default())
1002 .await
1003 .unwrap();
1004
1005 let listed = svc
1006 .list_sessions(first.user_id, &second.token)
1007 .await
1008 .unwrap();
1009 assert_eq!(listed.len(), 2);
1010 assert_eq!(listed.iter().filter(|s| s.current).count(), 1);
1011 assert!(listed.iter().all(|s| !s.id.is_empty()));
1013 }
1014
1015 #[tokio::test]
1016 async fn one_account_cannot_revoke_anothers_session() {
1017 let (pool, _guard) = test_db().await;
1018 seed_user(&pool, "root", "hunter2", true).await;
1019 seed_user(&pool, "mallory", "hunter2", false).await;
1020 let svc = service(pool);
1021
1022 let victim = svc
1023 .authenticate("root", "hunter2", &RequestContext::default())
1024 .await
1025 .unwrap();
1026 let attacker = svc
1027 .authenticate("mallory", "hunter2", &RequestContext::default())
1028 .await
1029 .unwrap();
1030
1031 let victims_id = svc
1033 .list_sessions(victim.user_id, &victim.token)
1034 .await
1035 .unwrap()
1036 .remove(0)
1037 .id;
1038
1039 assert!(
1040 !svc.revoke_session(attacker.user_id, &victims_id)
1041 .await
1042 .unwrap(),
1043 "a revoke is scoped to its own account"
1044 );
1045 svc.verify_session(&victim.token).await.unwrap();
1047 }
1048
1049 #[tokio::test]
1050 async fn signing_out_everywhere_keeps_this_one_and_drops_credentials() {
1051 let (pool, _guard) = test_db().await;
1052 let uid = seed_user(&pool, "root", "hunter2", true).await;
1053 let svc = service(pool);
1054
1055 let keep = svc
1056 .authenticate("root", "hunter2", &RequestContext::default())
1057 .await
1058 .unwrap();
1059 let other = svc
1060 .authenticate("root", "hunter2", &RequestContext::default())
1061 .await
1062 .unwrap();
1063 let credential = svc.issue_remember(uid).await.unwrap();
1064
1065 let ended = svc.sign_out_everywhere(uid, &keep.token).await.unwrap();
1066
1067 assert_eq!(ended, 1);
1068 svc.verify_session(&keep.token).await.unwrap();
1069 assert!(matches!(
1070 svc.verify_session(&other.token).await,
1071 Err(AuthError::SessionInvalid)
1072 ));
1073 assert!(matches!(
1076 svc.consume_remember(&credential.cookie, &RequestContext::default())
1077 .await,
1078 Err(AuthError::SessionInvalid)
1079 ));
1080 }
1081
1082 #[tokio::test]
1083 async fn revoking_a_credential_ends_it() {
1084 let (pool, _guard) = test_db().await;
1085 let uid = seed_user(&pool, "root", "hunter2", true).await;
1086 let svc = service(pool);
1087
1088 let credential = svc.issue_remember(uid).await.unwrap();
1089 svc.revoke_remember(&credential.cookie).await.unwrap();
1090
1091 assert!(matches!(
1092 svc.consume_remember(&credential.cookie, &RequestContext::default())
1093 .await,
1094 Err(AuthError::SessionInvalid)
1095 ));
1096 }
1097
1098 #[tokio::test]
1099 async fn deactivating_blocks_sign_in_and_ends_the_sessions() {
1100 let (pool, _guard) = test_db().await;
1101 let admin = seed_user(&pool, "root", "hunter2", true).await;
1102 let target = seed_user(&pool, "editor", "hunter2", false).await;
1103 let svc = service(pool.clone());
1104
1105 let issued = svc
1106 .authenticate("editor", "hunter2", &RequestContext::default())
1107 .await
1108 .unwrap();
1109 let credential = svc.issue_remember(target).await.unwrap();
1110
1111 svc.set_user_active(admin, target, false).await.unwrap();
1112
1113 assert!(matches!(
1115 svc.authenticate("editor", "hunter2", &RequestContext::default())
1116 .await,
1117 Err(AuthError::InactiveAccount)
1118 ));
1119 assert!(matches!(
1122 svc.verify_session(&issued.token).await,
1123 Err(AuthError::SessionInvalid)
1124 ));
1125 assert!(matches!(
1126 svc.consume_remember(&credential.cookie, &RequestContext::default())
1127 .await,
1128 Err(AuthError::SessionInvalid)
1129 ));
1130 }
1131
1132 #[tokio::test]
1133 async fn reactivating_lets_them_back_in() {
1134 let (pool, _guard) = test_db().await;
1135 let admin = seed_user(&pool, "root", "hunter2", true).await;
1136 let target = seed_user(&pool, "editor", "hunter2", false).await;
1137 let svc = service(pool);
1138
1139 svc.set_user_active(admin, target, false).await.unwrap();
1140 svc.set_user_active(admin, target, true).await.unwrap();
1141
1142 svc.authenticate("editor", "hunter2", &RequestContext::default())
1144 .await
1145 .unwrap();
1146 }
1147
1148 #[tokio::test]
1149 async fn an_operator_cannot_deactivate_themselves() {
1150 let (pool, _guard) = test_db().await;
1151 let admin = seed_user(&pool, "root", "hunter2", true).await;
1152 let svc = service(pool);
1153
1154 assert!(matches!(
1157 svc.set_user_active(admin, admin, false).await,
1158 Err(AuthError::Refused(_))
1159 ));
1160 }
1161
1162 #[tokio::test]
1163 async fn the_last_superuser_cannot_be_deactivated() {
1164 let (pool, _guard) = test_db().await;
1165 let only = seed_user(&pool, "root", "hunter2", true).await;
1166 let editor = seed_user(&pool, "editor", "hunter2", false).await;
1167 let svc = service(pool.clone());
1168
1169 assert!(matches!(
1172 svc.set_user_active(editor, only, false).await,
1173 Err(AuthError::Refused(_))
1174 ));
1175
1176 let second = seed_user(&pool, "root2", "hunter2", true).await;
1178 svc.set_user_active(second, only, false).await.unwrap();
1179 }
1180
1181 #[tokio::test]
1182 async fn audit_entries_record_and_read_back_newest_first() {
1183 let (pool, _guard) = test_db().await;
1184 let actor = seed_user(&pool, "root", "hunter2", true).await;
1185 let svc = service(pool);
1186
1187 svc.record_audit(AuditEntry {
1188 actor_user_id: Some(actor),
1189 actor_username: "root",
1190 action: "backend.role.update",
1191 target_type: Some("backend_role"),
1192 target_id: Some("42"),
1193 detail: Some(r#"{"added":["a.b"]}"#),
1194 })
1195 .await
1196 .unwrap();
1197 svc.record_audit(AuditEntry {
1198 actor_user_id: Some(actor),
1199 actor_username: "root",
1200 action: "backend.plugin.disable",
1201 target_type: Some("plugin"),
1202 target_id: Some("acme.widgets"),
1203 detail: None,
1204 })
1205 .await
1206 .unwrap();
1207
1208 let entries = svc.recent_audit(10).await.unwrap();
1209 assert_eq!(entries.len(), 2);
1210 assert_eq!(entries[0].action, "backend.plugin.disable");
1212 assert_eq!(entries[0].target_id.as_deref(), Some("acme.widgets"));
1213 assert_eq!(entries[0].detail, None);
1214 assert_eq!(entries[1].action, "backend.role.update");
1215 assert_eq!(entries[1].actor_username, "root");
1216 assert_eq!(entries[1].actor_user_id, Some(actor));
1217 assert_eq!(entries[1].detail.as_deref(), Some(r#"{"added":["a.b"]}"#));
1218 }
1219
1220 #[tokio::test]
1221 async fn authenticate_issues_a_verifiable_session() {
1222 let (pool, _guard) = test_db().await;
1223 seed_user(&pool, "root", "hunter2", true).await;
1224 let svc = service(pool);
1225
1226 let session = svc
1227 .authenticate("root", "hunter2", &RequestContext::default())
1228 .await
1229 .expect("login should succeed");
1230 let identity = svc
1231 .verify_session(&session.token)
1232 .await
1233 .expect("session should resolve");
1234
1235 assert_eq!(identity.user.username, "root");
1236 assert_eq!(identity.user.full_name(), "Test Operator");
1237 assert!(identity.allows("anything.superuser.can.do"));
1238 }
1239
1240 #[tokio::test]
1241 async fn session_data_blob_round_trips() {
1242 let (pool, _guard) = test_db().await;
1243 seed_user(&pool, "root", "hunter2", true).await;
1244 let svc = service(pool);
1245
1246 let session = svc
1247 .authenticate("root", "hunter2", &RequestContext::default())
1248 .await
1249 .unwrap();
1250
1251 let resolved = svc.resolve_session(&session.token).await.unwrap();
1253 assert_eq!(resolved.data, None);
1254 assert_eq!(resolved.identity.user.username, "root");
1255
1256 svc.set_session_data(&session.token, r#"{"v":1,"csrf":"abc"}"#)
1258 .await
1259 .unwrap();
1260 let resolved = svc.resolve_session(&session.token).await.unwrap();
1261 assert_eq!(resolved.data.as_deref(), Some(r#"{"v":1,"csrf":"abc"}"#));
1262 }
1263
1264 #[tokio::test]
1265 async fn username_is_case_insensitive_across_backends() {
1266 let (pool, _guard) = test_db().await;
1270 let hash = password::hash_password("pw").unwrap();
1271 store::create_user(
1272 &pool,
1273 "Root",
1274 "Root@Example.test",
1275 "Case",
1276 None,
1277 &hash,
1278 true,
1279 )
1280 .await
1281 .unwrap();
1282 let svc = service(pool);
1283 let session = svc
1284 .authenticate("ROOT", "pw", &RequestContext::default())
1285 .await
1286 .expect("case-varied login should resolve to the same account");
1287 let identity = svc.verify_session(&session.token).await.unwrap();
1288 assert_eq!(identity.user.username, "root");
1289 assert_eq!(identity.user.email, "root@example.test");
1290 }
1291
1292 #[tokio::test]
1293 async fn full_name_falls_back_to_first_name_when_last_is_absent() {
1294 let (pool, _guard) = test_db().await;
1295 let hash = password::hash_password("pw").unwrap();
1296 store::create_user(
1297 &pool,
1298 "mono",
1299 "mono@example.test",
1300 "Prakash",
1301 None,
1302 &hash,
1303 true,
1304 )
1305 .await
1306 .unwrap();
1307 let svc = service(pool);
1308 let session = svc
1309 .authenticate("mono", "pw", &RequestContext::default())
1310 .await
1311 .unwrap();
1312 let identity = svc.verify_session(&session.token).await.unwrap();
1313 assert_eq!(identity.user.full_name(), "Prakash");
1314 }
1315
1316 #[tokio::test]
1317 async fn operator_timezone_round_trips_and_clears() {
1318 let (pool, _guard) = test_db().await;
1319 let id = seed_user(&pool, "tz", "pw", true).await;
1320
1321 let user = store::find_active_user_by_id(&pool, id)
1323 .await
1324 .unwrap()
1325 .unwrap();
1326 assert_eq!(user.timezone, None);
1327
1328 let svc = service(pool.clone());
1329 svc.set_user_timezone(id, Some("Asia/Kolkata"))
1330 .await
1331 .unwrap();
1332 let user = store::find_active_user_by_id(&pool, id)
1333 .await
1334 .unwrap()
1335 .unwrap();
1336 assert_eq!(user.timezone.as_deref(), Some("Asia/Kolkata"));
1337
1338 svc.set_user_timezone(id, None).await.unwrap();
1340 let user = store::find_active_user_by_id(&pool, id)
1341 .await
1342 .unwrap()
1343 .unwrap();
1344 assert_eq!(user.timezone, None);
1345 }
1346
1347 #[tokio::test]
1348 async fn operator_locale_round_trips_and_clears() {
1349 let (pool, _guard) = test_db().await;
1350 let id = seed_user(&pool, "loc", "pw", true).await;
1351
1352 let user = store::find_active_user_by_id(&pool, id)
1354 .await
1355 .unwrap()
1356 .unwrap();
1357 assert_eq!(user.locale, None);
1358
1359 let svc = service(pool.clone());
1360 svc.set_user_locale(id, Some("kn")).await.unwrap();
1361 let user = store::find_active_user_by_id(&pool, id)
1362 .await
1363 .unwrap()
1364 .unwrap();
1365 assert_eq!(user.locale.as_deref(), Some("kn"));
1366
1367 svc.set_user_locale(id, None).await.unwrap();
1369 let user = store::find_active_user_by_id(&pool, id)
1370 .await
1371 .unwrap()
1372 .unwrap();
1373 assert_eq!(user.locale, None);
1374 }
1375
1376 #[tokio::test]
1377 async fn has_any_operator_flips_after_the_first_account() {
1378 let (pool, _guard) = test_db().await;
1379 let svc = service(pool);
1380
1381 assert!(!svc.has_any_operator().await.unwrap());
1383
1384 svc.create_superuser(NewOperator {
1385 username: "first",
1386 email: "first@example.test",
1387 first_name: "First",
1388 last_name: None,
1389 password: "hunter2",
1390 timezone: Some("Asia/Kolkata"),
1391 })
1392 .await
1393 .unwrap();
1394
1395 assert!(svc.has_any_operator().await.unwrap());
1396
1397 let session = svc
1399 .authenticate("first", "hunter2", &RequestContext::default())
1400 .await
1401 .unwrap();
1402 let identity = svc.verify_session(&session.token).await.unwrap();
1403 assert!(identity.allows("anything.a.superuser.can.do"));
1404 assert_eq!(identity.user.timezone.as_deref(), Some("Asia/Kolkata"));
1405 }
1406
1407 #[tokio::test]
1408 async fn wrong_password_is_rejected() {
1409 let (pool, _guard) = test_db().await;
1410 seed_user(&pool, "root", "hunter2", false).await;
1411 let svc = service(pool);
1412
1413 let err = svc
1414 .authenticate("root", "wrong", &RequestContext::default())
1415 .await
1416 .unwrap_err();
1417 assert!(matches!(err, AuthError::InvalidCredentials));
1418 }
1419
1420 #[tokio::test]
1421 async fn unknown_user_is_rejected_without_distinction() {
1422 let (pool, _guard) = test_db().await;
1423 let svc = service(pool);
1424 let err = svc
1425 .authenticate("ghost", "whatever", &RequestContext::default())
1426 .await
1427 .unwrap_err();
1428 assert!(matches!(err, AuthError::InvalidCredentials));
1429 }
1430
1431 #[tokio::test]
1432 async fn lockout_trips_after_max_failures() {
1433 let (pool, _guard) = test_db().await;
1434 seed_user(&pool, "root", "hunter2", false).await;
1435 let svc = AuthService::new(
1436 pool,
1437 AuthConfig {
1438 max_failures: 3,
1439 ..AuthConfig::default()
1440 },
1441 );
1442 let ctx = RequestContext::default();
1443
1444 for _ in 0..3 {
1445 let err = svc.authenticate("root", "bad", &ctx).await.unwrap_err();
1446 assert!(matches!(err, AuthError::InvalidCredentials));
1447 }
1448 let err = svc.authenticate("root", "hunter2", &ctx).await.unwrap_err();
1450 assert!(matches!(err, AuthError::TooManyAttempts));
1451 }
1452
1453 #[tokio::test]
1454 async fn permissions_come_from_assigned_roles() {
1455 let (pool, _guard) = test_db().await;
1456 let user_id = seed_user(&pool, "mod", "pw", false).await;
1457 let role_id = store::create_role(
1458 &pool,
1459 "content_editor",
1460 "Content Editor",
1461 &["posts.*".to_string()],
1462 )
1463 .await
1464 .unwrap();
1465 store::assign_role(&pool, user_id, role_id).await.unwrap();
1466
1467 let svc = service(pool);
1468 let session = svc
1469 .authenticate("mod", "pw", &RequestContext::default())
1470 .await
1471 .unwrap();
1472 let identity = svc.verify_session(&session.token).await.unwrap();
1473
1474 assert!(identity.allows("posts.approve"));
1475 assert!(!identity.allows("users.edit"));
1476 identity.require("posts.edit").unwrap();
1477 assert!(identity.require("users.edit").is_err());
1478 }
1479
1480 #[tokio::test]
1481 async fn logout_invalidates_the_session() {
1482 let (pool, _guard) = test_db().await;
1483 seed_user(&pool, "root", "pw", true).await;
1484 let svc = service(pool);
1485
1486 let session = svc
1487 .authenticate("root", "pw", &RequestContext::default())
1488 .await
1489 .unwrap();
1490 svc.verify_session(&session.token).await.unwrap();
1491 svc.logout(&session.token).await.unwrap();
1492
1493 let err = svc.verify_session(&session.token).await.unwrap_err();
1494 assert!(matches!(err, AuthError::SessionInvalid));
1495 }
1496
1497 #[tokio::test]
1498 async fn inactive_account_is_refused_after_correct_password() {
1499 let (pool, _guard) = test_db().await;
1500 let user_id = seed_user(&pool, "root", "pw", false).await;
1501 let (sql, values) = laterite_core::query::build(
1503 pool.backend,
1504 sea_query::Query::update()
1505 .table(crate::schema::BackendUsers::Table)
1506 .value(crate::schema::BackendUsers::IsActive, false)
1507 .and_where(sea_query::Expr::col(crate::schema::BackendUsers::Id).eq(user_id))
1508 .to_owned(),
1509 );
1510 laterite_core::query::bind_values(sqlx::query(&sql), values)
1511 .execute(&pool.pool)
1512 .await
1513 .unwrap();
1514
1515 let svc = service(pool);
1516 let err = svc
1517 .authenticate("root", "pw", &RequestContext::default())
1518 .await
1519 .unwrap_err();
1520 assert!(matches!(err, AuthError::InactiveAccount));
1521 }
1522
1523 #[tokio::test]
1524 async fn reset_password_updates_the_hash() {
1525 let (pool, _guard) = test_db().await;
1526 seed_user(&pool, "root", "oldpw", true).await;
1527
1528 let new_hash = password::hash_password("newpw").unwrap();
1529 let affected = store::update_password_by_username(&pool, "root", &new_hash)
1530 .await
1531 .unwrap();
1532 assert_eq!(affected, 1);
1533
1534 let svc = service(pool);
1535 let ctx = RequestContext::default();
1536 assert!(matches!(
1537 svc.authenticate("root", "oldpw", &ctx).await.unwrap_err(),
1538 AuthError::InvalidCredentials
1539 ));
1540 svc.authenticate("root", "newpw", &ctx).await.unwrap();
1541 }
1542
1543 #[tokio::test]
1544 async fn reset_password_reports_unknown_user() {
1545 let (pool, _guard) = test_db().await;
1546 let hash = password::hash_password("x").unwrap();
1547 let affected = store::update_password_by_username(&pool, "ghost", &hash)
1548 .await
1549 .unwrap();
1550 assert_eq!(affected, 0);
1551 }
1552
1553 #[tokio::test]
1554 async fn list_users_returns_all_seeded() {
1555 let (pool, _guard) = test_db().await;
1556 seed_user(&pool, "alice", "pw", true).await;
1557 seed_user(&pool, "bob", "pw", false).await;
1558
1559 let users = store::list_backend_users(&pool).await.unwrap();
1560 assert_eq!(users.len(), 2);
1561 assert!(users
1562 .iter()
1563 .any(|u| u.username == "alice" && u.is_superuser));
1564 assert!(users.iter().any(|u| u.username == "bob" && !u.is_superuser));
1565 }
1566
1567 #[tokio::test]
1568 async fn unlock_clears_the_lockout() {
1569 let (pool, _guard) = test_db().await;
1570 seed_user(&pool, "root", "pw", false).await;
1571 let svc = AuthService::new(
1572 pool.clone(),
1573 AuthConfig {
1574 max_failures: 3,
1575 ..AuthConfig::default()
1576 },
1577 );
1578 let ctx = RequestContext::default();
1579
1580 for _ in 0..3 {
1581 let _ = svc.authenticate("root", "bad", &ctx).await;
1582 }
1583 assert!(matches!(
1584 svc.authenticate("root", "pw", &ctx).await.unwrap_err(),
1585 AuthError::TooManyAttempts
1586 ));
1587
1588 let cleared = store::clear_failed_attempts(&pool, "root").await.unwrap();
1589 assert!(cleared >= 3);
1590 svc.authenticate("root", "pw", &ctx).await.unwrap();
1591 }
1592}