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
107impl AuthenticatedUser {
108 pub fn allows(&self, permission: &str) -> bool {
110 self.permissions.allows(permission)
111 }
112
113 pub fn require(&self, permission: &str) -> Result<(), AuthError> {
115 if self.allows(permission) {
116 Ok(())
117 } else {
118 Err(AuthError::PermissionDenied(permission.to_string()))
119 }
120 }
121}
122
123#[derive(Debug, Clone)]
126pub struct NewOperator<'a> {
127 pub username: &'a str,
128 pub email: &'a str,
129 pub first_name: &'a str,
130 pub last_name: Option<&'a str>,
131 pub password: &'a str,
132 pub timezone: Option<&'a str>,
135}
136
137#[derive(Clone)]
139pub struct AuthService {
140 db: Db,
141 config: AuthConfig,
142}
143
144impl AuthService {
145 pub fn new(db: Db, config: AuthConfig) -> Self {
146 Self { db, config }
147 }
148
149 pub async fn authenticate(
154 &self,
155 username: &str,
156 password: &str,
157 ctx: &RequestContext,
158 ) -> Result<IssuedSession, AuthError> {
159 let now = Utc::now();
160 let since = now - chrono_from_std(self.config.failure_window);
161
162 if store::count_recent_failures(&self.db, username, since).await?
163 >= self.config.max_failures
164 {
165 self.log(None, username, AccessEvent::LockedOut, ctx)
166 .await?;
167 return Err(AuthError::TooManyAttempts);
168 }
169
170 let user = match store::find_user_by_username(&self.db, username).await? {
171 Some(user) => user,
172 None => {
173 self.log(None, username, AccessEvent::LoginFailure, ctx)
174 .await?;
175 return Err(AuthError::InvalidCredentials);
176 }
177 };
178
179 if !password::verify_password(password, &user.password_hash)? {
180 self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
181 .await?;
182 return Err(AuthError::InvalidCredentials);
183 }
184
185 if !user.is_active {
186 self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
187 .await?;
188 return Err(AuthError::InactiveAccount);
189 }
190
191 let token = generate_token();
192 let expires_at = now + chrono_from_std(self.config.session_ttl);
193 store::insert_session(&self.db, &hash_token(&token), user.id, expires_at).await?;
194 self.log(Some(user.id), username, AccessEvent::LoginSuccess, ctx)
195 .await?;
196
197 Ok(IssuedSession { token, expires_at })
198 }
199
200 pub async fn verify_session(&self, token: &str) -> Result<AuthenticatedUser, AuthError> {
204 let token_hash = hash_token(token);
205 let now = Utc::now();
206
207 let user_id = store::find_valid_session(&self.db, &token_hash, now)
208 .await?
209 .ok_or(AuthError::SessionInvalid)?;
210 let user = store::find_active_user_by_id(&self.db, user_id)
211 .await?
212 .ok_or(AuthError::SessionInvalid)?;
213 store::touch_session(&self.db, &token_hash, now).await?;
214
215 let grants = store::load_role_permissions(&self.db, user.id)
216 .await?
217 .into_iter()
218 .flatten();
219 let overrides = store::load_user_permission_overrides(&self.db, user.id).await?;
222 let (mut allow, mut deny) = (Vec::new(), Vec::new());
223 for (code, decision) in overrides {
224 match decision.signum() {
225 1 => allow.push(code),
226 -1 => deny.push(code),
227 _ => {}
228 }
229 }
230 let permissions = PermissionSet::with_overrides(user.is_superuser, grants, allow, deny);
231
232 Ok(AuthenticatedUser { user, permissions })
233 }
234
235 pub async fn logout(&self, token: &str) -> Result<(), AuthError> {
237 store::delete_session(&self.db, &hash_token(token)).await
238 }
239
240 pub async fn set_user_timezone(
244 &self,
245 user_id: i64,
246 timezone: Option<&str>,
247 ) -> Result<(), AuthError> {
248 store::set_user_timezone(&self.db, user_id, timezone).await
249 }
250
251 pub async fn user_permission_overrides(
253 &self,
254 user_id: i64,
255 ) -> Result<std::collections::HashMap<String, i64>, AuthError> {
256 store::load_user_permission_overrides(&self.db, user_id).await
257 }
258
259 pub async fn set_user_permissions(
262 &self,
263 user_id: i64,
264 overrides: &std::collections::HashMap<String, i64>,
265 ) -> Result<(), AuthError> {
266 store::set_user_permissions(&self.db, user_id, overrides).await
267 }
268
269 pub async fn has_any_operator(&self) -> Result<bool, AuthError> {
272 store::any_user_exists(&self.db).await
273 }
274
275 pub async fn create_superuser(&self, new: NewOperator<'_>) -> Result<i64, AuthError> {
279 let hash = password::hash_password(new.password)?;
280 let id = store::create_user(
281 &self.db,
282 new.username,
283 new.email,
284 new.first_name,
285 new.last_name,
286 &hash,
287 true,
288 )
289 .await?;
290 if new.timezone.is_some() {
291 store::set_user_timezone(&self.db, id, new.timezone).await?;
292 }
293 Ok(id)
294 }
295
296 async fn log(
297 &self,
298 user_id: Option<i64>,
299 username: &str,
300 event: AccessEvent,
301 ctx: &RequestContext,
302 ) -> Result<(), AuthError> {
303 store::insert_access_log(
304 &self.db,
305 user_id,
306 username,
307 event,
308 ctx.ip_address.as_deref(),
309 ctx.user_agent.as_deref(),
310 )
311 .await
312 }
313}
314
315fn chrono_from_std(d: Duration) -> chrono::Duration {
318 chrono::Duration::from_std(d).expect("auth policy duration out of range")
319}
320
321fn generate_token() -> String {
322 let mut bytes = [0u8; 32];
323 rand::rngs::OsRng.fill_bytes(&mut bytes);
324 to_hex(&bytes)
325}
326
327fn hash_token(token: &str) -> String {
328 let mut hasher = Sha256::new();
329 hasher.update(token.as_bytes());
330 to_hex(&hasher.finalize())
331}
332
333fn to_hex(bytes: &[u8]) -> String {
334 let mut out = String::with_capacity(bytes.len() * 2);
335 for byte in bytes {
336 let _ = write!(out, "{byte:02x}");
337 }
338 out
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 async fn seed_user(db: &Db, username: &str, password: &str, superuser: bool) -> i64 {
346 let hash = password::hash_password(password).unwrap();
347 store::create_user(
348 db,
349 username,
350 &format!("{username}@example.test"),
351 "Test",
352 Some("Operator"),
353 &hash,
354 superuser,
355 )
356 .await
357 .unwrap()
358 }
359
360 fn service(db: Db) -> AuthService {
361 AuthService::new(db, AuthConfig::default())
362 }
363
364 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
368 laterite_core::testing::connect_test(&[crate::migrations()]).await
369 }
370
371 #[tokio::test]
372 async fn authenticate_issues_a_verifiable_session() {
373 let (pool, _guard) = test_db().await;
374 seed_user(&pool, "root", "hunter2", true).await;
375 let svc = service(pool);
376
377 let session = svc
378 .authenticate("root", "hunter2", &RequestContext::default())
379 .await
380 .expect("login should succeed");
381 let identity = svc
382 .verify_session(&session.token)
383 .await
384 .expect("session should resolve");
385
386 assert_eq!(identity.user.username, "root");
387 assert_eq!(identity.user.full_name(), "Test Operator");
388 assert!(identity.allows("anything.superuser.can.do"));
389 }
390
391 #[tokio::test]
392 async fn username_is_case_insensitive_across_backends() {
393 let (pool, _guard) = test_db().await;
397 let hash = password::hash_password("pw").unwrap();
398 store::create_user(
399 &pool,
400 "Root",
401 "Root@Example.test",
402 "Case",
403 None,
404 &hash,
405 true,
406 )
407 .await
408 .unwrap();
409 let svc = service(pool);
410 let session = svc
411 .authenticate("ROOT", "pw", &RequestContext::default())
412 .await
413 .expect("case-varied login should resolve to the same account");
414 let identity = svc.verify_session(&session.token).await.unwrap();
415 assert_eq!(identity.user.username, "root");
416 assert_eq!(identity.user.email, "root@example.test");
417 }
418
419 #[tokio::test]
420 async fn full_name_falls_back_to_first_name_when_last_is_absent() {
421 let (pool, _guard) = test_db().await;
422 let hash = password::hash_password("pw").unwrap();
423 store::create_user(
424 &pool,
425 "mono",
426 "mono@example.test",
427 "Prakash",
428 None,
429 &hash,
430 true,
431 )
432 .await
433 .unwrap();
434 let svc = service(pool);
435 let session = svc
436 .authenticate("mono", "pw", &RequestContext::default())
437 .await
438 .unwrap();
439 let identity = svc.verify_session(&session.token).await.unwrap();
440 assert_eq!(identity.user.full_name(), "Prakash");
441 }
442
443 #[tokio::test]
444 async fn operator_timezone_round_trips_and_clears() {
445 let (pool, _guard) = test_db().await;
446 let id = seed_user(&pool, "tz", "pw", true).await;
447
448 let user = store::find_active_user_by_id(&pool, id)
450 .await
451 .unwrap()
452 .unwrap();
453 assert_eq!(user.timezone, None);
454
455 let svc = service(pool.clone());
456 svc.set_user_timezone(id, Some("Asia/Kolkata"))
457 .await
458 .unwrap();
459 let user = store::find_active_user_by_id(&pool, id)
460 .await
461 .unwrap()
462 .unwrap();
463 assert_eq!(user.timezone.as_deref(), Some("Asia/Kolkata"));
464
465 svc.set_user_timezone(id, None).await.unwrap();
467 let user = store::find_active_user_by_id(&pool, id)
468 .await
469 .unwrap()
470 .unwrap();
471 assert_eq!(user.timezone, None);
472 }
473
474 #[tokio::test]
475 async fn has_any_operator_flips_after_the_first_account() {
476 let (pool, _guard) = test_db().await;
477 let svc = service(pool);
478
479 assert!(!svc.has_any_operator().await.unwrap());
481
482 svc.create_superuser(NewOperator {
483 username: "first",
484 email: "first@example.test",
485 first_name: "First",
486 last_name: None,
487 password: "hunter2",
488 timezone: Some("Asia/Kolkata"),
489 })
490 .await
491 .unwrap();
492
493 assert!(svc.has_any_operator().await.unwrap());
494
495 let session = svc
497 .authenticate("first", "hunter2", &RequestContext::default())
498 .await
499 .unwrap();
500 let identity = svc.verify_session(&session.token).await.unwrap();
501 assert!(identity.allows("anything.a.superuser.can.do"));
502 assert_eq!(identity.user.timezone.as_deref(), Some("Asia/Kolkata"));
503 }
504
505 #[tokio::test]
506 async fn wrong_password_is_rejected() {
507 let (pool, _guard) = test_db().await;
508 seed_user(&pool, "root", "hunter2", false).await;
509 let svc = service(pool);
510
511 let err = svc
512 .authenticate("root", "wrong", &RequestContext::default())
513 .await
514 .unwrap_err();
515 assert!(matches!(err, AuthError::InvalidCredentials));
516 }
517
518 #[tokio::test]
519 async fn unknown_user_is_rejected_without_distinction() {
520 let (pool, _guard) = test_db().await;
521 let svc = service(pool);
522 let err = svc
523 .authenticate("ghost", "whatever", &RequestContext::default())
524 .await
525 .unwrap_err();
526 assert!(matches!(err, AuthError::InvalidCredentials));
527 }
528
529 #[tokio::test]
530 async fn lockout_trips_after_max_failures() {
531 let (pool, _guard) = test_db().await;
532 seed_user(&pool, "root", "hunter2", false).await;
533 let svc = AuthService::new(
534 pool,
535 AuthConfig {
536 max_failures: 3,
537 ..AuthConfig::default()
538 },
539 );
540 let ctx = RequestContext::default();
541
542 for _ in 0..3 {
543 let err = svc.authenticate("root", "bad", &ctx).await.unwrap_err();
544 assert!(matches!(err, AuthError::InvalidCredentials));
545 }
546 let err = svc.authenticate("root", "hunter2", &ctx).await.unwrap_err();
548 assert!(matches!(err, AuthError::TooManyAttempts));
549 }
550
551 #[tokio::test]
552 async fn permissions_come_from_assigned_roles() {
553 let (pool, _guard) = test_db().await;
554 let user_id = seed_user(&pool, "mod", "pw", false).await;
555 let role_id = store::create_role(
556 &pool,
557 "content_editor",
558 "Content Editor",
559 &["posts.*".to_string()],
560 )
561 .await
562 .unwrap();
563 store::assign_role(&pool, user_id, role_id).await.unwrap();
564
565 let svc = service(pool);
566 let session = svc
567 .authenticate("mod", "pw", &RequestContext::default())
568 .await
569 .unwrap();
570 let identity = svc.verify_session(&session.token).await.unwrap();
571
572 assert!(identity.allows("posts.approve"));
573 assert!(!identity.allows("users.edit"));
574 identity.require("posts.edit").unwrap();
575 assert!(identity.require("users.edit").is_err());
576 }
577
578 #[tokio::test]
579 async fn logout_invalidates_the_session() {
580 let (pool, _guard) = test_db().await;
581 seed_user(&pool, "root", "pw", true).await;
582 let svc = service(pool);
583
584 let session = svc
585 .authenticate("root", "pw", &RequestContext::default())
586 .await
587 .unwrap();
588 svc.verify_session(&session.token).await.unwrap();
589 svc.logout(&session.token).await.unwrap();
590
591 let err = svc.verify_session(&session.token).await.unwrap_err();
592 assert!(matches!(err, AuthError::SessionInvalid));
593 }
594
595 #[tokio::test]
596 async fn inactive_account_is_refused_after_correct_password() {
597 let (pool, _guard) = test_db().await;
598 let user_id = seed_user(&pool, "root", "pw", false).await;
599 let (sql, values) = laterite_core::query::build(
601 pool.backend,
602 sea_query::Query::update()
603 .table(crate::schema::BackendUsers::Table)
604 .value(crate::schema::BackendUsers::IsActive, false)
605 .and_where(sea_query::Expr::col(crate::schema::BackendUsers::Id).eq(user_id))
606 .to_owned(),
607 );
608 laterite_core::query::bind_values(sqlx::query(&sql), values)
609 .execute(&pool.pool)
610 .await
611 .unwrap();
612
613 let svc = service(pool);
614 let err = svc
615 .authenticate("root", "pw", &RequestContext::default())
616 .await
617 .unwrap_err();
618 assert!(matches!(err, AuthError::InactiveAccount));
619 }
620
621 #[tokio::test]
622 async fn reset_password_updates_the_hash() {
623 let (pool, _guard) = test_db().await;
624 seed_user(&pool, "root", "oldpw", true).await;
625
626 let new_hash = password::hash_password("newpw").unwrap();
627 let affected = store::update_password_by_username(&pool, "root", &new_hash)
628 .await
629 .unwrap();
630 assert_eq!(affected, 1);
631
632 let svc = service(pool);
633 let ctx = RequestContext::default();
634 assert!(matches!(
635 svc.authenticate("root", "oldpw", &ctx).await.unwrap_err(),
636 AuthError::InvalidCredentials
637 ));
638 svc.authenticate("root", "newpw", &ctx).await.unwrap();
639 }
640
641 #[tokio::test]
642 async fn reset_password_reports_unknown_user() {
643 let (pool, _guard) = test_db().await;
644 let hash = password::hash_password("x").unwrap();
645 let affected = store::update_password_by_username(&pool, "ghost", &hash)
646 .await
647 .unwrap();
648 assert_eq!(affected, 0);
649 }
650
651 #[tokio::test]
652 async fn list_users_returns_all_seeded() {
653 let (pool, _guard) = test_db().await;
654 seed_user(&pool, "alice", "pw", true).await;
655 seed_user(&pool, "bob", "pw", false).await;
656
657 let users = store::list_backend_users(&pool).await.unwrap();
658 assert_eq!(users.len(), 2);
659 assert!(users
660 .iter()
661 .any(|u| u.username == "alice" && u.is_superuser));
662 assert!(users.iter().any(|u| u.username == "bob" && !u.is_superuser));
663 }
664
665 #[tokio::test]
666 async fn unlock_clears_the_lockout() {
667 let (pool, _guard) = test_db().await;
668 seed_user(&pool, "root", "pw", false).await;
669 let svc = AuthService::new(
670 pool.clone(),
671 AuthConfig {
672 max_failures: 3,
673 ..AuthConfig::default()
674 },
675 );
676 let ctx = RequestContext::default();
677
678 for _ in 0..3 {
679 let _ = svc.authenticate("root", "bad", &ctx).await;
680 }
681 assert!(matches!(
682 svc.authenticate("root", "pw", &ctx).await.unwrap_err(),
683 AuthError::TooManyAttempts
684 ));
685
686 let cleared = store::clear_failed_attempts(&pool, "root").await.unwrap();
687 assert!(cleared >= 3);
688 svc.authenticate("root", "pw", &ctx).await.unwrap();
689 }
690}