1use std::fmt::Write as _;
8use std::time::Duration;
9
10use chrono::{DateTime, Utc};
11use rand::RngCore;
12use serde::Deserialize;
13use sha2::{Digest, Sha256};
14
15use laterite_core::Db;
16
17use crate::error::AuthError;
18use crate::models::{AccessEvent, BackendUser};
19use crate::password;
20use crate::permission::PermissionSet;
21use crate::store;
22
23#[derive(Debug, Clone, Deserialize)]
33#[serde(default)]
34pub struct AuthConfig {
35 #[serde(rename = "session_ttl_secs", deserialize_with = "de_secs")]
37 pub session_ttl: Duration,
38 pub max_failures: i64,
40 #[serde(rename = "failure_window_secs", deserialize_with = "de_secs")]
42 pub failure_window: Duration,
43}
44
45impl Default for AuthConfig {
46 fn default() -> Self {
47 Self {
48 session_ttl: Duration::from_secs(60 * 60 * 12),
49 max_failures: 5,
50 failure_window: Duration::from_secs(60 * 15),
51 }
52 }
53}
54
55fn de_secs<'de, D: serde::Deserializer<'de>>(de: D) -> Result<Duration, D::Error> {
57 Ok(Duration::from_secs(u64::deserialize(de)?))
58}
59
60#[cfg(test)]
61mod auth_config_tests {
62 use super::AuthConfig;
63 use std::time::Duration;
64
65 #[test]
66 fn unset_keys_keep_defaults() {
67 let cfg: AuthConfig = serde_json::from_str(r#"{"max_failures": 3}"#).unwrap();
68 assert_eq!(cfg.max_failures, 3);
69 assert_eq!(cfg.session_ttl, Duration::from_secs(60 * 60 * 12));
70 assert_eq!(cfg.failure_window, Duration::from_secs(60 * 15));
71 }
72
73 #[test]
74 fn seconds_map_to_durations() {
75 let cfg: AuthConfig = serde_json::from_str(
76 r#"{"session_ttl_secs": 3600, "max_failures": 7, "failure_window_secs": 120}"#,
77 )
78 .unwrap();
79 assert_eq!(cfg.session_ttl, Duration::from_secs(3600));
80 assert_eq!(cfg.max_failures, 7);
81 assert_eq!(cfg.failure_window, Duration::from_secs(120));
82 }
83}
84
85#[derive(Debug, Clone, Default)]
87pub struct RequestContext {
88 pub ip_address: Option<String>,
89 pub user_agent: Option<String>,
90}
91
92#[derive(Debug, Clone)]
95pub struct IssuedSession {
96 pub token: String,
97 pub expires_at: DateTime<Utc>,
98}
99
100#[derive(Debug, Clone)]
102pub struct AuthenticatedUser {
103 pub user: BackendUser,
104 pub permissions: PermissionSet,
105}
106
107#[derive(Debug, Clone)]
111pub struct ResolvedSession {
112 pub identity: AuthenticatedUser,
113 pub data: Option<String>,
114}
115
116impl AuthenticatedUser {
117 pub fn allows(&self, permission: &str) -> bool {
119 self.permissions.allows(permission)
120 }
121
122 pub fn require(&self, permission: &str) -> Result<(), AuthError> {
124 if self.allows(permission) {
125 Ok(())
126 } else {
127 Err(AuthError::PermissionDenied(permission.to_string()))
128 }
129 }
130}
131
132#[derive(Debug, Clone)]
135pub struct NewOperator<'a> {
136 pub username: &'a str,
137 pub email: &'a str,
138 pub first_name: &'a str,
139 pub last_name: Option<&'a str>,
140 pub password: &'a str,
141 pub timezone: Option<&'a str>,
144}
145
146pub struct AuditEntry<'a> {
150 pub actor_user_id: Option<i64>,
152 pub actor_username: &'a str,
154 pub action: &'a str,
156 pub target_type: Option<&'a str>,
158 pub target_id: Option<&'a str>,
159 pub detail: Option<&'a str>,
161}
162
163#[derive(Clone)]
165pub struct AuthService {
166 db: Db,
167 config: AuthConfig,
168}
169
170impl AuthService {
171 pub fn new(db: Db, config: AuthConfig) -> Self {
172 Self { db, config }
173 }
174
175 pub async fn authenticate(
180 &self,
181 username: &str,
182 password: &str,
183 ctx: &RequestContext,
184 ) -> Result<IssuedSession, AuthError> {
185 let now = Utc::now();
186 let since = now - chrono_from_std(self.config.failure_window);
187
188 if store::count_recent_failures(&self.db, username, since).await?
189 >= self.config.max_failures
190 {
191 self.log(None, username, AccessEvent::LockedOut, ctx)
192 .await?;
193 return Err(AuthError::TooManyAttempts);
194 }
195
196 let user = match store::find_user_by_username(&self.db, username).await? {
197 Some(user) => user,
198 None => {
199 self.log(None, username, AccessEvent::LoginFailure, ctx)
200 .await?;
201 return Err(AuthError::InvalidCredentials);
202 }
203 };
204
205 if !password::verify_password(password, &user.password_hash)? {
206 self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
207 .await?;
208 return Err(AuthError::InvalidCredentials);
209 }
210
211 if !user.is_active {
212 self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
213 .await?;
214 return Err(AuthError::InactiveAccount);
215 }
216
217 let token = generate_token();
218 let expires_at = now + chrono_from_std(self.config.session_ttl);
219 store::insert_session(&self.db, &hash_token(&token), user.id, expires_at).await?;
220 self.log(Some(user.id), username, AccessEvent::LoginSuccess, ctx)
221 .await?;
222
223 Ok(IssuedSession { token, expires_at })
224 }
225
226 pub async fn resolve_session(&self, token: &str) -> Result<ResolvedSession, AuthError> {
231 let token_hash = hash_token(token);
232 let now = Utc::now();
233
234 let session = store::find_valid_session(&self.db, &token_hash, now)
235 .await?
236 .ok_or(AuthError::SessionInvalid)?;
237 let user = store::find_active_user_by_id(&self.db, session.user_id)
238 .await?
239 .ok_or(AuthError::SessionInvalid)?;
240 store::touch_session(&self.db, &token_hash, now).await?;
241
242 let grants = store::load_role_permissions(&self.db, user.id)
243 .await?
244 .into_iter()
245 .flatten();
246 let overrides = store::load_user_permission_overrides(&self.db, user.id).await?;
249 let (mut allow, mut deny) = (Vec::new(), Vec::new());
250 for (code, decision) in overrides {
251 match decision.signum() {
252 1 => allow.push(code),
253 -1 => deny.push(code),
254 _ => {}
255 }
256 }
257 let permissions = PermissionSet::with_overrides(user.is_superuser, grants, allow, deny);
258
259 Ok(ResolvedSession {
260 identity: AuthenticatedUser { user, permissions },
261 data: session.data,
262 })
263 }
264
265 pub async fn verify_session(&self, token: &str) -> Result<AuthenticatedUser, AuthError> {
268 Ok(self.resolve_session(token).await?.identity)
269 }
270
271 pub async fn set_session_data(&self, token: &str, data: &str) -> Result<(), AuthError> {
275 store::set_session_data(&self.db, &hash_token(token), data).await
276 }
277
278 pub async fn logout(&self, token: &str) -> Result<(), AuthError> {
280 store::delete_session(&self.db, &hash_token(token)).await
281 }
282
283 pub async fn set_user_timezone(
287 &self,
288 user_id: i64,
289 timezone: Option<&str>,
290 ) -> Result<(), AuthError> {
291 store::set_user_timezone(&self.db, user_id, timezone).await
292 }
293
294 pub async fn set_user_locale(
298 &self,
299 user_id: i64,
300 locale: Option<&str>,
301 ) -> Result<(), AuthError> {
302 store::set_user_locale(&self.db, user_id, locale).await
303 }
304
305 pub async fn user_permission_overrides(
307 &self,
308 user_id: i64,
309 ) -> Result<std::collections::HashMap<String, i64>, AuthError> {
310 store::load_user_permission_overrides(&self.db, user_id).await
311 }
312
313 pub async fn set_user_permissions(
316 &self,
317 user_id: i64,
318 overrides: &std::collections::HashMap<String, i64>,
319 ) -> Result<(), AuthError> {
320 store::set_user_permissions(&self.db, user_id, overrides).await
321 }
322
323 pub async fn record_audit(&self, entry: AuditEntry<'_>) -> Result<(), AuthError> {
327 store::insert_audit_log(
328 &self.db,
329 entry.actor_user_id,
330 entry.actor_username,
331 entry.action,
332 entry.target_type,
333 entry.target_id,
334 entry.detail,
335 )
336 .await
337 }
338
339 pub async fn recent_audit(&self, limit: u64) -> Result<Vec<store::AuditRecord>, AuthError> {
341 store::recent_audit(&self.db, limit).await
342 }
343
344 pub async fn has_any_operator(&self) -> Result<bool, AuthError> {
347 store::any_user_exists(&self.db).await
348 }
349
350 pub async fn create_superuser(&self, new: NewOperator<'_>) -> Result<i64, AuthError> {
354 let hash = password::hash_password(new.password)?;
355 let id = store::create_user(
356 &self.db,
357 new.username,
358 new.email,
359 new.first_name,
360 new.last_name,
361 &hash,
362 true,
363 )
364 .await?;
365 if new.timezone.is_some() {
366 store::set_user_timezone(&self.db, id, new.timezone).await?;
367 }
368 Ok(id)
369 }
370
371 async fn log(
372 &self,
373 user_id: Option<i64>,
374 username: &str,
375 event: AccessEvent,
376 ctx: &RequestContext,
377 ) -> Result<(), AuthError> {
378 store::insert_access_log(
379 &self.db,
380 user_id,
381 username,
382 event,
383 ctx.ip_address.as_deref(),
384 ctx.user_agent.as_deref(),
385 )
386 .await
387 }
388}
389
390fn chrono_from_std(d: Duration) -> chrono::Duration {
393 chrono::Duration::from_std(d).expect("auth policy duration out of range")
394}
395
396fn generate_token() -> String {
397 let mut bytes = [0u8; 32];
398 rand::rngs::OsRng.fill_bytes(&mut bytes);
399 to_hex(&bytes)
400}
401
402fn hash_token(token: &str) -> String {
403 let mut hasher = Sha256::new();
404 hasher.update(token.as_bytes());
405 to_hex(&hasher.finalize())
406}
407
408fn to_hex(bytes: &[u8]) -> String {
409 let mut out = String::with_capacity(bytes.len() * 2);
410 for byte in bytes {
411 let _ = write!(out, "{byte:02x}");
412 }
413 out
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 async fn seed_user(db: &Db, username: &str, password: &str, superuser: bool) -> i64 {
421 let hash = password::hash_password(password).unwrap();
422 store::create_user(
423 db,
424 username,
425 &format!("{username}@example.test"),
426 "Test",
427 Some("Operator"),
428 &hash,
429 superuser,
430 )
431 .await
432 .unwrap()
433 }
434
435 fn service(db: Db) -> AuthService {
436 AuthService::new(db, AuthConfig::default())
437 }
438
439 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
443 laterite_core::testing::connect_test(&[crate::migrations()]).await
444 }
445
446 #[tokio::test]
447 async fn audit_entries_record_and_read_back_newest_first() {
448 let (pool, _guard) = test_db().await;
449 let actor = seed_user(&pool, "root", "hunter2", true).await;
450 let svc = service(pool);
451
452 svc.record_audit(AuditEntry {
453 actor_user_id: Some(actor),
454 actor_username: "root",
455 action: "backend.role.update",
456 target_type: Some("backend_role"),
457 target_id: Some("42"),
458 detail: Some(r#"{"added":["a.b"]}"#),
459 })
460 .await
461 .unwrap();
462 svc.record_audit(AuditEntry {
463 actor_user_id: Some(actor),
464 actor_username: "root",
465 action: "backend.plugin.disable",
466 target_type: Some("plugin"),
467 target_id: Some("acme.widgets"),
468 detail: None,
469 })
470 .await
471 .unwrap();
472
473 let entries = svc.recent_audit(10).await.unwrap();
474 assert_eq!(entries.len(), 2);
475 assert_eq!(entries[0].action, "backend.plugin.disable");
477 assert_eq!(entries[0].target_id.as_deref(), Some("acme.widgets"));
478 assert_eq!(entries[0].detail, None);
479 assert_eq!(entries[1].action, "backend.role.update");
480 assert_eq!(entries[1].actor_username, "root");
481 assert_eq!(entries[1].actor_user_id, Some(actor));
482 assert_eq!(entries[1].detail.as_deref(), Some(r#"{"added":["a.b"]}"#));
483 }
484
485 #[tokio::test]
486 async fn authenticate_issues_a_verifiable_session() {
487 let (pool, _guard) = test_db().await;
488 seed_user(&pool, "root", "hunter2", true).await;
489 let svc = service(pool);
490
491 let session = svc
492 .authenticate("root", "hunter2", &RequestContext::default())
493 .await
494 .expect("login should succeed");
495 let identity = svc
496 .verify_session(&session.token)
497 .await
498 .expect("session should resolve");
499
500 assert_eq!(identity.user.username, "root");
501 assert_eq!(identity.user.full_name(), "Test Operator");
502 assert!(identity.allows("anything.superuser.can.do"));
503 }
504
505 #[tokio::test]
506 async fn session_data_blob_round_trips() {
507 let (pool, _guard) = test_db().await;
508 seed_user(&pool, "root", "hunter2", true).await;
509 let svc = service(pool);
510
511 let session = svc
512 .authenticate("root", "hunter2", &RequestContext::default())
513 .await
514 .unwrap();
515
516 let resolved = svc.resolve_session(&session.token).await.unwrap();
518 assert_eq!(resolved.data, None);
519 assert_eq!(resolved.identity.user.username, "root");
520
521 svc.set_session_data(&session.token, r#"{"v":1,"csrf":"abc"}"#)
523 .await
524 .unwrap();
525 let resolved = svc.resolve_session(&session.token).await.unwrap();
526 assert_eq!(resolved.data.as_deref(), Some(r#"{"v":1,"csrf":"abc"}"#));
527 }
528
529 #[tokio::test]
530 async fn username_is_case_insensitive_across_backends() {
531 let (pool, _guard) = test_db().await;
535 let hash = password::hash_password("pw").unwrap();
536 store::create_user(
537 &pool,
538 "Root",
539 "Root@Example.test",
540 "Case",
541 None,
542 &hash,
543 true,
544 )
545 .await
546 .unwrap();
547 let svc = service(pool);
548 let session = svc
549 .authenticate("ROOT", "pw", &RequestContext::default())
550 .await
551 .expect("case-varied login should resolve to the same account");
552 let identity = svc.verify_session(&session.token).await.unwrap();
553 assert_eq!(identity.user.username, "root");
554 assert_eq!(identity.user.email, "root@example.test");
555 }
556
557 #[tokio::test]
558 async fn full_name_falls_back_to_first_name_when_last_is_absent() {
559 let (pool, _guard) = test_db().await;
560 let hash = password::hash_password("pw").unwrap();
561 store::create_user(
562 &pool,
563 "mono",
564 "mono@example.test",
565 "Prakash",
566 None,
567 &hash,
568 true,
569 )
570 .await
571 .unwrap();
572 let svc = service(pool);
573 let session = svc
574 .authenticate("mono", "pw", &RequestContext::default())
575 .await
576 .unwrap();
577 let identity = svc.verify_session(&session.token).await.unwrap();
578 assert_eq!(identity.user.full_name(), "Prakash");
579 }
580
581 #[tokio::test]
582 async fn operator_timezone_round_trips_and_clears() {
583 let (pool, _guard) = test_db().await;
584 let id = seed_user(&pool, "tz", "pw", true).await;
585
586 let user = store::find_active_user_by_id(&pool, id)
588 .await
589 .unwrap()
590 .unwrap();
591 assert_eq!(user.timezone, None);
592
593 let svc = service(pool.clone());
594 svc.set_user_timezone(id, Some("Asia/Kolkata"))
595 .await
596 .unwrap();
597 let user = store::find_active_user_by_id(&pool, id)
598 .await
599 .unwrap()
600 .unwrap();
601 assert_eq!(user.timezone.as_deref(), Some("Asia/Kolkata"));
602
603 svc.set_user_timezone(id, None).await.unwrap();
605 let user = store::find_active_user_by_id(&pool, id)
606 .await
607 .unwrap()
608 .unwrap();
609 assert_eq!(user.timezone, None);
610 }
611
612 #[tokio::test]
613 async fn operator_locale_round_trips_and_clears() {
614 let (pool, _guard) = test_db().await;
615 let id = seed_user(&pool, "loc", "pw", true).await;
616
617 let user = store::find_active_user_by_id(&pool, id)
619 .await
620 .unwrap()
621 .unwrap();
622 assert_eq!(user.locale, None);
623
624 let svc = service(pool.clone());
625 svc.set_user_locale(id, Some("kn")).await.unwrap();
626 let user = store::find_active_user_by_id(&pool, id)
627 .await
628 .unwrap()
629 .unwrap();
630 assert_eq!(user.locale.as_deref(), Some("kn"));
631
632 svc.set_user_locale(id, None).await.unwrap();
634 let user = store::find_active_user_by_id(&pool, id)
635 .await
636 .unwrap()
637 .unwrap();
638 assert_eq!(user.locale, None);
639 }
640
641 #[tokio::test]
642 async fn has_any_operator_flips_after_the_first_account() {
643 let (pool, _guard) = test_db().await;
644 let svc = service(pool);
645
646 assert!(!svc.has_any_operator().await.unwrap());
648
649 svc.create_superuser(NewOperator {
650 username: "first",
651 email: "first@example.test",
652 first_name: "First",
653 last_name: None,
654 password: "hunter2",
655 timezone: Some("Asia/Kolkata"),
656 })
657 .await
658 .unwrap();
659
660 assert!(svc.has_any_operator().await.unwrap());
661
662 let session = svc
664 .authenticate("first", "hunter2", &RequestContext::default())
665 .await
666 .unwrap();
667 let identity = svc.verify_session(&session.token).await.unwrap();
668 assert!(identity.allows("anything.a.superuser.can.do"));
669 assert_eq!(identity.user.timezone.as_deref(), Some("Asia/Kolkata"));
670 }
671
672 #[tokio::test]
673 async fn wrong_password_is_rejected() {
674 let (pool, _guard) = test_db().await;
675 seed_user(&pool, "root", "hunter2", false).await;
676 let svc = service(pool);
677
678 let err = svc
679 .authenticate("root", "wrong", &RequestContext::default())
680 .await
681 .unwrap_err();
682 assert!(matches!(err, AuthError::InvalidCredentials));
683 }
684
685 #[tokio::test]
686 async fn unknown_user_is_rejected_without_distinction() {
687 let (pool, _guard) = test_db().await;
688 let svc = service(pool);
689 let err = svc
690 .authenticate("ghost", "whatever", &RequestContext::default())
691 .await
692 .unwrap_err();
693 assert!(matches!(err, AuthError::InvalidCredentials));
694 }
695
696 #[tokio::test]
697 async fn lockout_trips_after_max_failures() {
698 let (pool, _guard) = test_db().await;
699 seed_user(&pool, "root", "hunter2", false).await;
700 let svc = AuthService::new(
701 pool,
702 AuthConfig {
703 max_failures: 3,
704 ..AuthConfig::default()
705 },
706 );
707 let ctx = RequestContext::default();
708
709 for _ in 0..3 {
710 let err = svc.authenticate("root", "bad", &ctx).await.unwrap_err();
711 assert!(matches!(err, AuthError::InvalidCredentials));
712 }
713 let err = svc.authenticate("root", "hunter2", &ctx).await.unwrap_err();
715 assert!(matches!(err, AuthError::TooManyAttempts));
716 }
717
718 #[tokio::test]
719 async fn permissions_come_from_assigned_roles() {
720 let (pool, _guard) = test_db().await;
721 let user_id = seed_user(&pool, "mod", "pw", false).await;
722 let role_id = store::create_role(
723 &pool,
724 "content_editor",
725 "Content Editor",
726 &["posts.*".to_string()],
727 )
728 .await
729 .unwrap();
730 store::assign_role(&pool, user_id, role_id).await.unwrap();
731
732 let svc = service(pool);
733 let session = svc
734 .authenticate("mod", "pw", &RequestContext::default())
735 .await
736 .unwrap();
737 let identity = svc.verify_session(&session.token).await.unwrap();
738
739 assert!(identity.allows("posts.approve"));
740 assert!(!identity.allows("users.edit"));
741 identity.require("posts.edit").unwrap();
742 assert!(identity.require("users.edit").is_err());
743 }
744
745 #[tokio::test]
746 async fn logout_invalidates_the_session() {
747 let (pool, _guard) = test_db().await;
748 seed_user(&pool, "root", "pw", true).await;
749 let svc = service(pool);
750
751 let session = svc
752 .authenticate("root", "pw", &RequestContext::default())
753 .await
754 .unwrap();
755 svc.verify_session(&session.token).await.unwrap();
756 svc.logout(&session.token).await.unwrap();
757
758 let err = svc.verify_session(&session.token).await.unwrap_err();
759 assert!(matches!(err, AuthError::SessionInvalid));
760 }
761
762 #[tokio::test]
763 async fn inactive_account_is_refused_after_correct_password() {
764 let (pool, _guard) = test_db().await;
765 let user_id = seed_user(&pool, "root", "pw", false).await;
766 let (sql, values) = laterite_core::query::build(
768 pool.backend,
769 sea_query::Query::update()
770 .table(crate::schema::BackendUsers::Table)
771 .value(crate::schema::BackendUsers::IsActive, false)
772 .and_where(sea_query::Expr::col(crate::schema::BackendUsers::Id).eq(user_id))
773 .to_owned(),
774 );
775 laterite_core::query::bind_values(sqlx::query(&sql), values)
776 .execute(&pool.pool)
777 .await
778 .unwrap();
779
780 let svc = service(pool);
781 let err = svc
782 .authenticate("root", "pw", &RequestContext::default())
783 .await
784 .unwrap_err();
785 assert!(matches!(err, AuthError::InactiveAccount));
786 }
787
788 #[tokio::test]
789 async fn reset_password_updates_the_hash() {
790 let (pool, _guard) = test_db().await;
791 seed_user(&pool, "root", "oldpw", true).await;
792
793 let new_hash = password::hash_password("newpw").unwrap();
794 let affected = store::update_password_by_username(&pool, "root", &new_hash)
795 .await
796 .unwrap();
797 assert_eq!(affected, 1);
798
799 let svc = service(pool);
800 let ctx = RequestContext::default();
801 assert!(matches!(
802 svc.authenticate("root", "oldpw", &ctx).await.unwrap_err(),
803 AuthError::InvalidCredentials
804 ));
805 svc.authenticate("root", "newpw", &ctx).await.unwrap();
806 }
807
808 #[tokio::test]
809 async fn reset_password_reports_unknown_user() {
810 let (pool, _guard) = test_db().await;
811 let hash = password::hash_password("x").unwrap();
812 let affected = store::update_password_by_username(&pool, "ghost", &hash)
813 .await
814 .unwrap();
815 assert_eq!(affected, 0);
816 }
817
818 #[tokio::test]
819 async fn list_users_returns_all_seeded() {
820 let (pool, _guard) = test_db().await;
821 seed_user(&pool, "alice", "pw", true).await;
822 seed_user(&pool, "bob", "pw", false).await;
823
824 let users = store::list_backend_users(&pool).await.unwrap();
825 assert_eq!(users.len(), 2);
826 assert!(users
827 .iter()
828 .any(|u| u.username == "alice" && u.is_superuser));
829 assert!(users.iter().any(|u| u.username == "bob" && !u.is_superuser));
830 }
831
832 #[tokio::test]
833 async fn unlock_clears_the_lockout() {
834 let (pool, _guard) = test_db().await;
835 seed_user(&pool, "root", "pw", false).await;
836 let svc = AuthService::new(
837 pool.clone(),
838 AuthConfig {
839 max_failures: 3,
840 ..AuthConfig::default()
841 },
842 );
843 let ctx = RequestContext::default();
844
845 for _ in 0..3 {
846 let _ = svc.authenticate("root", "bad", &ctx).await;
847 }
848 assert!(matches!(
849 svc.authenticate("root", "pw", &ctx).await.unwrap_err(),
850 AuthError::TooManyAttempts
851 ));
852
853 let cleared = store::clear_failed_attempts(&pool, "root").await.unwrap();
854 assert!(cleared >= 3);
855 svc.authenticate("root", "pw", &ctx).await.unwrap();
856 }
857}