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};
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
275pub const MIN_PASSWORD_LENGTH: usize = 8;
277
278#[derive(Clone)]
280pub struct AuthService {
281 db: Db,
282 config: AuthConfig,
283}
284
285impl AuthService {
286 pub fn new(db: Db, config: AuthConfig) -> Self {
287 Self { db, config }
288 }
289
290 pub fn session_absolute_timeout(&self) -> Duration {
293 self.config.session_absolute_timeout
294 }
295
296 pub async fn authenticate(
301 &self,
302 username: &str,
303 password: &str,
304 ctx: &RequestContext,
305 ) -> Result<IssuedSession, AuthError> {
306 let now = Utc::now();
307 let since = now - chrono_from_std(self.config.failure_window);
308
309 if store::count_recent_failures(&self.db, username, since).await?
310 >= self.config.max_failures
311 {
312 self.log(None, username, AccessEvent::LockedOut, ctx)
313 .await?;
314 return Err(AuthError::TooManyAttempts);
315 }
316
317 let user = match store::find_user_by_username(&self.db, username).await? {
318 Some(user) => user,
319 None => {
320 self.log(None, username, AccessEvent::LoginFailure, ctx)
321 .await?;
322 return Err(AuthError::InvalidCredentials);
323 }
324 };
325
326 if !password::verify_password(password, &user.password_hash)? {
327 self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
328 .await?;
329 return Err(AuthError::InvalidCredentials);
330 }
331
332 if !user.is_active {
333 self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
334 .await?;
335 return Err(AuthError::InactiveAccount);
336 }
337
338 let token = generate_token();
339 let expires_at = self.config.deadline(now, now);
340 store::insert_session(
341 &self.db,
342 &hash_token(&token),
343 user.id,
344 expires_at,
345 ctx.ip_address.as_deref(),
346 ctx.user_agent.as_deref(),
347 )
348 .await?;
349 self.log(Some(user.id), username, AccessEvent::LoginSuccess, ctx)
350 .await?;
351
352 Ok(IssuedSession {
353 token,
354 expires_at,
355 user_id: user.id,
356 })
357 }
358
359 pub async fn resolve_session(&self, token: &str) -> Result<ResolvedSession, AuthError> {
364 let token_hash = hash_token(token);
365 let now = Utc::now();
366
367 let session = store::find_valid_session(&self.db, &token_hash, now)
368 .await?
369 .ok_or(AuthError::SessionInvalid)?;
370 let user = store::find_active_user_by_id(&self.db, session.user_id)
371 .await?
372 .ok_or(AuthError::SessionInvalid)?;
373 let idle = chrono_from_std(self.config.session_idle_timeout);
377 let expires_at = self.config.deadline(session.created_at, now);
378 if now - session.last_seen_at > idle / 2 {
379 store::renew_session(&self.db, &token_hash, now, expires_at).await?;
380 }
381
382 let grants = store::load_role_permissions(&self.db, user.id)
383 .await?
384 .into_iter()
385 .flatten();
386 let overrides = store::load_user_permission_overrides(&self.db, user.id).await?;
389 let (mut allow, mut deny) = (Vec::new(), Vec::new());
390 for (code, decision) in overrides {
391 match decision.signum() {
392 1 => allow.push(code),
393 -1 => deny.push(code),
394 _ => {}
395 }
396 }
397 let permissions = PermissionSet::with_overrides(user.is_superuser, grants, allow, deny);
398
399 Ok(ResolvedSession {
400 identity: AuthenticatedUser { user, permissions },
401 data: session.data,
402 expires_at,
403 })
404 }
405
406 pub async fn verify_session(&self, token: &str) -> Result<AuthenticatedUser, AuthError> {
409 Ok(self.resolve_session(token).await?.identity)
410 }
411
412 pub async fn set_session_data(&self, token: &str, data: &str) -> Result<(), AuthError> {
416 store::set_session_data(&self.db, &hash_token(token), data).await
417 }
418
419 pub async fn issue_remember(&self, user_id: i64) -> Result<RememberCredential, AuthError> {
421 let (selector, verifier) = (generate_token(), generate_token());
422 let expires_at = Utc::now() + chrono_from_std(self.config.remember_duration);
423 store::insert_remember_token(
424 &self.db,
425 &selector,
426 &hash_token(&verifier),
427 user_id,
428 expires_at,
429 )
430 .await?;
431 Ok(RememberCredential {
432 cookie: format!("{selector}:{verifier}"),
433 expires_at,
434 })
435 }
436
437 pub async fn consume_remember(
445 &self,
446 cookie: &str,
447 ctx: &RequestContext,
448 ) -> Result<RecalledSession, AuthError> {
449 let (selector, verifier) = cookie.split_once(':').ok_or(AuthError::SessionInvalid)?;
450 let now = Utc::now();
451 let found = store::find_remember_token(&self.db, selector, now)
452 .await?
453 .ok_or(AuthError::SessionInvalid)?;
454
455 if !constant_time_eq(&hash_token(verifier), &found.verifier_hash) {
456 tracing::warn!(
457 user_id = found.user_id,
458 "a stay-signed-in cookie was presented with a stale secret; dropping every credential for this account"
459 );
460 store::delete_user_remember_tokens(&self.db, found.user_id).await?;
461 self.log(Some(found.user_id), "", AccessEvent::LoginFailure, ctx)
462 .await?;
463 return Err(AuthError::SessionInvalid);
464 }
465
466 let user = store::find_active_user_by_id(&self.db, found.user_id)
468 .await?
469 .ok_or(AuthError::SessionInvalid)?;
470
471 let replacement = generate_token();
475 let remember_expires = now + chrono_from_std(self.config.remember_duration);
476 store::rotate_remember_token(
477 &self.db,
478 selector,
479 &hash_token(&replacement),
480 remember_expires,
481 )
482 .await?;
483 let remember = RememberCredential {
484 cookie: format!("{selector}:{replacement}"),
485 expires_at: remember_expires,
486 };
487
488 let token = generate_token();
489 let expires_at = self.config.deadline(now, now);
490 store::insert_session(
491 &self.db,
492 &hash_token(&token),
493 user.id,
494 expires_at,
495 ctx.ip_address.as_deref(),
496 ctx.user_agent.as_deref(),
497 )
498 .await?;
499 self.log(
500 Some(user.id),
501 &user.username,
502 AccessEvent::LoginSuccess,
503 ctx,
504 )
505 .await?;
506
507 Ok(RecalledSession {
508 session: IssuedSession {
509 token,
510 expires_at,
511 user_id: user.id,
512 },
513 remember,
514 })
515 }
516
517 pub async fn revoke_remember(&self, cookie: &str) -> Result<(), AuthError> {
520 if let Some((selector, _)) = cookie.split_once(':') {
521 store::delete_remember_token(&self.db, selector).await?;
522 }
523 Ok(())
524 }
525
526 pub async fn list_sessions(
529 &self,
530 user_id: i64,
531 current_token: &str,
532 ) -> Result<Vec<ActiveSession>, AuthError> {
533 let current_hash = hash_token(current_token);
534 let rows = store::list_user_sessions(&self.db, user_id, Utc::now()).await?;
535 Ok(rows
536 .into_iter()
537 .map(|s| ActiveSession {
538 id: public_session_id(&s.token_hash),
539 created_at: s.created_at,
540 last_seen_at: s.last_seen_at,
541 expires_at: s.expires_at,
542 ip_address: s.ip_address,
543 user_agent: s.user_agent,
544 current: s.token_hash == current_hash,
545 })
546 .collect())
547 }
548
549 pub async fn revoke_session(&self, user_id: i64, id: &str) -> Result<bool, AuthError> {
553 let rows = store::list_user_sessions(&self.db, user_id, Utc::now()).await?;
554 let Some(target) = rows
555 .into_iter()
556 .find(|s| constant_time_eq(&public_session_id(&s.token_hash), id))
557 else {
558 return Ok(false);
559 };
560 let done = store::revoke_user_session(
561 &self.db,
562 user_id,
563 &target.token_hash,
564 store::RevokeReason::SignedOutElsewhere,
565 )
566 .await?;
567 Ok(done > 0)
568 }
569
570 pub async fn sign_out_everywhere(
575 &self,
576 user_id: i64,
577 keep_token: &str,
578 ) -> Result<u64, AuthError> {
579 let ended = store::revoke_user_sessions(
580 &self.db,
581 user_id,
582 Some(&hash_token(keep_token)),
583 store::RevokeReason::SignedOutElsewhere,
584 )
585 .await?;
586 store::revoke_user_remember_tokens(
587 &self.db,
588 user_id,
589 store::RevokeReason::SignedOutElsewhere,
590 )
591 .await?;
592 Ok(ended)
593 }
594
595 pub async fn change_password(
610 &self,
611 user_id: i64,
612 new_password: &str,
613 keep_token: Option<&str>,
614 actor: &Actor,
615 ) -> Result<(), AuthError> {
616 if new_password.chars().count() < MIN_PASSWORD_LENGTH {
617 return Err(AuthError::Refused(format!(
618 "A password needs at least {MIN_PASSWORD_LENGTH} characters."
619 )));
620 }
621 let hash = password::hash_password(new_password)?;
622 let keep = keep_token.map(hash_token);
623 let done = store::change_password(
624 &self.db,
625 user_id,
626 &hash,
627 keep.as_deref(),
628 actor.user_id(),
629 actor.label(),
630 )
631 .await?;
632 if done == 0 {
633 return Err(AuthError::SessionInvalid);
634 }
635 Ok(())
636 }
637
638 pub async fn change_own_password(
645 &self,
646 user_id: i64,
647 current_password: &str,
648 new_password: &str,
649 keep_token: &str,
650 ctx: &RequestContext,
651 ) -> Result<(), AuthError> {
652 let user = store::find_user_by_id(&self.db, user_id)
653 .await?
654 .ok_or(AuthError::SessionInvalid)?;
655 let since = Utc::now() - chrono_from_std(self.config.failure_window);
656 if store::count_recent_failures(&self.db, &user.username, since).await?
657 >= self.config.max_failures
658 {
659 self.log(Some(user.id), &user.username, AccessEvent::LockedOut, ctx)
660 .await?;
661 return Err(AuthError::TooManyAttempts);
662 }
663 if !password::verify_password(current_password, &user.password_hash)? {
664 self.log(
665 Some(user.id),
666 &user.username,
667 AccessEvent::LoginFailure,
668 ctx,
669 )
670 .await?;
671 return Err(AuthError::InvalidCredentials);
672 }
673 self.change_password(
674 user.id,
675 new_password,
676 Some(keep_token),
677 &Actor::user(user.id, user.username.as_str()),
678 )
679 .await
680 }
681
682 pub async fn password_changed_at(
684 &self,
685 user_id: i64,
686 ) -> Result<Option<DateTime<Utc>>, AuthError> {
687 store::password_changed_at(&self.db, user_id).await
688 }
689
690 pub async fn reset_password(
694 &self,
695 username: &str,
696 new_password: &str,
697 actor: &Actor,
698 ) -> Result<bool, AuthError> {
699 let Some(user) = store::find_user_by_username(&self.db, username).await? else {
700 return Ok(false);
701 };
702 self.change_password(user.id, new_password, None, actor)
703 .await?;
704 Ok(true)
705 }
706
707 pub async fn remember_holder(&self, cookie: &str) -> Result<Option<i64>, AuthError> {
711 let Some((selector, verifier)) = cookie.split_once(':') else {
712 return Ok(None);
713 };
714 let Some(found) = store::find_remember_token(&self.db, selector, Utc::now()).await? else {
715 return Ok(None);
716 };
717 Ok(constant_time_eq(&hash_token(verifier), &found.verifier_hash).then_some(found.user_id))
718 }
719
720 pub async fn remember_end(
727 &self,
728 cookie: &str,
729 ) -> Result<Option<store::RevokeReason>, AuthError> {
730 let Some((selector, verifier)) = cookie.split_once(':') else {
731 return Ok(None);
732 };
733 let Some((verifier_hash, reason)) =
734 store::revoked_remember_token(&self.db, selector).await?
735 else {
736 return Ok(None);
737 };
738 if !constant_time_eq(&hash_token(verifier), &verifier_hash) {
739 return Ok(None);
740 }
741 store::delete_remember_token(&self.db, selector).await?;
742 Ok(Some(reason))
743 }
744
745 pub async fn session_end(&self, token: &str) -> Result<Option<store::SessionEnd>, AuthError> {
750 let token_hash = hash_token(token);
751 let end = store::session_end(&self.db, &token_hash, Utc::now()).await?;
752 if matches!(end, Some(store::SessionEnd::Revoked(_))) {
753 store::delete_session(&self.db, &token_hash).await?;
754 }
755 Ok(end)
756 }
757
758 pub async fn set_user_active(
772 &self,
773 actor_id: i64,
774 user_id: i64,
775 active: bool,
776 ) -> Result<(), AuthError> {
777 if !active {
778 if actor_id == user_id {
779 return Err(AuthError::Refused(
780 "You cannot deactivate your own account.".to_string(),
781 ));
782 }
783 let user = store::find_user_by_id(&self.db, user_id)
784 .await?
785 .ok_or(AuthError::SessionInvalid)?;
786 if user.is_superuser && store::other_active_superusers(&self.db, user_id).await? == 0 {
787 return Err(AuthError::Refused(
788 "This is the last active superuser; the panel would have no \
789 administrator left."
790 .to_string(),
791 ));
792 }
793 }
794
795 if store::set_user_active(&self.db, user_id, active).await? == 0 {
796 return Err(AuthError::SessionInvalid);
797 }
798 Ok(())
799 }
800
801 pub async fn logout(&self, token: &str) -> Result<(), AuthError> {
803 store::delete_session(&self.db, &hash_token(token)).await
804 }
805
806 pub async fn set_user_timezone(
810 &self,
811 user_id: i64,
812 timezone: Option<&str>,
813 ) -> Result<(), AuthError> {
814 store::set_user_timezone(&self.db, user_id, timezone).await
815 }
816
817 pub async fn set_user_locale(
821 &self,
822 user_id: i64,
823 locale: Option<&str>,
824 ) -> Result<(), AuthError> {
825 store::set_user_locale(&self.db, user_id, locale).await
826 }
827
828 pub async fn user_permission_overrides(
830 &self,
831 user_id: i64,
832 ) -> Result<std::collections::HashMap<String, i64>, AuthError> {
833 store::load_user_permission_overrides(&self.db, user_id).await
834 }
835
836 pub async fn set_user_permissions(
839 &self,
840 user_id: i64,
841 overrides: &std::collections::HashMap<String, i64>,
842 ) -> Result<(), AuthError> {
843 store::set_user_permissions(&self.db, user_id, overrides).await
844 }
845
846 pub async fn record_audit(&self, entry: AuditEntry<'_>) -> Result<(), AuthError> {
850 store::insert_audit_log(
851 &self.db,
852 entry.actor_user_id,
853 entry.actor_username,
854 entry.action,
855 entry.target_type,
856 entry.target_id,
857 entry.detail,
858 )
859 .await
860 }
861
862 pub async fn recent_audit(&self, limit: u64) -> Result<Vec<store::AuditRecord>, AuthError> {
864 store::recent_audit(&self.db, limit).await
865 }
866
867 pub async fn has_any_operator(&self) -> Result<bool, AuthError> {
870 store::any_user_exists(&self.db).await
871 }
872
873 pub async fn create_superuser(&self, new: NewOperator<'_>) -> Result<i64, AuthError> {
877 let hash = password::hash_password(new.password)?;
878 let id = store::create_user(
879 &self.db,
880 new.username,
881 new.email,
882 new.first_name,
883 new.last_name,
884 &hash,
885 true,
886 )
887 .await?;
888 if new.timezone.is_some() {
889 store::set_user_timezone(&self.db, id, new.timezone).await?;
890 }
891 if let Some(role_id) = store::role_id_by_code(&self.db, crate::ROLE_ADMIN).await? {
895 store::assign_role(&self.db, id, role_id).await?;
896 }
897 Ok(id)
898 }
899
900 async fn log(
901 &self,
902 user_id: Option<i64>,
903 username: &str,
904 event: AccessEvent,
905 ctx: &RequestContext,
906 ) -> Result<(), AuthError> {
907 store::insert_access_log(
908 &self.db,
909 user_id,
910 username,
911 event,
912 ctx.ip_address.as_deref(),
913 ctx.user_agent.as_deref(),
914 )
915 .await
916 }
917}
918
919fn chrono_from_std(d: Duration) -> chrono::Duration {
922 chrono::Duration::from_std(d).expect("auth policy duration out of range")
923}
924
925fn generate_token() -> String {
926 let mut bytes = [0u8; 32];
927 rand::rngs::OsRng.fill_bytes(&mut bytes);
928 to_hex(&bytes)
929}
930
931fn public_session_id(token_hash: &str) -> String {
935 let mut hasher = Sha256::new();
936 hasher.update(b"laterite:session-id:");
937 hasher.update(token_hash.as_bytes());
938 to_hex(&hasher.finalize())
939}
940
941fn constant_time_eq(a: &str, b: &str) -> bool {
943 let (a, b) = (a.as_bytes(), b.as_bytes());
944 if a.len() != b.len() {
945 return false;
946 }
947 a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
948}
949
950fn hash_token(token: &str) -> String {
951 let mut hasher = Sha256::new();
952 hasher.update(token.as_bytes());
953 to_hex(&hasher.finalize())
954}
955
956fn to_hex(bytes: &[u8]) -> String {
957 let mut out = String::with_capacity(bytes.len() * 2);
958 for byte in bytes {
959 let _ = write!(out, "{byte:02x}");
960 }
961 out
962}
963
964#[cfg(test)]
965mod tests {
966 use super::*;
967
968 async fn seed_user(db: &Db, username: &str, password: &str, superuser: bool) -> i64 {
969 let hash = password::hash_password(password).unwrap();
970 store::create_user(
971 db,
972 username,
973 &format!("{username}@example.test"),
974 "Test",
975 Some("Operator"),
976 &hash,
977 superuser,
978 )
979 .await
980 .unwrap()
981 }
982
983 fn service(db: Db) -> AuthService {
984 AuthService::new(db, AuthConfig::default())
985 }
986
987 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
991 laterite_core::testing::connect_test(&[crate::migrations()]).await
992 }
993
994 async fn set_last_seen(db: &Db, token: &str, seen: DateTime<Utc>) {
996 store::renew_session(
997 db,
998 &hash_token(token),
999 seen,
1000 seen + chrono::Duration::hours(2),
1001 )
1002 .await
1003 .unwrap();
1004 }
1005
1006 async fn last_seen(db: &Db, token: &str) -> DateTime<Utc> {
1007 store::find_valid_session(db, &hash_token(token), Utc::now())
1008 .await
1009 .unwrap()
1010 .expect("session still valid")
1011 .last_seen_at
1012 }
1013
1014 #[tokio::test]
1015 async fn a_request_past_the_halfway_mark_renews_the_session() {
1016 let (pool, _guard) = test_db().await;
1017 seed_user(&pool, "root", "hunter2", true).await;
1018 let svc = service(pool.clone());
1019
1020 let issued = svc
1021 .authenticate("root", "hunter2", &RequestContext::default())
1022 .await
1023 .unwrap();
1024 let stale = Utc::now() - chrono::Duration::minutes(90);
1027 set_last_seen(&pool, &issued.token, stale).await;
1028
1029 let resolved = svc.resolve_session(&issued.token).await.unwrap();
1030
1031 assert!(
1032 last_seen(&pool, &issued.token).await > stale,
1033 "a request past the halfway mark should push the idle clock"
1034 );
1035 assert!(
1036 resolved.expires_at > Utc::now() + chrono::Duration::minutes(115),
1037 "renewal should restore very nearly the full idle window"
1038 );
1039 }
1040
1041 #[tokio::test]
1042 async fn verify_session_renews_like_the_path_it_wraps() {
1043 let (pool, _guard) = test_db().await;
1044 seed_user(&pool, "root", "hunter2", true).await;
1045 let svc = service(pool.clone());
1046
1047 let issued = svc
1048 .authenticate("root", "hunter2", &RequestContext::default())
1049 .await
1050 .unwrap();
1051 let stale = Utc::now() - chrono::Duration::minutes(90);
1052 set_last_seen(&pool, &issued.token, stale).await;
1053
1054 svc.verify_session(&issued.token).await.unwrap();
1055
1056 assert!(last_seen(&pool, &issued.token).await > stale);
1060 }
1061
1062 #[tokio::test]
1063 async fn a_request_before_the_halfway_mark_writes_nothing() {
1064 let (pool, _guard) = test_db().await;
1065 seed_user(&pool, "root", "hunter2", true).await;
1066 let svc = service(pool.clone());
1067
1068 let issued = svc
1069 .authenticate("root", "hunter2", &RequestContext::default())
1070 .await
1071 .unwrap();
1072 let recent = Utc::now() - chrono::Duration::minutes(10);
1075 set_last_seen(&pool, &issued.token, recent).await;
1076
1077 svc.resolve_session(&issued.token).await.unwrap();
1078
1079 assert_eq!(
1080 last_seen(&pool, &issued.token).await.timestamp(),
1081 recent.timestamp(),
1082 "a request inside the halfway mark should leave the row alone"
1083 );
1084 }
1085
1086 #[tokio::test]
1087 async fn a_session_past_its_idle_window_no_longer_resolves() {
1088 let (pool, _guard) = test_db().await;
1089 seed_user(&pool, "root", "hunter2", true).await;
1090 let svc = service(pool.clone());
1091
1092 let issued = svc
1093 .authenticate("root", "hunter2", &RequestContext::default())
1094 .await
1095 .unwrap();
1096 let long_ago = Utc::now() - chrono::Duration::hours(3);
1099 set_last_seen(&pool, &issued.token, long_ago).await;
1100
1101 assert!(matches!(
1102 svc.resolve_session(&issued.token).await,
1103 Err(AuthError::SessionInvalid)
1104 ));
1105 }
1106
1107 #[tokio::test]
1108 async fn a_remember_cookie_is_single_use_and_rotates() {
1109 let (pool, _guard) = test_db().await;
1110 let uid = seed_user(&pool, "root", "hunter2", true).await;
1111 let svc = service(pool);
1112
1113 let first = svc.issue_remember(uid).await.unwrap();
1114 let recalled = svc
1115 .consume_remember(&first.cookie, &RequestContext::default())
1116 .await
1117 .unwrap();
1118
1119 assert_ne!(
1120 recalled.remember.cookie, first.cookie,
1121 "using a credential must replace its secret"
1122 );
1123 assert_eq!(
1124 recalled.remember.cookie.split_once(':').unwrap().0,
1125 first.cookie.split_once(':').unwrap().0,
1126 "the selector is the series and must survive rotation"
1127 );
1128 svc.verify_session(&recalled.session.token).await.unwrap();
1130 assert!(matches!(
1133 svc.consume_remember(&first.cookie, &RequestContext::default())
1134 .await,
1135 Err(AuthError::SessionInvalid)
1136 ));
1137 }
1138
1139 #[tokio::test]
1140 async fn a_stale_secret_drops_every_credential_the_user_holds() {
1141 let (pool, _guard) = test_db().await;
1142 let uid = seed_user(&pool, "root", "hunter2", true).await;
1143 let svc = service(pool);
1144
1145 let stolen = svc.issue_remember(uid).await.unwrap();
1146 let other_device = svc.issue_remember(uid).await.unwrap();
1147 let owner = svc
1149 .consume_remember(&stolen.cookie, &RequestContext::default())
1150 .await
1151 .unwrap();
1152
1153 let selector = stolen.cookie.split_once(':').unwrap().0;
1156 let forged = format!("{selector}:{}", generate_token());
1157 assert!(matches!(
1158 svc.consume_remember(&forged, &RequestContext::default())
1159 .await,
1160 Err(AuthError::SessionInvalid)
1161 ));
1162
1163 for credential in [owner.remember.cookie, other_device.cookie] {
1166 assert!(matches!(
1167 svc.consume_remember(&credential, &RequestContext::default())
1168 .await,
1169 Err(AuthError::SessionInvalid)
1170 ));
1171 }
1172 }
1173
1174 #[tokio::test]
1175 async fn the_sessions_list_marks_the_one_asking() {
1176 let (pool, _guard) = test_db().await;
1177 seed_user(&pool, "root", "hunter2", true).await;
1178 let svc = service(pool);
1179
1180 let first = svc
1181 .authenticate("root", "hunter2", &RequestContext::default())
1182 .await
1183 .unwrap();
1184 let second = svc
1185 .authenticate("root", "hunter2", &RequestContext::default())
1186 .await
1187 .unwrap();
1188
1189 let listed = svc
1190 .list_sessions(first.user_id, &second.token)
1191 .await
1192 .unwrap();
1193 assert_eq!(listed.len(), 2);
1194 assert_eq!(listed.iter().filter(|s| s.current).count(), 1);
1195 assert!(listed.iter().all(|s| !s.id.is_empty()));
1197 }
1198
1199 #[tokio::test]
1200 async fn one_account_cannot_revoke_anothers_session() {
1201 let (pool, _guard) = test_db().await;
1202 seed_user(&pool, "root", "hunter2", true).await;
1203 seed_user(&pool, "mallory", "hunter2", false).await;
1204 let svc = service(pool);
1205
1206 let victim = svc
1207 .authenticate("root", "hunter2", &RequestContext::default())
1208 .await
1209 .unwrap();
1210 let attacker = svc
1211 .authenticate("mallory", "hunter2", &RequestContext::default())
1212 .await
1213 .unwrap();
1214
1215 let victims_id = svc
1217 .list_sessions(victim.user_id, &victim.token)
1218 .await
1219 .unwrap()
1220 .remove(0)
1221 .id;
1222
1223 assert!(
1224 !svc.revoke_session(attacker.user_id, &victims_id)
1225 .await
1226 .unwrap(),
1227 "a revoke is scoped to its own account"
1228 );
1229 svc.verify_session(&victim.token).await.unwrap();
1231 }
1232
1233 #[tokio::test]
1234 async fn signing_out_everywhere_keeps_this_one_and_drops_credentials() {
1235 let (pool, _guard) = test_db().await;
1236 let uid = seed_user(&pool, "root", "hunter2", true).await;
1237 let svc = service(pool);
1238
1239 let keep = svc
1240 .authenticate("root", "hunter2", &RequestContext::default())
1241 .await
1242 .unwrap();
1243 let other = svc
1244 .authenticate("root", "hunter2", &RequestContext::default())
1245 .await
1246 .unwrap();
1247 let credential = svc.issue_remember(uid).await.unwrap();
1248
1249 let ended = svc.sign_out_everywhere(uid, &keep.token).await.unwrap();
1250
1251 assert_eq!(ended, 1);
1252 svc.verify_session(&keep.token).await.unwrap();
1253 assert!(matches!(
1254 svc.verify_session(&other.token).await,
1255 Err(AuthError::SessionInvalid)
1256 ));
1257 assert!(matches!(
1260 svc.consume_remember(&credential.cookie, &RequestContext::default())
1261 .await,
1262 Err(AuthError::SessionInvalid)
1263 ));
1264 }
1265
1266 #[tokio::test]
1267 async fn revoking_a_credential_ends_it() {
1268 let (pool, _guard) = test_db().await;
1269 let uid = seed_user(&pool, "root", "hunter2", true).await;
1270 let svc = service(pool);
1271
1272 let credential = svc.issue_remember(uid).await.unwrap();
1273 svc.revoke_remember(&credential.cookie).await.unwrap();
1274
1275 assert!(matches!(
1276 svc.consume_remember(&credential.cookie, &RequestContext::default())
1277 .await,
1278 Err(AuthError::SessionInvalid)
1279 ));
1280 }
1281
1282 #[tokio::test]
1283 async fn deactivating_blocks_sign_in_and_ends_the_sessions() {
1284 let (pool, _guard) = test_db().await;
1285 let admin = seed_user(&pool, "root", "hunter2", true).await;
1286 let target = seed_user(&pool, "editor", "hunter2", false).await;
1287 let svc = service(pool.clone());
1288
1289 let issued = svc
1290 .authenticate("editor", "hunter2", &RequestContext::default())
1291 .await
1292 .unwrap();
1293 let credential = svc.issue_remember(target).await.unwrap();
1294
1295 svc.set_user_active(admin, target, false).await.unwrap();
1296
1297 assert!(matches!(
1299 svc.authenticate("editor", "hunter2", &RequestContext::default())
1300 .await,
1301 Err(AuthError::InactiveAccount)
1302 ));
1303 assert!(matches!(
1306 svc.verify_session(&issued.token).await,
1307 Err(AuthError::SessionInvalid)
1308 ));
1309 assert!(matches!(
1310 svc.consume_remember(&credential.cookie, &RequestContext::default())
1311 .await,
1312 Err(AuthError::SessionInvalid)
1313 ));
1314 }
1315
1316 #[tokio::test]
1317 async fn reactivating_lets_them_back_in() {
1318 let (pool, _guard) = test_db().await;
1319 let admin = seed_user(&pool, "root", "hunter2", true).await;
1320 let target = seed_user(&pool, "editor", "hunter2", false).await;
1321 let svc = service(pool);
1322
1323 svc.set_user_active(admin, target, false).await.unwrap();
1324 svc.set_user_active(admin, target, true).await.unwrap();
1325
1326 svc.authenticate("editor", "hunter2", &RequestContext::default())
1328 .await
1329 .unwrap();
1330 }
1331
1332 #[tokio::test]
1333 async fn an_operator_cannot_deactivate_themselves() {
1334 let (pool, _guard) = test_db().await;
1335 let admin = seed_user(&pool, "root", "hunter2", true).await;
1336 let svc = service(pool);
1337
1338 assert!(matches!(
1341 svc.set_user_active(admin, admin, false).await,
1342 Err(AuthError::Refused(_))
1343 ));
1344 }
1345
1346 #[tokio::test]
1347 async fn the_last_superuser_cannot_be_deactivated() {
1348 let (pool, _guard) = test_db().await;
1349 let only = seed_user(&pool, "root", "hunter2", true).await;
1350 let editor = seed_user(&pool, "editor", "hunter2", false).await;
1351 let svc = service(pool.clone());
1352
1353 assert!(matches!(
1356 svc.set_user_active(editor, only, false).await,
1357 Err(AuthError::Refused(_))
1358 ));
1359
1360 let second = seed_user(&pool, "root2", "hunter2", true).await;
1362 svc.set_user_active(second, only, false).await.unwrap();
1363 }
1364
1365 #[tokio::test]
1366 async fn audit_entries_record_and_read_back_newest_first() {
1367 let (pool, _guard) = test_db().await;
1368 let actor = seed_user(&pool, "root", "hunter2", true).await;
1369 let svc = service(pool);
1370
1371 svc.record_audit(AuditEntry {
1372 actor_user_id: Some(actor),
1373 actor_username: "root",
1374 action: "backend.role.update",
1375 target_type: Some("backend_role"),
1376 target_id: Some("42"),
1377 detail: Some(r#"{"added":["a.b"]}"#),
1378 })
1379 .await
1380 .unwrap();
1381 svc.record_audit(AuditEntry {
1382 actor_user_id: Some(actor),
1383 actor_username: "root",
1384 action: "backend.plugin.disable",
1385 target_type: Some("plugin"),
1386 target_id: Some("acme.widgets"),
1387 detail: None,
1388 })
1389 .await
1390 .unwrap();
1391
1392 let entries = svc.recent_audit(10).await.unwrap();
1393 assert_eq!(entries.len(), 2);
1394 assert_eq!(entries[0].action, "backend.plugin.disable");
1396 assert_eq!(entries[0].target_id.as_deref(), Some("acme.widgets"));
1397 assert_eq!(entries[0].detail, None);
1398 assert_eq!(entries[1].action, "backend.role.update");
1399 assert_eq!(entries[1].actor_username, "root");
1400 assert_eq!(entries[1].actor_user_id, Some(actor));
1401 assert_eq!(entries[1].detail.as_deref(), Some(r#"{"added":["a.b"]}"#));
1402 }
1403
1404 #[tokio::test]
1405 async fn authenticate_issues_a_verifiable_session() {
1406 let (pool, _guard) = test_db().await;
1407 seed_user(&pool, "root", "hunter2", true).await;
1408 let svc = service(pool);
1409
1410 let session = svc
1411 .authenticate("root", "hunter2", &RequestContext::default())
1412 .await
1413 .expect("login should succeed");
1414 let identity = svc
1415 .verify_session(&session.token)
1416 .await
1417 .expect("session should resolve");
1418
1419 assert_eq!(identity.user.username, "root");
1420 assert_eq!(identity.user.full_name(), "Test Operator");
1421 assert!(identity.allows("anything.superuser.can.do"));
1422 }
1423
1424 #[tokio::test]
1425 async fn session_data_blob_round_trips() {
1426 let (pool, _guard) = test_db().await;
1427 seed_user(&pool, "root", "hunter2", true).await;
1428 let svc = service(pool);
1429
1430 let session = svc
1431 .authenticate("root", "hunter2", &RequestContext::default())
1432 .await
1433 .unwrap();
1434
1435 let resolved = svc.resolve_session(&session.token).await.unwrap();
1437 assert_eq!(resolved.data, None);
1438 assert_eq!(resolved.identity.user.username, "root");
1439
1440 svc.set_session_data(&session.token, r#"{"v":1,"csrf":"abc"}"#)
1442 .await
1443 .unwrap();
1444 let resolved = svc.resolve_session(&session.token).await.unwrap();
1445 assert_eq!(resolved.data.as_deref(), Some(r#"{"v":1,"csrf":"abc"}"#));
1446 }
1447
1448 #[tokio::test]
1449 async fn username_is_case_insensitive_across_backends() {
1450 let (pool, _guard) = test_db().await;
1454 let hash = password::hash_password("pw").unwrap();
1455 store::create_user(
1456 &pool,
1457 "Root",
1458 "Root@Example.test",
1459 "Case",
1460 None,
1461 &hash,
1462 true,
1463 )
1464 .await
1465 .unwrap();
1466 let svc = service(pool);
1467 let session = svc
1468 .authenticate("ROOT", "pw", &RequestContext::default())
1469 .await
1470 .expect("case-varied login should resolve to the same account");
1471 let identity = svc.verify_session(&session.token).await.unwrap();
1472 assert_eq!(identity.user.username, "root");
1473 assert_eq!(identity.user.email, "root@example.test");
1474 }
1475
1476 #[tokio::test]
1477 async fn full_name_falls_back_to_first_name_when_last_is_absent() {
1478 let (pool, _guard) = test_db().await;
1479 let hash = password::hash_password("pw").unwrap();
1480 store::create_user(
1481 &pool,
1482 "mono",
1483 "mono@example.test",
1484 "Prakash",
1485 None,
1486 &hash,
1487 true,
1488 )
1489 .await
1490 .unwrap();
1491 let svc = service(pool);
1492 let session = svc
1493 .authenticate("mono", "pw", &RequestContext::default())
1494 .await
1495 .unwrap();
1496 let identity = svc.verify_session(&session.token).await.unwrap();
1497 assert_eq!(identity.user.full_name(), "Prakash");
1498 }
1499
1500 #[tokio::test]
1501 async fn operator_timezone_round_trips_and_clears() {
1502 let (pool, _guard) = test_db().await;
1503 let id = seed_user(&pool, "tz", "pw", true).await;
1504
1505 let user = store::find_active_user_by_id(&pool, id)
1507 .await
1508 .unwrap()
1509 .unwrap();
1510 assert_eq!(user.timezone, None);
1511
1512 let svc = service(pool.clone());
1513 svc.set_user_timezone(id, Some("Asia/Kolkata"))
1514 .await
1515 .unwrap();
1516 let user = store::find_active_user_by_id(&pool, id)
1517 .await
1518 .unwrap()
1519 .unwrap();
1520 assert_eq!(user.timezone.as_deref(), Some("Asia/Kolkata"));
1521
1522 svc.set_user_timezone(id, None).await.unwrap();
1524 let user = store::find_active_user_by_id(&pool, id)
1525 .await
1526 .unwrap()
1527 .unwrap();
1528 assert_eq!(user.timezone, None);
1529 }
1530
1531 #[tokio::test]
1532 async fn operator_locale_round_trips_and_clears() {
1533 let (pool, _guard) = test_db().await;
1534 let id = seed_user(&pool, "loc", "pw", true).await;
1535
1536 let user = store::find_active_user_by_id(&pool, id)
1538 .await
1539 .unwrap()
1540 .unwrap();
1541 assert_eq!(user.locale, None);
1542
1543 let svc = service(pool.clone());
1544 svc.set_user_locale(id, Some("kn")).await.unwrap();
1545 let user = store::find_active_user_by_id(&pool, id)
1546 .await
1547 .unwrap()
1548 .unwrap();
1549 assert_eq!(user.locale.as_deref(), Some("kn"));
1550
1551 svc.set_user_locale(id, None).await.unwrap();
1553 let user = store::find_active_user_by_id(&pool, id)
1554 .await
1555 .unwrap()
1556 .unwrap();
1557 assert_eq!(user.locale, None);
1558 }
1559
1560 #[tokio::test]
1561 async fn has_any_operator_flips_after_the_first_account() {
1562 let (pool, _guard) = test_db().await;
1563 let svc = service(pool);
1564
1565 assert!(!svc.has_any_operator().await.unwrap());
1567
1568 svc.create_superuser(NewOperator {
1569 username: "first",
1570 email: "first@example.test",
1571 first_name: "First",
1572 last_name: None,
1573 password: "hunter2",
1574 timezone: Some("Asia/Kolkata"),
1575 })
1576 .await
1577 .unwrap();
1578
1579 assert!(svc.has_any_operator().await.unwrap());
1580
1581 let session = svc
1583 .authenticate("first", "hunter2", &RequestContext::default())
1584 .await
1585 .unwrap();
1586 let identity = svc.verify_session(&session.token).await.unwrap();
1587 assert!(identity.allows("anything.a.superuser.can.do"));
1588 assert_eq!(identity.user.timezone.as_deref(), Some("Asia/Kolkata"));
1589 }
1590
1591 #[tokio::test]
1592 async fn wrong_password_is_rejected() {
1593 let (pool, _guard) = test_db().await;
1594 seed_user(&pool, "root", "hunter2", false).await;
1595 let svc = service(pool);
1596
1597 let err = svc
1598 .authenticate("root", "wrong", &RequestContext::default())
1599 .await
1600 .unwrap_err();
1601 assert!(matches!(err, AuthError::InvalidCredentials));
1602 }
1603
1604 #[tokio::test]
1605 async fn unknown_user_is_rejected_without_distinction() {
1606 let (pool, _guard) = test_db().await;
1607 let svc = service(pool);
1608 let err = svc
1609 .authenticate("ghost", "whatever", &RequestContext::default())
1610 .await
1611 .unwrap_err();
1612 assert!(matches!(err, AuthError::InvalidCredentials));
1613 }
1614
1615 #[tokio::test]
1616 async fn lockout_trips_after_max_failures() {
1617 let (pool, _guard) = test_db().await;
1618 seed_user(&pool, "root", "hunter2", false).await;
1619 let svc = AuthService::new(
1620 pool,
1621 AuthConfig {
1622 max_failures: 3,
1623 ..AuthConfig::default()
1624 },
1625 );
1626 let ctx = RequestContext::default();
1627
1628 for _ in 0..3 {
1629 let err = svc.authenticate("root", "bad", &ctx).await.unwrap_err();
1630 assert!(matches!(err, AuthError::InvalidCredentials));
1631 }
1632 let err = svc.authenticate("root", "hunter2", &ctx).await.unwrap_err();
1634 assert!(matches!(err, AuthError::TooManyAttempts));
1635 }
1636
1637 #[tokio::test]
1638 async fn permissions_come_from_assigned_roles() {
1639 let (pool, _guard) = test_db().await;
1640 let user_id = seed_user(&pool, "mod", "pw", false).await;
1641 let role_id = store::create_role(
1642 &pool,
1643 "content_editor",
1644 "Content Editor",
1645 &["posts.*".to_string()],
1646 )
1647 .await
1648 .unwrap();
1649 store::assign_role(&pool, user_id, role_id).await.unwrap();
1650
1651 let svc = service(pool);
1652 let session = svc
1653 .authenticate("mod", "pw", &RequestContext::default())
1654 .await
1655 .unwrap();
1656 let identity = svc.verify_session(&session.token).await.unwrap();
1657
1658 assert!(identity.allows("posts.approve"));
1659 assert!(!identity.allows("users.edit"));
1660 identity.require("posts.edit").unwrap();
1661 assert!(identity.require("users.edit").is_err());
1662 }
1663
1664 #[tokio::test]
1665 async fn logout_invalidates_the_session() {
1666 let (pool, _guard) = test_db().await;
1667 seed_user(&pool, "root", "pw", true).await;
1668 let svc = service(pool);
1669
1670 let session = svc
1671 .authenticate("root", "pw", &RequestContext::default())
1672 .await
1673 .unwrap();
1674 svc.verify_session(&session.token).await.unwrap();
1675 svc.logout(&session.token).await.unwrap();
1676
1677 let err = svc.verify_session(&session.token).await.unwrap_err();
1678 assert!(matches!(err, AuthError::SessionInvalid));
1679 }
1680
1681 #[tokio::test]
1682 async fn inactive_account_is_refused_after_correct_password() {
1683 let (pool, _guard) = test_db().await;
1684 let user_id = seed_user(&pool, "root", "pw", false).await;
1685 let (sql, values) = laterite_core::query::build(
1687 pool.backend,
1688 sea_query::Query::update()
1689 .table(crate::schema::BackendUsers::Table)
1690 .value(crate::schema::BackendUsers::IsActive, false)
1691 .and_where(sea_query::Expr::col(crate::schema::BackendUsers::Id).eq(user_id))
1692 .to_owned(),
1693 );
1694 laterite_core::query::bind_values(sqlx::query(&sql), values)
1695 .execute(&pool.pool)
1696 .await
1697 .unwrap();
1698
1699 let svc = service(pool);
1700 let err = svc
1701 .authenticate("root", "pw", &RequestContext::default())
1702 .await
1703 .unwrap_err();
1704 assert!(matches!(err, AuthError::InactiveAccount));
1705 }
1706
1707 #[tokio::test]
1708 async fn reset_password_updates_the_hash() {
1709 let (pool, _guard) = test_db().await;
1710 seed_user(&pool, "root", "oldpw", true).await;
1711
1712 let new_hash = password::hash_password("newpw").unwrap();
1713 let affected = store::update_password_by_username(&pool, "root", &new_hash)
1714 .await
1715 .unwrap();
1716 assert_eq!(affected, 1);
1717
1718 let svc = service(pool);
1719 let ctx = RequestContext::default();
1720 assert!(matches!(
1721 svc.authenticate("root", "oldpw", &ctx).await.unwrap_err(),
1722 AuthError::InvalidCredentials
1723 ));
1724 svc.authenticate("root", "newpw", &ctx).await.unwrap();
1725 }
1726
1727 #[tokio::test]
1728 async fn reset_password_reports_unknown_user() {
1729 let (pool, _guard) = test_db().await;
1730 let hash = password::hash_password("x").unwrap();
1731 let affected = store::update_password_by_username(&pool, "ghost", &hash)
1732 .await
1733 .unwrap();
1734 assert_eq!(affected, 0);
1735 }
1736
1737 #[tokio::test]
1738 async fn list_users_returns_all_seeded() {
1739 let (pool, _guard) = test_db().await;
1740 seed_user(&pool, "alice", "pw", true).await;
1741 seed_user(&pool, "bob", "pw", false).await;
1742
1743 let users = store::list_backend_users(&pool).await.unwrap();
1744 assert_eq!(users.len(), 2);
1745 assert!(users
1746 .iter()
1747 .any(|u| u.username == "alice" && u.is_superuser));
1748 assert!(users.iter().any(|u| u.username == "bob" && !u.is_superuser));
1749 }
1750
1751 #[tokio::test]
1752 async fn unlock_clears_the_lockout() {
1753 let (pool, _guard) = test_db().await;
1754 seed_user(&pool, "root", "pw", false).await;
1755 let svc = AuthService::new(
1756 pool.clone(),
1757 AuthConfig {
1758 max_failures: 3,
1759 ..AuthConfig::default()
1760 },
1761 );
1762 let ctx = RequestContext::default();
1763
1764 for _ in 0..3 {
1765 let _ = svc.authenticate("root", "bad", &ctx).await;
1766 }
1767 assert!(matches!(
1768 svc.authenticate("root", "pw", &ctx).await.unwrap_err(),
1769 AuthError::TooManyAttempts
1770 ));
1771
1772 let cleared = store::clear_failed_attempts(&pool, "root").await.unwrap();
1773 assert!(cleared >= 3);
1774 svc.authenticate("root", "pw", &ctx).await.unwrap();
1775 }
1776}