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::{Actor, Db, Events};
16
17use crate::error::AuthError;
18use crate::events;
19use crate::models::{AccessEvent, BackendUser};
20use crate::password;
21use crate::permission::PermissionSet;
22use crate::store;
23
24#[derive(Debug, Clone, Deserialize)]
40#[serde(default)]
41#[non_exhaustive]
42pub struct AuthConfig {
43 #[serde(rename = "session_idle_timeout_secs", deserialize_with = "de_secs")]
46 pub session_idle_timeout: Duration,
47 #[serde(
51 rename = "session_absolute_timeout_secs",
52 alias = "session_ttl_secs",
53 deserialize_with = "de_secs"
54 )]
55 pub session_absolute_timeout: Duration,
56 #[serde(rename = "remember_duration_secs", deserialize_with = "de_secs")]
59 pub remember_duration: Duration,
60 pub max_failures: i64,
64 pub max_failures_per_address: i64,
67 #[serde(rename = "failure_window_secs", deserialize_with = "de_secs")]
69 pub failure_window: Duration,
70 pub password_policy: PasswordPolicy,
72}
73
74impl Default for AuthConfig {
75 fn default() -> Self {
76 Self {
77 session_idle_timeout: Duration::from_secs(60 * 60 * 2),
78 session_absolute_timeout: Duration::from_secs(60 * 60 * 12),
79 remember_duration: Duration::from_secs(60 * 60 * 24 * 14),
80 max_failures: 5,
81 max_failures_per_address: 20,
82 failure_window: Duration::from_secs(60 * 15),
83 password_policy: PasswordPolicy::default(),
84 }
85 }
86}
87
88#[derive(Debug, Clone, Deserialize)]
92#[serde(default)]
93#[non_exhaustive]
94pub struct PasswordPolicy {
95 pub min_length: usize,
97}
98
99impl Default for PasswordPolicy {
100 fn default() -> Self {
101 Self {
102 min_length: MIN_PASSWORD_LENGTH,
103 }
104 }
105}
106
107impl PasswordPolicy {
108 pub fn check(&self, password: &str) -> Result<(), AuthError> {
111 if password.chars().count() < self.min_length {
112 return Err(AuthError::Refused(format!(
113 "A password needs at least {} characters.",
114 self.min_length
115 )));
116 }
117 Ok(())
118 }
119}
120
121impl AuthConfig {
122 fn deadline(&self, created_at: DateTime<Utc>, last_seen: DateTime<Utc>) -> DateTime<Utc> {
126 let idle = last_seen + chrono_from_std(self.session_idle_timeout);
127 let ceiling = created_at + chrono_from_std(self.session_absolute_timeout);
128 idle.min(ceiling)
129 }
130}
131
132fn de_secs<'de, D: serde::Deserializer<'de>>(de: D) -> Result<Duration, D::Error> {
134 Ok(Duration::from_secs(u64::deserialize(de)?))
135}
136
137#[cfg(test)]
138mod auth_config_tests {
139 use super::{AuthConfig, MIN_PASSWORD_LENGTH};
140 use chrono::Utc;
141 use std::time::Duration;
142
143 #[test]
144 fn unset_keys_keep_defaults() {
145 let cfg: AuthConfig = serde_json::from_str(r#"{"max_failures": 3}"#).unwrap();
146 assert_eq!(cfg.max_failures, 3);
147 assert_eq!(cfg.session_idle_timeout, Duration::from_secs(60 * 60 * 2));
148 assert_eq!(
149 cfg.session_absolute_timeout,
150 Duration::from_secs(60 * 60 * 12)
151 );
152 assert_eq!(cfg.failure_window, Duration::from_secs(60 * 15));
153 }
154
155 #[test]
156 fn seconds_map_to_durations() {
157 let cfg: AuthConfig = serde_json::from_str(
158 r#"{"session_idle_timeout_secs": 1800, "session_absolute_timeout_secs": 3600,
159 "max_failures": 7, "failure_window_secs": 120}"#,
160 )
161 .unwrap();
162 assert_eq!(cfg.session_idle_timeout, Duration::from_secs(1800));
163 assert_eq!(cfg.session_absolute_timeout, Duration::from_secs(3600));
164 assert_eq!(cfg.max_failures, 7);
165 assert_eq!(cfg.failure_window, Duration::from_secs(120));
166 }
167
168 #[test]
169 fn the_idle_window_ends_a_quiet_session_first() {
170 let cfg = AuthConfig::default();
171 let login = Utc::now();
172 let seen = login + chrono::Duration::hours(1);
175 assert_eq!(cfg.deadline(login, seen), seen + chrono::Duration::hours(2));
176 }
177
178 #[test]
179 fn the_ceiling_caps_a_session_kept_busy() {
180 let cfg = AuthConfig::default();
181 let login = Utc::now();
182 let seen = login + chrono::Duration::hours(11);
185 assert_eq!(
186 cfg.deadline(login, seen),
187 login + chrono::Duration::hours(12)
188 );
189 }
190
191 #[test]
192 fn the_password_policy_reads_from_its_own_table() {
193 let cfg: AuthConfig =
194 serde_json::from_str(r#"{"password_policy": {"min_length": 12}}"#).unwrap();
195 assert_eq!(cfg.password_policy.min_length, 12);
196 assert_eq!(
197 AuthConfig::default().password_policy.min_length,
198 MIN_PASSWORD_LENGTH
199 );
200 assert!(cfg.password_policy.check("eleven-char").is_err());
201 assert!(cfg.password_policy.check("twelve-chars").is_ok());
202 }
203
204 #[test]
205 fn the_former_ttl_key_still_sets_the_ceiling() {
206 let cfg: AuthConfig = serde_json::from_str(r#"{"session_ttl_secs": 3600}"#).unwrap();
207 assert_eq!(cfg.session_absolute_timeout, Duration::from_secs(3600));
208 }
209}
210
211#[derive(Debug, Clone, Default)]
213pub struct RequestContext {
214 pub ip_address: Option<String>,
215 pub user_agent: Option<String>,
216}
217
218#[derive(Debug, Clone)]
221pub struct IssuedSession {
222 pub token: String,
223 pub expires_at: DateTime<Utc>,
224 pub user_id: i64,
227}
228
229#[derive(Debug, Clone)]
232pub struct RememberCredential {
233 pub cookie: String,
234 pub expires_at: DateTime<Utc>,
235}
236
237#[derive(Debug, Clone)]
240pub struct RecalledSession {
241 pub session: IssuedSession,
242 pub remember: RememberCredential,
243}
244
245#[derive(Debug, Clone)]
249pub struct ActiveSession {
250 pub id: String,
251 pub created_at: DateTime<Utc>,
252 pub last_seen_at: DateTime<Utc>,
253 pub expires_at: DateTime<Utc>,
254 pub ip_address: Option<String>,
256 pub user_agent: Option<String>,
258 pub current: bool,
260}
261
262#[derive(Debug, Clone)]
264pub struct AuthenticatedUser {
265 pub user: BackendUser,
266 pub permissions: PermissionSet,
267}
268
269impl From<&AuthenticatedUser> for laterite_core::Actor {
272 fn from(user: &AuthenticatedUser) -> Self {
273 laterite_core::Actor::user(user.user.id, &user.user.username)
274 }
275}
276
277#[derive(Debug, Clone)]
281pub struct ResolvedSession {
282 pub identity: AuthenticatedUser,
283 pub data: Option<String>,
284 pub expires_at: DateTime<Utc>,
287}
288
289impl AuthenticatedUser {
290 pub fn allows(&self, permission: &str) -> bool {
292 self.permissions.allows(permission)
293 }
294
295 pub fn require(&self, permission: &str) -> Result<(), AuthError> {
297 if self.allows(permission) {
298 Ok(())
299 } else {
300 Err(AuthError::PermissionDenied(permission.to_string()))
301 }
302 }
303}
304
305#[derive(Debug, Clone)]
308pub struct NewOperator<'a> {
309 pub username: &'a str,
310 pub email: &'a str,
311 pub first_name: &'a str,
312 pub last_name: Option<&'a str>,
313 pub password: &'a str,
314 pub timezone: Option<&'a str>,
317}
318
319#[non_exhaustive]
323pub struct AuditEntry<'a> {
324 pub actor_user_id: Option<i64>,
326 pub actor_username: &'a str,
328 pub action: &'a str,
330 pub target_type: Option<&'a str>,
332 pub target_id: Option<&'a str>,
333 pub target_label: Option<&'a str>,
336 pub detail: Option<&'a str>,
338}
339
340impl<'a> AuditEntry<'a> {
341 pub fn new(actor: &'a Actor, action: &'a str) -> Self {
343 Self {
344 actor_user_id: actor.user_id(),
345 actor_username: actor.label(),
346 action,
347 target_type: None,
348 target_id: None,
349 target_label: None,
350 detail: None,
351 }
352 }
353
354 pub fn target(mut self, kind: &'a str, id: &'a str, label: Option<&'a str>) -> Self {
356 self.target_type = Some(kind);
357 self.target_id = Some(id);
358 self.target_label = label;
359 self
360 }
361
362 pub fn detail(mut self, detail: &'a str) -> Self {
364 self.detail = Some(detail);
365 self
366 }
367}
368
369#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
371#[non_exhaustive]
372pub struct Purged {
373 pub sessions: u64,
374 pub remember_tokens: u64,
375}
376
377pub const MIN_PASSWORD_LENGTH: usize = 8;
379
380#[derive(Clone)]
383pub struct AuthService {
384 db: Db,
385 config: AuthConfig,
386 events: Events,
387}
388
389impl AuthService {
390 pub fn new(db: Db, config: AuthConfig) -> Self {
393 let events = Events::new(db.clone());
394 Self { db, config, events }
395 }
396
397 pub fn with_events(mut self, events: Events) -> Self {
400 self.events = events;
401 self
402 }
403
404 pub fn events(&self) -> &Events {
406 &self.events
407 }
408
409 pub fn session_absolute_timeout(&self) -> Duration {
412 self.config.session_absolute_timeout
413 }
414
415 pub async fn authenticate(
420 &self,
421 username: &str,
422 password: &str,
423 ctx: &RequestContext,
424 ) -> Result<IssuedSession, AuthError> {
425 let now = Utc::now();
426 let since = now - chrono_from_std(self.config.failure_window);
427
428 if self.locked_out(username, ctx, since).await? {
429 self.log(None, username, AccessEvent::LockedOut, ctx)
430 .await?;
431 return Err(AuthError::TooManyAttempts);
432 }
433
434 let user = match store::find_user_by_username(&self.db, username).await? {
435 Some(user) => user,
436 None => {
437 self.log(None, username, AccessEvent::LoginFailure, ctx)
438 .await?;
439 return Err(AuthError::InvalidCredentials);
440 }
441 };
442
443 if !password::verify_password(password, &user.password_hash)? {
444 self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
445 .await?;
446 return Err(AuthError::InvalidCredentials);
447 }
448
449 if !user.is_active {
450 self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
451 .await?;
452 return Err(AuthError::InactiveAccount);
453 }
454
455 let token = generate_token();
456 let expires_at = self.config.deadline(now, now);
457 store::insert_session(
458 &self.db,
459 &hash_token(&token),
460 user.id,
461 expires_at,
462 ctx.ip_address.as_deref(),
463 ctx.user_agent.as_deref(),
464 )
465 .await?;
466 self.log(Some(user.id), username, AccessEvent::LoginSuccess, ctx)
467 .await?;
468
469 Ok(IssuedSession {
470 token,
471 expires_at,
472 user_id: user.id,
473 })
474 }
475
476 pub async fn resolve_session(&self, token: &str) -> Result<ResolvedSession, AuthError> {
481 let token_hash = hash_token(token);
482 let now = Utc::now();
483
484 let session = store::find_valid_session(&self.db, &token_hash, now)
485 .await?
486 .ok_or(AuthError::SessionInvalid)?;
487 let user = store::find_active_user_by_id(&self.db, session.user_id)
488 .await?
489 .ok_or(AuthError::SessionInvalid)?;
490 let idle = chrono_from_std(self.config.session_idle_timeout);
494 let expires_at = self.config.deadline(session.created_at, now);
495 if now - session.last_seen_at > idle / 2 {
496 store::renew_session(&self.db, &token_hash, now, expires_at).await?;
497 }
498
499 let grants = store::load_role_permissions(&self.db, user.id)
500 .await?
501 .into_iter()
502 .flatten();
503 let overrides = store::load_user_permission_overrides(&self.db, user.id).await?;
506 let (mut allow, mut deny) = (Vec::new(), Vec::new());
507 for (code, decision) in overrides {
508 match decision.signum() {
509 1 => allow.push(code),
510 -1 => deny.push(code),
511 _ => {}
512 }
513 }
514 let permissions = PermissionSet::with_overrides(user.is_superuser, grants, allow, deny);
515
516 Ok(ResolvedSession {
517 identity: AuthenticatedUser { user, permissions },
518 data: session.data,
519 expires_at,
520 })
521 }
522
523 pub async fn verify_session(&self, token: &str) -> Result<AuthenticatedUser, AuthError> {
526 Ok(self.resolve_session(token).await?.identity)
527 }
528
529 pub async fn set_session_data(&self, token: &str, data: &str) -> Result<(), AuthError> {
533 store::set_session_data(&self.db, &hash_token(token), data).await
534 }
535
536 pub async fn issue_remember(&self, user_id: i64) -> Result<RememberCredential, AuthError> {
538 let (selector, verifier) = (generate_token(), generate_token());
539 let expires_at = Utc::now() + chrono_from_std(self.config.remember_duration);
540 store::insert_remember_token(
541 &self.db,
542 &selector,
543 &hash_token(&verifier),
544 user_id,
545 expires_at,
546 )
547 .await?;
548 Ok(RememberCredential {
549 cookie: format!("{selector}:{verifier}"),
550 expires_at,
551 })
552 }
553
554 pub async fn consume_remember(
562 &self,
563 cookie: &str,
564 ctx: &RequestContext,
565 ) -> Result<RecalledSession, AuthError> {
566 let (selector, verifier) = cookie.split_once(':').ok_or(AuthError::SessionInvalid)?;
567 let now = Utc::now();
568 let found = store::find_remember_token(&self.db, selector, now)
569 .await?
570 .ok_or(AuthError::SessionInvalid)?;
571
572 if !constant_time_eq(&hash_token(verifier), &found.verifier_hash) {
573 tracing::warn!(
574 user_id = found.user_id,
575 "a stay-signed-in cookie was presented with a stale secret; dropping every credential for this account"
576 );
577 store::delete_user_remember_tokens(&self.db, found.user_id).await?;
578 self.log(Some(found.user_id), "", AccessEvent::LoginFailure, ctx)
579 .await?;
580 return Err(AuthError::SessionInvalid);
581 }
582
583 let user = store::find_active_user_by_id(&self.db, found.user_id)
585 .await?
586 .ok_or(AuthError::SessionInvalid)?;
587
588 let replacement = generate_token();
592 let remember_expires = now + chrono_from_std(self.config.remember_duration);
593 store::rotate_remember_token(
594 &self.db,
595 selector,
596 &hash_token(&replacement),
597 remember_expires,
598 )
599 .await?;
600 let remember = RememberCredential {
601 cookie: format!("{selector}:{replacement}"),
602 expires_at: remember_expires,
603 };
604
605 let token = generate_token();
606 let expires_at = self.config.deadline(now, now);
607 store::insert_session(
608 &self.db,
609 &hash_token(&token),
610 user.id,
611 expires_at,
612 ctx.ip_address.as_deref(),
613 ctx.user_agent.as_deref(),
614 )
615 .await?;
616 self.log(
617 Some(user.id),
618 &user.username,
619 AccessEvent::LoginSuccess,
620 ctx,
621 )
622 .await?;
623
624 Ok(RecalledSession {
625 session: IssuedSession {
626 token,
627 expires_at,
628 user_id: user.id,
629 },
630 remember,
631 })
632 }
633
634 pub async fn revoke_remember(&self, cookie: &str) -> Result<(), AuthError> {
637 if let Some((selector, _)) = cookie.split_once(':') {
638 store::delete_remember_token(&self.db, selector).await?;
639 }
640 Ok(())
641 }
642
643 pub async fn list_sessions(
646 &self,
647 user_id: i64,
648 current_token: &str,
649 ) -> Result<Vec<ActiveSession>, AuthError> {
650 let current_hash = hash_token(current_token);
651 let rows = store::list_user_sessions(&self.db, user_id, Utc::now()).await?;
652 Ok(rows
653 .into_iter()
654 .map(|s| ActiveSession {
655 id: public_session_id(&s.token_hash),
656 created_at: s.created_at,
657 last_seen_at: s.last_seen_at,
658 expires_at: s.expires_at,
659 ip_address: s.ip_address,
660 user_agent: s.user_agent,
661 current: s.token_hash == current_hash,
662 })
663 .collect())
664 }
665
666 pub async fn revoke_session(&self, user_id: i64, id: &str) -> Result<bool, AuthError> {
670 let rows = store::list_user_sessions(&self.db, user_id, Utc::now()).await?;
671 let Some(target) = rows
672 .into_iter()
673 .find(|s| constant_time_eq(&public_session_id(&s.token_hash), id))
674 else {
675 return Ok(false);
676 };
677 let done = store::revoke_user_session(
678 &self.db,
679 user_id,
680 &target.token_hash,
681 store::RevokeReason::SignedOutElsewhere,
682 )
683 .await?;
684 Ok(done > 0)
685 }
686
687 pub async fn sign_out_everywhere(
692 &self,
693 user_id: i64,
694 keep_token: &str,
695 ) -> Result<u64, AuthError> {
696 let ended = store::revoke_user_sessions(
697 &self.db,
698 user_id,
699 Some(&hash_token(keep_token)),
700 store::RevokeReason::SignedOutElsewhere,
701 )
702 .await?;
703 store::revoke_user_remember_tokens(
704 &self.db,
705 user_id,
706 store::RevokeReason::SignedOutElsewhere,
707 )
708 .await?;
709 Ok(ended)
710 }
711
712 pub async fn create_operator(
717 &self,
718 new: NewOperator<'_>,
719 actor: &Actor,
720 ) -> Result<i64, AuthError> {
721 self.config.password_policy.check(new.password)?;
722 let hash = password::hash_password(new.password)?;
723 let id = store::create_user(
724 &self.db,
725 new.username,
726 new.email,
727 new.first_name,
728 new.last_name,
729 &hash,
730 false,
731 )
732 .await?;
733 if new.timezone.is_some() {
734 store::set_user_timezone(&self.db, id, new.timezone).await?;
735 }
736 let target = id.to_string();
737 self.record_audit(AuditEntry::new(actor, "backend.user.create").target(
738 "backend_user",
739 &target,
740 Some(new.username),
741 ))
742 .await?;
743 Ok(id)
744 }
745
746 pub async fn reset_operator_password(
752 &self,
753 user_id: i64,
754 actor: &Actor,
755 ) -> Result<String, AuthError> {
756 let password = password::generate();
757 self.change_password(user_id, &password, None, actor)
758 .await?;
759 store::set_must_change_password(&self.db, user_id, true).await?;
760 Ok(password)
761 }
762
763 pub async fn is_locked_out(&self, username: &str) -> Result<bool, AuthError> {
766 let since = Utc::now() - chrono_from_std(self.config.failure_window);
767 store::any_address_locked(&self.db, username, since, self.config.max_failures).await
768 }
769
770 async fn locked_out(
774 &self,
775 username: &str,
776 ctx: &RequestContext,
777 since: DateTime<Utc>,
778 ) -> Result<bool, AuthError> {
779 let address = ctx.ip_address.as_deref();
780 if store::count_recent_failures_by_address(&self.db, address, since).await?
781 >= self.config.max_failures_per_address
782 {
783 return Ok(true);
784 }
785 Ok(
786 store::count_recent_failures_from(&self.db, username, address, since).await?
787 >= self.config.max_failures,
788 )
789 }
790
791 pub async fn unlock(&self, username: &str) -> Result<u64, AuthError> {
794 store::clear_failed_attempts(&self.db, username).await
795 }
796
797 pub async fn purge_expired(&self) -> Result<Purged, AuthError> {
801 let now = Utc::now();
802 Ok(Purged {
803 sessions: store::delete_expired_sessions(&self.db, now).await?,
804 remember_tokens: store::delete_expired_remember_tokens(&self.db, now).await?,
805 })
806 }
807
808 pub async fn require_password_change(&self, user_id: i64) -> Result<(), AuthError> {
813 if store::set_must_change_password(&self.db, user_id, true).await? == 0 {
814 return Err(AuthError::SessionInvalid);
815 }
816 Ok(())
817 }
818
819 pub async fn must_change_password(&self, user_id: i64) -> Result<bool, AuthError> {
821 store::must_change_password(&self.db, user_id).await
822 }
823
824 pub fn password_policy(&self) -> &PasswordPolicy {
826 &self.config.password_policy
827 }
828
829 pub async fn change_password(
844 &self,
845 user_id: i64,
846 new_password: &str,
847 keep_token: Option<&str>,
848 actor: &Actor,
849 ) -> Result<(), AuthError> {
850 self.config.password_policy.check(new_password)?;
851 let hash = password::hash_password(new_password)?;
852 let keep = keep_token.map(hash_token);
853 let done = store::change_password(
854 &self.db,
855 user_id,
856 &hash,
857 keep.as_deref(),
858 actor.user_id(),
859 actor.label(),
860 )
861 .await?;
862 if done == 0 {
863 return Err(AuthError::SessionInvalid);
864 }
865 self.events
866 .emit(&events::PasswordChanged::new(user_id, actor.user_id()))
867 .await;
868 Ok(())
869 }
870
871 pub async fn change_own_password(
878 &self,
879 user_id: i64,
880 current_password: &str,
881 new_password: &str,
882 keep_token: &str,
883 ctx: &RequestContext,
884 ) -> Result<(), AuthError> {
885 let user = store::find_user_by_id(&self.db, user_id)
886 .await?
887 .ok_or(AuthError::SessionInvalid)?;
888 let since = Utc::now() - chrono_from_std(self.config.failure_window);
889 if self.locked_out(&user.username, ctx, since).await? {
890 self.log(Some(user.id), &user.username, AccessEvent::LockedOut, ctx)
891 .await?;
892 return Err(AuthError::TooManyAttempts);
893 }
894 if !password::verify_password(current_password, &user.password_hash)? {
895 self.log(
896 Some(user.id),
897 &user.username,
898 AccessEvent::LoginFailure,
899 ctx,
900 )
901 .await?;
902 return Err(AuthError::InvalidCredentials);
903 }
904 self.change_password(
905 user.id,
906 new_password,
907 Some(keep_token),
908 &Actor::user(user.id, user.username.as_str()),
909 )
910 .await?;
911 self.log(
912 Some(user.id),
913 &user.username,
914 AccessEvent::PasswordChanged,
915 ctx,
916 )
917 .await
918 }
919
920 pub async fn recent_access(
923 &self,
924 user_id: i64,
925 limit: u64,
926 ) -> Result<Vec<store::AccessRecord>, AuthError> {
927 store::recent_access_log(&self.db, user_id, limit).await
928 }
929
930 pub async fn password_changed_at(
932 &self,
933 user_id: i64,
934 ) -> Result<Option<DateTime<Utc>>, AuthError> {
935 store::password_changed_at(&self.db, user_id).await
936 }
937
938 pub async fn reset_password(
942 &self,
943 username: &str,
944 new_password: &str,
945 actor: &Actor,
946 ) -> Result<bool, AuthError> {
947 let Some(user) = store::find_user_by_username(&self.db, username).await? else {
948 return Ok(false);
949 };
950 self.change_password(user.id, new_password, None, actor)
951 .await?;
952 Ok(true)
953 }
954
955 pub async fn remember_holder(&self, cookie: &str) -> Result<Option<i64>, AuthError> {
959 let Some((selector, verifier)) = cookie.split_once(':') else {
960 return Ok(None);
961 };
962 let Some(found) = store::find_remember_token(&self.db, selector, Utc::now()).await? else {
963 return Ok(None);
964 };
965 Ok(constant_time_eq(&hash_token(verifier), &found.verifier_hash).then_some(found.user_id))
966 }
967
968 pub async fn remember_end(
975 &self,
976 cookie: &str,
977 ) -> Result<Option<store::RevokeReason>, AuthError> {
978 let Some((selector, verifier)) = cookie.split_once(':') else {
979 return Ok(None);
980 };
981 let Some((verifier_hash, reason)) =
982 store::revoked_remember_token(&self.db, selector).await?
983 else {
984 return Ok(None);
985 };
986 if !constant_time_eq(&hash_token(verifier), &verifier_hash) {
987 return Ok(None);
988 }
989 store::delete_remember_token(&self.db, selector).await?;
990 Ok(Some(reason))
991 }
992
993 pub async fn session_end(&self, token: &str) -> Result<Option<store::SessionEnd>, AuthError> {
998 let token_hash = hash_token(token);
999 let end = store::session_end(&self.db, &token_hash, Utc::now()).await?;
1000 if matches!(end, Some(store::SessionEnd::Revoked(_))) {
1001 store::delete_session(&self.db, &token_hash).await?;
1002 }
1003 Ok(end)
1004 }
1005
1006 pub async fn set_user_active(
1020 &self,
1021 actor_id: i64,
1022 user_id: i64,
1023 active: bool,
1024 ) -> Result<(), AuthError> {
1025 if !active {
1026 if actor_id == user_id {
1027 return Err(AuthError::Refused(
1028 "You cannot deactivate your own account.".to_string(),
1029 ));
1030 }
1031 let user = store::find_user_by_id(&self.db, user_id)
1032 .await?
1033 .ok_or(AuthError::SessionInvalid)?;
1034 if user.is_superuser && store::other_active_superusers(&self.db, user_id).await? == 0 {
1035 return Err(AuthError::Refused(
1036 "This is the last active superuser; the panel would have no \
1037 administrator left."
1038 .to_string(),
1039 ));
1040 }
1041 }
1042
1043 if store::set_user_active(&self.db, user_id, active).await? == 0 {
1044 return Err(AuthError::SessionInvalid);
1045 }
1046 Ok(())
1047 }
1048
1049 pub async fn logout(&self, token: &str) -> Result<(), AuthError> {
1051 let hash = hash_token(token);
1052 let owner = store::find_valid_session(&self.db, &hash, Utc::now()).await?;
1055 store::delete_session(&self.db, &hash).await?;
1056 if let Some(session) = owner {
1057 self.events
1058 .emit(&events::SignedOut::new(session.user_id))
1059 .await;
1060 }
1061 Ok(())
1062 }
1063
1064 pub async fn set_user_timezone(
1068 &self,
1069 user_id: i64,
1070 timezone: Option<&str>,
1071 ) -> Result<(), AuthError> {
1072 store::set_user_timezone(&self.db, user_id, timezone).await
1073 }
1074
1075 pub async fn set_user_locale(
1079 &self,
1080 user_id: i64,
1081 locale: Option<&str>,
1082 ) -> Result<(), AuthError> {
1083 store::set_user_locale(&self.db, user_id, locale).await
1084 }
1085
1086 pub async fn user_permission_overrides(
1088 &self,
1089 user_id: i64,
1090 ) -> Result<std::collections::HashMap<String, i64>, AuthError> {
1091 store::load_user_permission_overrides(&self.db, user_id).await
1092 }
1093
1094 pub async fn set_user_permissions(
1097 &self,
1098 user_id: i64,
1099 overrides: &std::collections::HashMap<String, i64>,
1100 ) -> Result<(), AuthError> {
1101 store::set_user_permissions(&self.db, user_id, overrides).await
1102 }
1103
1104 pub async fn record_audit(&self, entry: AuditEntry<'_>) -> Result<(), AuthError> {
1108 store::insert_audit_log(
1109 &self.db,
1110 entry.actor_user_id,
1111 entry.actor_username,
1112 entry.action,
1113 entry.target_type,
1114 entry.target_id,
1115 entry.target_label,
1116 entry.detail,
1117 )
1118 .await
1119 }
1120
1121 pub async fn recent_audit(&self, limit: u64) -> Result<Vec<store::AuditRecord>, AuthError> {
1123 store::recent_audit(&self.db, limit).await
1124 }
1125
1126 pub async fn has_any_operator(&self) -> Result<bool, AuthError> {
1129 store::any_user_exists(&self.db).await
1130 }
1131
1132 pub async fn create_superuser(&self, new: NewOperator<'_>) -> Result<i64, AuthError> {
1136 self.config.password_policy.check(new.password)?;
1137 let hash = password::hash_password(new.password)?;
1138 let id = store::create_user(
1139 &self.db,
1140 new.username,
1141 new.email,
1142 new.first_name,
1143 new.last_name,
1144 &hash,
1145 true,
1146 )
1147 .await?;
1148 if new.timezone.is_some() {
1149 store::set_user_timezone(&self.db, id, new.timezone).await?;
1150 }
1151 if let Some(role_id) = store::role_id_by_code(&self.db, crate::ROLE_ADMIN).await? {
1155 store::assign_role(&self.db, id, role_id).await?;
1156 }
1157 Ok(id)
1158 }
1159
1160 async fn log(
1161 &self,
1162 user_id: Option<i64>,
1163 username: &str,
1164 event: AccessEvent,
1165 ctx: &RequestContext,
1166 ) -> Result<(), AuthError> {
1167 store::insert_access_log(
1168 &self.db,
1169 user_id,
1170 username,
1171 event,
1172 ctx.ip_address.as_deref(),
1173 ctx.user_agent.as_deref(),
1174 )
1175 .await?;
1176 match (event, user_id) {
1179 (AccessEvent::LoginSuccess, Some(id)) => {
1180 self.events
1181 .emit(&events::SignedIn::new(id, username, ctx))
1182 .await;
1183 }
1184 (AccessEvent::LoginFailure, _) => {
1185 self.events
1186 .emit(&events::SignInFailed::new(user_id, username, ctx))
1187 .await;
1188 }
1189 (AccessEvent::LockedOut, _) => {
1190 self.events
1191 .emit(&events::LockedOut::new(user_id, username, ctx))
1192 .await;
1193 }
1194 _ => {}
1197 }
1198 Ok(())
1199 }
1200}
1201
1202fn chrono_from_std(d: Duration) -> chrono::Duration {
1205 chrono::Duration::from_std(d).expect("auth policy duration out of range")
1206}
1207
1208fn generate_token() -> String {
1209 let mut bytes = [0u8; 32];
1210 rand::rngs::OsRng.fill_bytes(&mut bytes);
1211 to_hex(&bytes)
1212}
1213
1214fn public_session_id(token_hash: &str) -> String {
1218 let mut hasher = Sha256::new();
1219 hasher.update(b"laterite:session-id:");
1220 hasher.update(token_hash.as_bytes());
1221 to_hex(&hasher.finalize())
1222}
1223
1224fn constant_time_eq(a: &str, b: &str) -> bool {
1226 let (a, b) = (a.as_bytes(), b.as_bytes());
1227 if a.len() != b.len() {
1228 return false;
1229 }
1230 a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
1231}
1232
1233fn hash_token(token: &str) -> String {
1234 let mut hasher = Sha256::new();
1235 hasher.update(token.as_bytes());
1236 to_hex(&hasher.finalize())
1237}
1238
1239fn to_hex(bytes: &[u8]) -> String {
1240 let mut out = String::with_capacity(bytes.len() * 2);
1241 for byte in bytes {
1242 let _ = write!(out, "{byte:02x}");
1243 }
1244 out
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249 use super::*;
1250
1251 async fn seed_user(db: &Db, username: &str, password: &str, superuser: bool) -> i64 {
1252 let hash = password::hash_password(password).unwrap();
1253 store::create_user(
1254 db,
1255 username,
1256 &format!("{username}@example.test"),
1257 "Test",
1258 Some("Operator"),
1259 &hash,
1260 superuser,
1261 )
1262 .await
1263 .unwrap()
1264 }
1265
1266 fn service(db: Db) -> AuthService {
1267 AuthService::new(db, AuthConfig::default())
1268 }
1269
1270 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
1274 laterite_core::testing::connect_test(&[crate::migrations()]).await
1275 }
1276
1277 async fn set_last_seen(db: &Db, token: &str, seen: DateTime<Utc>) {
1279 store::renew_session(
1280 db,
1281 &hash_token(token),
1282 seen,
1283 seen + chrono::Duration::hours(2),
1284 )
1285 .await
1286 .unwrap();
1287 }
1288
1289 async fn last_seen(db: &Db, token: &str) -> DateTime<Utc> {
1290 store::find_valid_session(db, &hash_token(token), Utc::now())
1291 .await
1292 .unwrap()
1293 .expect("session still valid")
1294 .last_seen_at
1295 }
1296
1297 #[tokio::test]
1298 async fn a_request_past_the_halfway_mark_renews_the_session() {
1299 let (pool, _guard) = test_db().await;
1300 seed_user(&pool, "root", "hunter2", true).await;
1301 let svc = service(pool.clone());
1302
1303 let issued = svc
1304 .authenticate("root", "hunter2", &RequestContext::default())
1305 .await
1306 .unwrap();
1307 let stale = Utc::now() - chrono::Duration::minutes(90);
1310 set_last_seen(&pool, &issued.token, stale).await;
1311
1312 let resolved = svc.resolve_session(&issued.token).await.unwrap();
1313
1314 assert!(
1315 last_seen(&pool, &issued.token).await > stale,
1316 "a request past the halfway mark should push the idle clock"
1317 );
1318 assert!(
1319 resolved.expires_at > Utc::now() + chrono::Duration::minutes(115),
1320 "renewal should restore very nearly the full idle window"
1321 );
1322 }
1323
1324 #[tokio::test]
1325 async fn verify_session_renews_like_the_path_it_wraps() {
1326 let (pool, _guard) = test_db().await;
1327 seed_user(&pool, "root", "hunter2", true).await;
1328 let svc = service(pool.clone());
1329
1330 let issued = svc
1331 .authenticate("root", "hunter2", &RequestContext::default())
1332 .await
1333 .unwrap();
1334 let stale = Utc::now() - chrono::Duration::minutes(90);
1335 set_last_seen(&pool, &issued.token, stale).await;
1336
1337 svc.verify_session(&issued.token).await.unwrap();
1338
1339 assert!(last_seen(&pool, &issued.token).await > stale);
1343 }
1344
1345 #[tokio::test]
1346 async fn a_request_before_the_halfway_mark_writes_nothing() {
1347 let (pool, _guard) = test_db().await;
1348 seed_user(&pool, "root", "hunter2", true).await;
1349 let svc = service(pool.clone());
1350
1351 let issued = svc
1352 .authenticate("root", "hunter2", &RequestContext::default())
1353 .await
1354 .unwrap();
1355 let recent = Utc::now() - chrono::Duration::minutes(10);
1358 set_last_seen(&pool, &issued.token, recent).await;
1359
1360 svc.resolve_session(&issued.token).await.unwrap();
1361
1362 assert_eq!(
1363 last_seen(&pool, &issued.token).await.timestamp(),
1364 recent.timestamp(),
1365 "a request inside the halfway mark should leave the row alone"
1366 );
1367 }
1368
1369 #[tokio::test]
1370 async fn a_session_past_its_idle_window_no_longer_resolves() {
1371 let (pool, _guard) = test_db().await;
1372 seed_user(&pool, "root", "hunter2", true).await;
1373 let svc = service(pool.clone());
1374
1375 let issued = svc
1376 .authenticate("root", "hunter2", &RequestContext::default())
1377 .await
1378 .unwrap();
1379 let long_ago = Utc::now() - chrono::Duration::hours(3);
1382 set_last_seen(&pool, &issued.token, long_ago).await;
1383
1384 assert!(matches!(
1385 svc.resolve_session(&issued.token).await,
1386 Err(AuthError::SessionInvalid)
1387 ));
1388 }
1389
1390 #[tokio::test]
1391 async fn a_remember_cookie_is_single_use_and_rotates() {
1392 let (pool, _guard) = test_db().await;
1393 let uid = seed_user(&pool, "root", "hunter2", true).await;
1394 let svc = service(pool);
1395
1396 let first = svc.issue_remember(uid).await.unwrap();
1397 let recalled = svc
1398 .consume_remember(&first.cookie, &RequestContext::default())
1399 .await
1400 .unwrap();
1401
1402 assert_ne!(
1403 recalled.remember.cookie, first.cookie,
1404 "using a credential must replace its secret"
1405 );
1406 assert_eq!(
1407 recalled.remember.cookie.split_once(':').unwrap().0,
1408 first.cookie.split_once(':').unwrap().0,
1409 "the selector is the series and must survive rotation"
1410 );
1411 svc.verify_session(&recalled.session.token).await.unwrap();
1413 assert!(matches!(
1416 svc.consume_remember(&first.cookie, &RequestContext::default())
1417 .await,
1418 Err(AuthError::SessionInvalid)
1419 ));
1420 }
1421
1422 #[tokio::test]
1423 async fn a_stale_secret_drops_every_credential_the_user_holds() {
1424 let (pool, _guard) = test_db().await;
1425 let uid = seed_user(&pool, "root", "hunter2", true).await;
1426 let svc = service(pool);
1427
1428 let stolen = svc.issue_remember(uid).await.unwrap();
1429 let other_device = svc.issue_remember(uid).await.unwrap();
1430 let owner = svc
1432 .consume_remember(&stolen.cookie, &RequestContext::default())
1433 .await
1434 .unwrap();
1435
1436 let selector = stolen.cookie.split_once(':').unwrap().0;
1439 let forged = format!("{selector}:{}", generate_token());
1440 assert!(matches!(
1441 svc.consume_remember(&forged, &RequestContext::default())
1442 .await,
1443 Err(AuthError::SessionInvalid)
1444 ));
1445
1446 for credential in [owner.remember.cookie, other_device.cookie] {
1449 assert!(matches!(
1450 svc.consume_remember(&credential, &RequestContext::default())
1451 .await,
1452 Err(AuthError::SessionInvalid)
1453 ));
1454 }
1455 }
1456
1457 #[tokio::test]
1458 async fn the_sessions_list_marks_the_one_asking() {
1459 let (pool, _guard) = test_db().await;
1460 seed_user(&pool, "root", "hunter2", true).await;
1461 let svc = service(pool);
1462
1463 let first = svc
1464 .authenticate("root", "hunter2", &RequestContext::default())
1465 .await
1466 .unwrap();
1467 let second = svc
1468 .authenticate("root", "hunter2", &RequestContext::default())
1469 .await
1470 .unwrap();
1471
1472 let listed = svc
1473 .list_sessions(first.user_id, &second.token)
1474 .await
1475 .unwrap();
1476 assert_eq!(listed.len(), 2);
1477 assert_eq!(listed.iter().filter(|s| s.current).count(), 1);
1478 assert!(listed.iter().all(|s| !s.id.is_empty()));
1480 }
1481
1482 #[tokio::test]
1483 async fn one_account_cannot_revoke_anothers_session() {
1484 let (pool, _guard) = test_db().await;
1485 seed_user(&pool, "root", "hunter2", true).await;
1486 seed_user(&pool, "mallory", "hunter2", false).await;
1487 let svc = service(pool);
1488
1489 let victim = svc
1490 .authenticate("root", "hunter2", &RequestContext::default())
1491 .await
1492 .unwrap();
1493 let attacker = svc
1494 .authenticate("mallory", "hunter2", &RequestContext::default())
1495 .await
1496 .unwrap();
1497
1498 let victims_id = svc
1500 .list_sessions(victim.user_id, &victim.token)
1501 .await
1502 .unwrap()
1503 .remove(0)
1504 .id;
1505
1506 assert!(
1507 !svc.revoke_session(attacker.user_id, &victims_id)
1508 .await
1509 .unwrap(),
1510 "a revoke is scoped to its own account"
1511 );
1512 svc.verify_session(&victim.token).await.unwrap();
1514 }
1515
1516 #[tokio::test]
1517 async fn signing_out_everywhere_keeps_this_one_and_drops_credentials() {
1518 let (pool, _guard) = test_db().await;
1519 let uid = seed_user(&pool, "root", "hunter2", true).await;
1520 let svc = service(pool);
1521
1522 let keep = svc
1523 .authenticate("root", "hunter2", &RequestContext::default())
1524 .await
1525 .unwrap();
1526 let other = svc
1527 .authenticate("root", "hunter2", &RequestContext::default())
1528 .await
1529 .unwrap();
1530 let credential = svc.issue_remember(uid).await.unwrap();
1531
1532 let ended = svc.sign_out_everywhere(uid, &keep.token).await.unwrap();
1533
1534 assert_eq!(ended, 1);
1535 svc.verify_session(&keep.token).await.unwrap();
1536 assert!(matches!(
1537 svc.verify_session(&other.token).await,
1538 Err(AuthError::SessionInvalid)
1539 ));
1540 assert!(matches!(
1543 svc.consume_remember(&credential.cookie, &RequestContext::default())
1544 .await,
1545 Err(AuthError::SessionInvalid)
1546 ));
1547 }
1548
1549 #[tokio::test]
1550 async fn revoking_a_credential_ends_it() {
1551 let (pool, _guard) = test_db().await;
1552 let uid = seed_user(&pool, "root", "hunter2", true).await;
1553 let svc = service(pool);
1554
1555 let credential = svc.issue_remember(uid).await.unwrap();
1556 svc.revoke_remember(&credential.cookie).await.unwrap();
1557
1558 assert!(matches!(
1559 svc.consume_remember(&credential.cookie, &RequestContext::default())
1560 .await,
1561 Err(AuthError::SessionInvalid)
1562 ));
1563 }
1564
1565 #[tokio::test]
1566 async fn deactivating_blocks_sign_in_and_ends_the_sessions() {
1567 let (pool, _guard) = test_db().await;
1568 let admin = seed_user(&pool, "root", "hunter2", true).await;
1569 let target = seed_user(&pool, "editor", "hunter2", false).await;
1570 let svc = service(pool.clone());
1571
1572 let issued = svc
1573 .authenticate("editor", "hunter2", &RequestContext::default())
1574 .await
1575 .unwrap();
1576 let credential = svc.issue_remember(target).await.unwrap();
1577
1578 svc.set_user_active(admin, target, false).await.unwrap();
1579
1580 assert!(matches!(
1582 svc.authenticate("editor", "hunter2", &RequestContext::default())
1583 .await,
1584 Err(AuthError::InactiveAccount)
1585 ));
1586 assert!(matches!(
1589 svc.verify_session(&issued.token).await,
1590 Err(AuthError::SessionInvalid)
1591 ));
1592 assert!(matches!(
1593 svc.consume_remember(&credential.cookie, &RequestContext::default())
1594 .await,
1595 Err(AuthError::SessionInvalid)
1596 ));
1597 }
1598
1599 #[tokio::test]
1600 async fn reactivating_lets_them_back_in() {
1601 let (pool, _guard) = test_db().await;
1602 let admin = seed_user(&pool, "root", "hunter2", true).await;
1603 let target = seed_user(&pool, "editor", "hunter2", false).await;
1604 let svc = service(pool);
1605
1606 svc.set_user_active(admin, target, false).await.unwrap();
1607 svc.set_user_active(admin, target, true).await.unwrap();
1608
1609 svc.authenticate("editor", "hunter2", &RequestContext::default())
1611 .await
1612 .unwrap();
1613 }
1614
1615 #[tokio::test]
1616 async fn an_operator_cannot_deactivate_themselves() {
1617 let (pool, _guard) = test_db().await;
1618 let admin = seed_user(&pool, "root", "hunter2", true).await;
1619 let svc = service(pool);
1620
1621 assert!(matches!(
1624 svc.set_user_active(admin, admin, false).await,
1625 Err(AuthError::Refused(_))
1626 ));
1627 }
1628
1629 #[tokio::test]
1630 async fn the_last_superuser_cannot_be_deactivated() {
1631 let (pool, _guard) = test_db().await;
1632 let only = seed_user(&pool, "root", "hunter2", true).await;
1633 let editor = seed_user(&pool, "editor", "hunter2", false).await;
1634 let svc = service(pool.clone());
1635
1636 assert!(matches!(
1639 svc.set_user_active(editor, only, false).await,
1640 Err(AuthError::Refused(_))
1641 ));
1642
1643 let second = seed_user(&pool, "root2", "hunter2", true).await;
1645 svc.set_user_active(second, only, false).await.unwrap();
1646 }
1647
1648 #[tokio::test]
1649 async fn audit_entries_record_and_read_back_newest_first() {
1650 let (pool, _guard) = test_db().await;
1651 let actor = seed_user(&pool, "root", "hunter2", true).await;
1652 let svc = service(pool);
1653
1654 svc.record_audit(AuditEntry {
1655 actor_user_id: Some(actor),
1656 actor_username: "root",
1657 action: "backend.role.update",
1658 target_type: Some("backend_role"),
1659 target_id: Some("42"),
1660 target_label: Some("Editors"),
1661 detail: Some(r#"{"added":["a.b"]}"#),
1662 })
1663 .await
1664 .unwrap();
1665 svc.record_audit(AuditEntry {
1666 actor_user_id: Some(actor),
1667 actor_username: "root",
1668 action: "backend.plugin.disable",
1669 target_type: Some("plugin"),
1670 target_id: Some("acme.widgets"),
1671 target_label: None,
1672 detail: None,
1673 })
1674 .await
1675 .unwrap();
1676
1677 let entries = svc.recent_audit(10).await.unwrap();
1678 assert_eq!(entries.len(), 2);
1679 assert_eq!(entries[0].action, "backend.plugin.disable");
1681 assert_eq!(entries[0].target_id.as_deref(), Some("acme.widgets"));
1682 assert_eq!(entries[0].detail, None);
1683 assert_eq!(entries[1].action, "backend.role.update");
1684 assert_eq!(entries[1].actor_username, "root");
1685 assert_eq!(entries[1].actor_user_id, Some(actor));
1686 assert_eq!(entries[1].detail.as_deref(), Some(r#"{"added":["a.b"]}"#));
1687 }
1688
1689 #[tokio::test]
1690 async fn authenticate_issues_a_verifiable_session() {
1691 let (pool, _guard) = test_db().await;
1692 seed_user(&pool, "root", "hunter2", true).await;
1693 let svc = service(pool);
1694
1695 let session = svc
1696 .authenticate("root", "hunter2", &RequestContext::default())
1697 .await
1698 .expect("login should succeed");
1699 let identity = svc
1700 .verify_session(&session.token)
1701 .await
1702 .expect("session should resolve");
1703
1704 assert_eq!(identity.user.username, "root");
1705 assert_eq!(identity.user.full_name(), "Test Operator");
1706 assert!(identity.allows("anything.superuser.can.do"));
1707 }
1708
1709 #[tokio::test]
1710 async fn session_data_blob_round_trips() {
1711 let (pool, _guard) = test_db().await;
1712 seed_user(&pool, "root", "hunter2", true).await;
1713 let svc = service(pool);
1714
1715 let session = svc
1716 .authenticate("root", "hunter2", &RequestContext::default())
1717 .await
1718 .unwrap();
1719
1720 let resolved = svc.resolve_session(&session.token).await.unwrap();
1722 assert_eq!(resolved.data, None);
1723 assert_eq!(resolved.identity.user.username, "root");
1724
1725 svc.set_session_data(&session.token, r#"{"v":1,"csrf":"abc"}"#)
1727 .await
1728 .unwrap();
1729 let resolved = svc.resolve_session(&session.token).await.unwrap();
1730 assert_eq!(resolved.data.as_deref(), Some(r#"{"v":1,"csrf":"abc"}"#));
1731 }
1732
1733 #[tokio::test]
1734 async fn username_is_case_insensitive_across_backends() {
1735 let (pool, _guard) = test_db().await;
1739 let hash = password::hash_password("pw").unwrap();
1740 store::create_user(
1741 &pool,
1742 "Root",
1743 "Root@Example.test",
1744 "Case",
1745 None,
1746 &hash,
1747 true,
1748 )
1749 .await
1750 .unwrap();
1751 let svc = service(pool);
1752 let session = svc
1753 .authenticate("ROOT", "pw", &RequestContext::default())
1754 .await
1755 .expect("case-varied login should resolve to the same account");
1756 let identity = svc.verify_session(&session.token).await.unwrap();
1757 assert_eq!(identity.user.username, "root");
1758 assert_eq!(identity.user.email, "root@example.test");
1759 }
1760
1761 #[tokio::test]
1762 async fn full_name_falls_back_to_first_name_when_last_is_absent() {
1763 let (pool, _guard) = test_db().await;
1764 let hash = password::hash_password("pw").unwrap();
1765 store::create_user(
1766 &pool,
1767 "mono",
1768 "mono@example.test",
1769 "Prakash",
1770 None,
1771 &hash,
1772 true,
1773 )
1774 .await
1775 .unwrap();
1776 let svc = service(pool);
1777 let session = svc
1778 .authenticate("mono", "pw", &RequestContext::default())
1779 .await
1780 .unwrap();
1781 let identity = svc.verify_session(&session.token).await.unwrap();
1782 assert_eq!(identity.user.full_name(), "Prakash");
1783 }
1784
1785 #[tokio::test]
1786 async fn operator_timezone_round_trips_and_clears() {
1787 let (pool, _guard) = test_db().await;
1788 let id = seed_user(&pool, "tz", "pw", true).await;
1789
1790 let user = store::find_active_user_by_id(&pool, id)
1792 .await
1793 .unwrap()
1794 .unwrap();
1795 assert_eq!(user.timezone, None);
1796
1797 let svc = service(pool.clone());
1798 svc.set_user_timezone(id, Some("Asia/Kolkata"))
1799 .await
1800 .unwrap();
1801 let user = store::find_active_user_by_id(&pool, id)
1802 .await
1803 .unwrap()
1804 .unwrap();
1805 assert_eq!(user.timezone.as_deref(), Some("Asia/Kolkata"));
1806
1807 svc.set_user_timezone(id, None).await.unwrap();
1809 let user = store::find_active_user_by_id(&pool, id)
1810 .await
1811 .unwrap()
1812 .unwrap();
1813 assert_eq!(user.timezone, None);
1814 }
1815
1816 #[tokio::test]
1817 async fn operator_locale_round_trips_and_clears() {
1818 let (pool, _guard) = test_db().await;
1819 let id = seed_user(&pool, "loc", "pw", true).await;
1820
1821 let user = store::find_active_user_by_id(&pool, id)
1823 .await
1824 .unwrap()
1825 .unwrap();
1826 assert_eq!(user.locale, None);
1827
1828 let svc = service(pool.clone());
1829 svc.set_user_locale(id, Some("kn")).await.unwrap();
1830 let user = store::find_active_user_by_id(&pool, id)
1831 .await
1832 .unwrap()
1833 .unwrap();
1834 assert_eq!(user.locale.as_deref(), Some("kn"));
1835
1836 svc.set_user_locale(id, None).await.unwrap();
1838 let user = store::find_active_user_by_id(&pool, id)
1839 .await
1840 .unwrap()
1841 .unwrap();
1842 assert_eq!(user.locale, None);
1843 }
1844
1845 #[tokio::test]
1846 async fn has_any_operator_flips_after_the_first_account() {
1847 let (pool, _guard) = test_db().await;
1848 let svc = service(pool);
1849
1850 assert!(!svc.has_any_operator().await.unwrap());
1852
1853 svc.create_superuser(NewOperator {
1854 username: "first",
1855 email: "first@example.test",
1856 first_name: "First",
1857 last_name: None,
1858 password: "hunter2-hunter2",
1859 timezone: Some("Asia/Kolkata"),
1860 })
1861 .await
1862 .unwrap();
1863
1864 assert!(svc.has_any_operator().await.unwrap());
1865
1866 let session = svc
1868 .authenticate("first", "hunter2-hunter2", &RequestContext::default())
1869 .await
1870 .unwrap();
1871 let identity = svc.verify_session(&session.token).await.unwrap();
1872 assert!(identity.allows("anything.a.superuser.can.do"));
1873 assert_eq!(identity.user.timezone.as_deref(), Some("Asia/Kolkata"));
1874 }
1875
1876 #[tokio::test]
1877 async fn wrong_password_is_rejected() {
1878 let (pool, _guard) = test_db().await;
1879 seed_user(&pool, "root", "hunter2", false).await;
1880 let svc = service(pool);
1881
1882 let err = svc
1883 .authenticate("root", "wrong", &RequestContext::default())
1884 .await
1885 .unwrap_err();
1886 assert!(matches!(err, AuthError::InvalidCredentials));
1887 }
1888
1889 #[tokio::test]
1890 async fn unknown_user_is_rejected_without_distinction() {
1891 let (pool, _guard) = test_db().await;
1892 let svc = service(pool);
1893 let err = svc
1894 .authenticate("ghost", "whatever", &RequestContext::default())
1895 .await
1896 .unwrap_err();
1897 assert!(matches!(err, AuthError::InvalidCredentials));
1898 }
1899
1900 #[tokio::test]
1901 async fn lockout_trips_after_max_failures() {
1902 let (pool, _guard) = test_db().await;
1903 seed_user(&pool, "root", "hunter2", false).await;
1904 let svc = AuthService::new(
1905 pool,
1906 AuthConfig {
1907 max_failures: 3,
1908 ..AuthConfig::default()
1909 },
1910 );
1911 let ctx = RequestContext::default();
1912
1913 for _ in 0..3 {
1914 let err = svc.authenticate("root", "bad", &ctx).await.unwrap_err();
1915 assert!(matches!(err, AuthError::InvalidCredentials));
1916 }
1917 let err = svc.authenticate("root", "hunter2", &ctx).await.unwrap_err();
1919 assert!(matches!(err, AuthError::TooManyAttempts));
1920 }
1921
1922 #[tokio::test]
1923 async fn permissions_come_from_assigned_roles() {
1924 let (pool, _guard) = test_db().await;
1925 let user_id = seed_user(&pool, "mod", "pw", false).await;
1926 let role_id = store::create_role(
1927 &pool,
1928 "content_editor",
1929 "Content Editor",
1930 &["posts.*".to_string()],
1931 )
1932 .await
1933 .unwrap();
1934 store::assign_role(&pool, user_id, role_id).await.unwrap();
1935
1936 let svc = service(pool);
1937 let session = svc
1938 .authenticate("mod", "pw", &RequestContext::default())
1939 .await
1940 .unwrap();
1941 let identity = svc.verify_session(&session.token).await.unwrap();
1942
1943 assert!(identity.allows("posts.approve"));
1944 assert!(!identity.allows("users.edit"));
1945 identity.require("posts.edit").unwrap();
1946 assert!(identity.require("users.edit").is_err());
1947 }
1948
1949 #[tokio::test]
1950 async fn logout_invalidates_the_session() {
1951 let (pool, _guard) = test_db().await;
1952 seed_user(&pool, "root", "pw", true).await;
1953 let svc = service(pool);
1954
1955 let session = svc
1956 .authenticate("root", "pw", &RequestContext::default())
1957 .await
1958 .unwrap();
1959 svc.verify_session(&session.token).await.unwrap();
1960 svc.logout(&session.token).await.unwrap();
1961
1962 let err = svc.verify_session(&session.token).await.unwrap_err();
1963 assert!(matches!(err, AuthError::SessionInvalid));
1964 }
1965
1966 #[tokio::test]
1967 async fn inactive_account_is_refused_after_correct_password() {
1968 let (pool, _guard) = test_db().await;
1969 let user_id = seed_user(&pool, "root", "pw", false).await;
1970 let (sql, values) = laterite_core::query::build(
1972 pool.backend,
1973 sea_query::Query::update()
1974 .table(crate::schema::BackendUsers::Table)
1975 .value(crate::schema::BackendUsers::IsActive, false)
1976 .and_where(sea_query::Expr::col(crate::schema::BackendUsers::Id).eq(user_id))
1977 .to_owned(),
1978 );
1979 laterite_core::query::bind_values(sqlx::query(&sql), values)
1980 .execute(&pool.pool)
1981 .await
1982 .unwrap();
1983
1984 let svc = service(pool);
1985 let err = svc
1986 .authenticate("root", "pw", &RequestContext::default())
1987 .await
1988 .unwrap_err();
1989 assert!(matches!(err, AuthError::InactiveAccount));
1990 }
1991
1992 #[tokio::test]
1993 async fn reset_password_updates_the_hash() {
1994 let (pool, _guard) = test_db().await;
1995 seed_user(&pool, "root", "oldpw", true).await;
1996
1997 let new_hash = password::hash_password("newpw").unwrap();
1998 let affected = store::update_password_by_username(&pool, "root", &new_hash)
1999 .await
2000 .unwrap();
2001 assert_eq!(affected, 1);
2002
2003 let svc = service(pool);
2004 let ctx = RequestContext::default();
2005 assert!(matches!(
2006 svc.authenticate("root", "oldpw", &ctx).await.unwrap_err(),
2007 AuthError::InvalidCredentials
2008 ));
2009 svc.authenticate("root", "newpw", &ctx).await.unwrap();
2010 }
2011
2012 #[tokio::test]
2013 async fn reset_password_reports_unknown_user() {
2014 let (pool, _guard) = test_db().await;
2015 let hash = password::hash_password("x").unwrap();
2016 let affected = store::update_password_by_username(&pool, "ghost", &hash)
2017 .await
2018 .unwrap();
2019 assert_eq!(affected, 0);
2020 }
2021
2022 #[tokio::test]
2023 async fn list_users_returns_all_seeded() {
2024 let (pool, _guard) = test_db().await;
2025 seed_user(&pool, "alice", "pw", true).await;
2026 seed_user(&pool, "bob", "pw", false).await;
2027
2028 let users = store::list_backend_users(&pool).await.unwrap();
2029 assert_eq!(users.len(), 2);
2030 assert!(users
2031 .iter()
2032 .any(|u| u.username == "alice" && u.is_superuser));
2033 assert!(users.iter().any(|u| u.username == "bob" && !u.is_superuser));
2034 }
2035
2036 #[tokio::test]
2037 async fn unlock_clears_the_lockout() {
2038 let (pool, _guard) = test_db().await;
2039 seed_user(&pool, "root", "pw", false).await;
2040 let svc = AuthService::new(
2041 pool.clone(),
2042 AuthConfig {
2043 max_failures: 3,
2044 ..AuthConfig::default()
2045 },
2046 );
2047 let ctx = RequestContext::default();
2048
2049 for _ in 0..3 {
2050 let _ = svc.authenticate("root", "bad", &ctx).await;
2051 }
2052 assert!(matches!(
2053 svc.authenticate("root", "pw", &ctx).await.unwrap_err(),
2054 AuthError::TooManyAttempts
2055 ));
2056
2057 let cleared = store::clear_failed_attempts(&pool, "root").await.unwrap();
2058 assert!(cleared >= 3);
2059 svc.authenticate("root", "pw", &ctx).await.unwrap();
2060 }
2061}