1use std::time::Duration;
9use uuid::Uuid;
10
11use crate::sql::Row;
12use serde_json::Value;
13use tracing::{debug, info};
14
15use crate::db::Database;
16use crate::nonce::{fingerprint, now_secs};
17use acme_proxy_core::datetime::rfc3339;
18
19#[derive(Debug, Clone)]
47pub struct AdminSession {
48 pub token_hash: String,
50 pub user_id: Uuid,
51 pub csrf_token: String,
53 pub state: String,
55 pub mfa_attempts: i64,
58 pub created_at: i64,
59 pub expires_at: i64,
61 pub last_seen_at: i64,
63 pub created_ip: Option<String>,
65 pub user_agent: Option<String>,
66}
67
68#[derive(Debug, Clone)]
79pub struct NewSession<'a> {
80 pub user_id: Uuid,
81 pub token_hash: &'a str,
84 pub csrf_token: &'a str,
85 pub created_ip: Option<String>,
87 pub user_agent: Option<String>,
88}
89
90macro_rules! columns {
96 () => {
97 "token_hash, user_id, csrf_token, state, mfa_attempts, created_at, \
98 expires_at, last_seen_at, created_ip, user_agent"
99 };
100}
101
102impl AdminSession {
103 fn from_row(row: Row) -> Result<Self, sqlx::Error> {
104 Ok(AdminSession {
105 token_hash: row.try_get("token_hash")?,
106 user_id: row.try_get("user_id")?,
107 csrf_token: row.try_get("csrf_token")?,
108 state: row.try_get("state")?,
109 mfa_attempts: row.try_get("mfa_attempts")?,
110 created_at: row.try_get("created_at")?,
111 expires_at: row.try_get("expires_at")?,
112 last_seen_at: row.try_get("last_seen_at")?,
113 created_ip: row.try_get("created_ip")?,
114 user_agent: row.try_get("user_agent")?,
115 })
116 }
117
118 pub async fn create(
120 new: NewSession<'_>,
121 ttl: Duration,
122 database: &Database,
123 ) -> Result<AdminSession, sqlx::Error> {
124 Self::create_with_state("active", new, ttl, database).await
125 }
126
127 pub async fn create_pending(
139 new: NewSession<'_>,
140 ttl: Duration,
141 database: &Database,
142 ) -> Result<AdminSession, sqlx::Error> {
143 Self::create_with_state("pending_mfa", new, ttl, database).await
144 }
145
146 async fn create_with_state(
150 state: &str,
151 new: NewSession<'_>,
152 ttl: Duration,
153 database: &Database,
154 ) -> Result<AdminSession, sqlx::Error> {
155 let now = now_secs();
156 let session = AdminSession {
157 token_hash: new.token_hash.to_string(),
158 user_id: new.user_id,
159 csrf_token: new.csrf_token.to_string(),
160 state: state.to_string(),
161 mfa_attempts: 0,
162 created_at: now,
163 expires_at: now.saturating_add(ttl.as_secs() as i64),
166 last_seen_at: now,
167 created_ip: new.created_ip,
168 user_agent: new.user_agent,
169 };
170
171 crate::sql::query(
172 "INSERT INTO admin_sessions (token_hash, user_id, csrf_token, state, created_at, \
173 expires_at, last_seen_at, created_ip, user_agent) \
174 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);",
175 )
176 .bind(&session.token_hash)
177 .bind(session.user_id)
178 .bind(&session.csrf_token)
179 .bind(&session.state)
180 .bind(session.created_at)
181 .bind(session.expires_at)
182 .bind(session.last_seen_at)
183 .bind(&session.created_ip)
184 .bind(&session.user_agent)
185 .execute(database)
186 .await?;
187
188 info!(event = "db_admin_session_created",
192 outcome = "success",
193 session_fp = %fingerprint(&session.token_hash),
194 user_id = %session.user_id,
195 state = %session.state);
196 Ok(session)
197 }
198
199 pub async fn promote(
218 pending_token_hash: &str,
219 new_token_hash: &str,
220 new_csrf_token: &str,
221 ttl: Duration,
222 database: &Database,
223 ) -> Result<Option<AdminSession>, sqlx::Error> {
224 let mut tx = database.transaction().await?;
225
226 let row = crate::sql::query(concat!(
227 "SELECT ",
228 columns!(),
229 " FROM admin_sessions WHERE token_hash = ? AND state = 'pending_mfa';"
230 ))
231 .bind(pending_token_hash)
232 .fetch_optional(tx.conn())
233 .await?;
234
235 let Some(pending) = row.map(AdminSession::from_row).transpose()? else {
236 return Ok(None);
237 };
238
239 let removed = crate::sql::query("DELETE FROM admin_sessions WHERE token_hash = ?;")
242 .bind(pending_token_hash)
243 .execute(tx.conn())
244 .await?;
245 if removed.rows_affected() != 1 {
246 return Ok(None);
247 }
248
249 let now = now_secs();
250 let session = AdminSession {
251 token_hash: new_token_hash.to_string(),
252 user_id: pending.user_id,
253 csrf_token: new_csrf_token.to_string(),
254 state: "active".to_string(),
255 mfa_attempts: 0,
259 created_at: now,
260 expires_at: now.saturating_add(ttl.as_secs() as i64),
261 last_seen_at: now,
262 created_ip: pending.created_ip,
263 user_agent: pending.user_agent,
264 };
265
266 crate::sql::query(
267 "INSERT INTO admin_sessions (token_hash, user_id, csrf_token, state, created_at, \
268 expires_at, last_seen_at, created_ip, user_agent) \
269 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);",
270 )
271 .bind(&session.token_hash)
272 .bind(session.user_id)
273 .bind(&session.csrf_token)
274 .bind(&session.state)
275 .bind(session.created_at)
276 .bind(session.expires_at)
277 .bind(session.last_seen_at)
278 .bind(&session.created_ip)
279 .bind(&session.user_agent)
280 .execute(tx.conn())
281 .await?;
282
283 tx.commit().await?;
284
285 info!(event = "db_admin_session_promoted",
286 outcome = "success",
287 session_fp = %fingerprint(&session.token_hash),
288 replaced = %fingerprint(pending_token_hash),
289 user_id = %session.user_id);
290 Ok(Some(session))
291 }
292
293 pub async fn record_mfa_failure(
310 token_hash: &str,
311 database: &Database,
312 ) -> Result<Option<i64>, sqlx::Error> {
313 let row = crate::sql::query(
314 "UPDATE admin_sessions SET mfa_attempts = mfa_attempts + 1 \
315 WHERE token_hash = ? RETURNING mfa_attempts;",
316 )
317 .bind(token_hash)
318 .fetch_optional(database)
319 .await?;
320
321 let attempts = row
322 .map(|row| row.try_get::<i64>("mfa_attempts"))
323 .transpose()?;
324 if let Some(attempts) = attempts {
325 debug!(event = "db_admin_session_mfa_failure_recorded",
326 outcome = "success",
327 session_fp = %fingerprint(token_hash),
328 attempts);
329 }
330 Ok(attempts)
331 }
332
333 pub async fn find_by_token_hash(
337 token_hash: &str,
338 database: &Database,
339 ) -> Result<Option<AdminSession>, sqlx::Error> {
340 let row = crate::sql::query(concat!(
341 "SELECT ",
342 columns!(),
343 " FROM admin_sessions WHERE token_hash = ?;"
344 ))
345 .bind(token_hash)
346 .fetch_optional(database)
347 .await?;
348
349 row.map(AdminSession::from_row).transpose()
350 }
351
352 pub async fn find_by_user_and_fingerprint(
365 user_id: Uuid,
366 fingerprint_id: &str,
367 database: &Database,
368 ) -> Result<Option<AdminSession>, sqlx::Error> {
369 let sessions = Self::list_all(Some(user_id), database).await?;
370 Ok(sessions
371 .into_iter()
372 .find(|session| fingerprint(&session.token_hash) == fingerprint_id))
373 }
374
375 pub async fn touch(&mut self, database: &Database) -> Result<(), sqlx::Error> {
379 let now = now_secs();
380 crate::sql::query("UPDATE admin_sessions SET last_seen_at = ? WHERE token_hash = ?;")
381 .bind(now)
382 .bind(&self.token_hash)
383 .execute(database)
384 .await?;
385
386 self.last_seen_at = now;
387 Ok(())
388 }
389
390 pub async fn delete(token_hash: &str, database: &Database) -> Result<bool, sqlx::Error> {
392 let result = crate::sql::query("DELETE FROM admin_sessions WHERE token_hash = ?;")
393 .bind(token_hash)
394 .execute(database)
395 .await?;
396
397 let deleted = result.rows_affected() > 0;
398 if deleted {
399 info!(event = "db_admin_session_deleted", outcome = "success", session_fp = %fingerprint(token_hash));
400 }
401 Ok(deleted)
402 }
403
404 pub async fn delete_for_user(user_id: Uuid, database: &Database) -> Result<u64, sqlx::Error> {
407 let result = crate::sql::query("DELETE FROM admin_sessions WHERE user_id = ?;")
408 .bind(user_id)
409 .execute(database)
410 .await?;
411
412 info!(event = "db_admin_sessions_revoked",
413 outcome = "success",
414 scope = "user",
415 user_id = %user_id,
416 rows_removed = result.rows_affected());
417 Ok(result.rows_affected())
418 }
419
420 pub async fn delete_for_user_except(
424 user_id: Uuid,
425 keep_token_hash: &str,
426 database: &Database,
427 ) -> Result<u64, sqlx::Error> {
428 let result =
429 crate::sql::query("DELETE FROM admin_sessions WHERE user_id = ? AND token_hash != ?;")
430 .bind(user_id)
431 .bind(keep_token_hash)
432 .execute(database)
433 .await?;
434
435 info!(event = "db_admin_sessions_revoked",
436 outcome = "success",
437 scope = "user_except_current",
438 user_id = %user_id,
439 rows_removed = result.rows_affected());
440 Ok(result.rows_affected())
441 }
442
443 pub async fn delete_all(database: &Database) -> Result<u64, sqlx::Error> {
446 let result = crate::sql::query("DELETE FROM admin_sessions;")
447 .execute(database)
448 .await?;
449
450 info!(
454 event = "db_admin_sessions_revoked",
455 outcome = "success",
456 scope = "all",
457 rows_removed = result.rows_affected()
458 );
459 Ok(result.rows_affected())
460 }
461
462 pub async fn list_all(
470 user_id: Option<Uuid>,
471 database: &Database,
472 ) -> Result<Vec<AdminSession>, sqlx::Error> {
473 let rows = match user_id {
477 Some(id) => crate::sql::query(concat!(
478 "SELECT ",
479 columns!(),
480 " FROM admin_sessions WHERE user_id = ? ORDER BY created_at DESC, token_hash ASC;"
481 ))
482 .bind(id)
483 .fetch_all(database)
484 .await?,
485 None => {
486 crate::sql::query(concat!(
487 "SELECT ",
488 columns!(),
489 " FROM admin_sessions ORDER BY created_at DESC, token_hash ASC;"
490 ))
491 .fetch_all(database)
492 .await?
493 }
494 };
495
496 rows.into_iter().map(AdminSession::from_row).collect()
497 }
498
499 pub async fn search(
507 user_id: Option<Uuid>,
508 limit: i64,
509 offset: i64,
510 database: &Database,
511 ) -> Result<(Vec<AdminSession>, i64), sqlx::Error> {
512 let (rows, total) = match user_id {
516 Some(id) => (
517 crate::sql::query(concat!(
518 "SELECT ",
519 columns!(),
520 " FROM admin_sessions WHERE user_id = ? \
521 ORDER BY created_at DESC, token_hash ASC LIMIT ? OFFSET ?;"
522 ))
523 .bind(id)
524 .bind(limit)
525 .bind(offset)
526 .fetch_all(database)
527 .await?,
528 crate::sql::query("SELECT COUNT(*) FROM admin_sessions WHERE user_id = ?;")
529 .bind(id)
530 .fetch_one(database)
531 .await?
532 .try_get::<i64>(0)?,
533 ),
534 None => (
535 crate::sql::query(concat!(
536 "SELECT ",
537 columns!(),
538 " FROM admin_sessions ORDER BY created_at DESC, token_hash ASC \
539 LIMIT ? OFFSET ?;"
540 ))
541 .bind(limit)
542 .bind(offset)
543 .fetch_all(database)
544 .await?,
545 crate::sql::query("SELECT COUNT(*) FROM admin_sessions;")
546 .fetch_one(database)
547 .await?
548 .try_get::<i64>(0)?,
549 ),
550 };
551
552 let sessions = rows
553 .into_iter()
554 .map(AdminSession::from_row)
555 .collect::<Result<_, _>>()?;
556 Ok((sessions, total))
557 }
558
559 pub async fn cleanup(idle_timeout: Duration, database: &Database) -> Result<u64, sqlx::Error> {
563 let now = now_secs();
564 let idle_cutoff = now.saturating_sub(idle_timeout.as_secs() as i64);
565
566 let result = crate::sql::query(
567 "DELETE FROM admin_sessions WHERE expires_at <= ? OR last_seen_at <= ?;",
568 )
569 .bind(now)
570 .bind(idle_cutoff)
571 .execute(database)
572 .await?;
573
574 debug!(
575 event = "db_admin_session_cleanup_completed",
576 outcome = "success",
577 rows_removed = result.rows_affected(),
578 idle_cutoff = idle_cutoff
579 );
580 Ok(result.rows_affected())
581 }
582
583 #[must_use]
585 pub fn is_expired(&self, now: i64) -> bool {
586 now >= self.expires_at
587 }
588
589 #[must_use]
591 pub fn is_idle(&self, now: i64, idle_timeout: Duration) -> bool {
592 now.saturating_sub(self.last_seen_at) >= idle_timeout.as_secs() as i64
593 }
594
595 #[must_use]
598 pub fn is_active(&self) -> bool {
599 self.state == "active"
600 }
601
602 #[must_use]
609 pub fn to_json(&self) -> Value {
610 serde_json::json!({
611 "id": fingerprint(&self.token_hash),
612 "userId": self.user_id,
613 "state": self.state,
614 "createdAt": rfc3339(self.created_at),
615 "expiresAt": rfc3339(self.expires_at),
616 "lastSeenAt": rfc3339(self.last_seen_at),
617 "createdIp": self.created_ip,
618 "userAgent": self.user_agent,
619 })
620 }
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626 use crate::admin_user::AdminUser;
627 use std::sync::Arc;
628
629 const TTL: Duration = Duration::from_secs(43_200);
630 const IDLE: Duration = Duration::from_secs(3_600);
631
632 async fn db_with_user() -> (Arc<Database>, AdminUser) {
633 let db = Arc::new(Database::connect_for_test().await.unwrap());
634 let user = AdminUser::create("alice", "hash", None, &db).await.unwrap();
635 (db, user)
636 }
637
638 async fn session(db: Arc<Database>, user: &AdminUser, token_hash: &str) -> AdminSession {
639 AdminSession::create(
640 NewSession {
641 user_id: user.id,
642 token_hash,
643 csrf_token: "csrf",
644 created_ip: Some("192.0.2.1".to_string()),
645 user_agent: Some("curl/8".to_string()),
646 },
647 TTL,
648 &db,
649 )
650 .await
651 .unwrap()
652 }
653
654 #[tokio::test]
655 async fn create_persists_an_active_session_and_round_trips() {
656 let (db, user) = db_with_user().await;
657 let created = session(db.clone(), &user, "aaaa").await;
658 assert!(created.is_active());
659 assert_eq!(
660 created.expires_at,
661 created.created_at + TTL.as_secs() as i64
662 );
663 assert_eq!(created.last_seen_at, created.created_at);
664
665 let found = AdminSession::find_by_token_hash("aaaa", &db)
666 .await
667 .unwrap()
668 .unwrap();
669 assert_eq!(found.user_id, user.id);
670 assert_eq!(found.csrf_token, "csrf");
671 assert_eq!(found.created_ip.as_deref(), Some("192.0.2.1"));
672 assert_eq!(found.user_agent.as_deref(), Some("curl/8"));
673 }
674
675 #[tokio::test]
676 async fn find_by_unknown_token_hash_returns_none() {
677 let (db, _user) = db_with_user().await;
678 assert!(
679 AdminSession::find_by_token_hash("nope", &db)
680 .await
681 .unwrap()
682 .is_none()
683 );
684 }
685
686 #[tokio::test]
687 async fn a_session_for_an_unknown_user_is_refused_by_the_foreign_key() {
688 let db = Arc::new(Database::connect_for_test().await.unwrap());
689 let error = AdminSession::create(
690 NewSession {
691 user_id: crate::id::mint(),
692 token_hash: "aaaa",
693 csrf_token: "csrf",
694 created_ip: None,
695 user_agent: None,
696 },
697 TTL,
698 &db,
699 )
700 .await
701 .unwrap_err();
702 assert!(
703 crate::sql::is_foreign_key_violation(&error),
704 "expected a FOREIGN KEY violation, got: {error}"
705 );
706 }
707
708 #[tokio::test]
709 async fn the_state_check_refuses_a_value_outside_the_schema() {
710 let (db, user) = db_with_user().await;
711 session(db.clone(), &user, "aaaa").await;
712 let error =
713 crate::sql::query("UPDATE admin_sessions SET state = 'whatever' WHERE token_hash = ?;")
714 .bind("aaaa")
715 .execute(&db)
716 .await
717 .unwrap_err();
718 assert!(crate::sql::is_check_violation(&error), "{error}");
719 }
720
721 #[tokio::test]
722 async fn touch_advances_the_idle_deadline_and_persists() {
723 let (db, user) = db_with_user().await;
724 let mut created = session(db.clone(), &user, "aaaa").await;
725 crate::sql::query("UPDATE admin_sessions SET last_seen_at = ? WHERE token_hash = ?;")
727 .bind(created.created_at - 500)
728 .bind("aaaa")
729 .execute(&db)
730 .await
731 .unwrap();
732
733 created.touch(&db).await.unwrap();
734 let reloaded = AdminSession::find_by_token_hash("aaaa", &db)
735 .await
736 .unwrap()
737 .unwrap();
738 assert_eq!(reloaded.last_seen_at, created.last_seen_at);
739 assert!(reloaded.last_seen_at > created.created_at - 500);
740 }
741
742 #[tokio::test]
743 async fn delete_reports_whether_a_row_existed() {
744 let (db, user) = db_with_user().await;
745 session(db.clone(), &user, "aaaa").await;
746 assert!(AdminSession::delete("aaaa", &db).await.unwrap());
747 assert!(!AdminSession::delete("aaaa", &db).await.unwrap());
748 }
749
750 #[tokio::test]
751 async fn delete_for_user_removes_every_session_of_that_user_only() {
752 let (db, alice) = db_with_user().await;
753 let bob = AdminUser::create("bob", "hash", None, &db).await.unwrap();
754 session(db.clone(), &alice, "a1").await;
755 session(db.clone(), &alice, "a2").await;
756 session(db.clone(), &bob, "b1").await;
757
758 assert_eq!(
759 AdminSession::delete_for_user(alice.id, &db).await.unwrap(),
760 2
761 );
762 assert!(
763 AdminSession::find_by_token_hash("b1", &db)
764 .await
765 .unwrap()
766 .is_some()
767 );
768 }
769
770 #[tokio::test]
771 async fn delete_for_user_except_keeps_the_named_session() {
772 let (db, user) = db_with_user().await;
773 session(db.clone(), &user, "keep").await;
774 session(db.clone(), &user, "drop1").await;
775 session(db.clone(), &user, "drop2").await;
776
777 assert_eq!(
778 AdminSession::delete_for_user_except(user.id, "keep", &db)
779 .await
780 .unwrap(),
781 2
782 );
783 assert!(
784 AdminSession::find_by_token_hash("keep", &db)
785 .await
786 .unwrap()
787 .is_some()
788 );
789 assert!(
790 AdminSession::find_by_token_hash("drop1", &db)
791 .await
792 .unwrap()
793 .is_none()
794 );
795 }
796
797 #[tokio::test]
798 async fn deleting_a_user_cascades_to_their_sessions() {
799 let (db, user) = db_with_user().await;
800 session(db.clone(), &user, "aaaa").await;
801 assert!(AdminUser::delete(user.id, &db).await.unwrap());
802 assert!(
803 AdminSession::find_by_token_hash("aaaa", &db)
804 .await
805 .unwrap()
806 .is_none(),
807 "ON DELETE CASCADE needs `foreign_keys` on, which connect_in_memory pins"
808 );
809 }
810
811 #[tokio::test]
812 async fn list_all_filters_by_user_and_is_empty_when_there_are_none() {
813 let (db, alice) = db_with_user().await;
814 assert!(AdminSession::list_all(None, &db).await.unwrap().is_empty());
815
816 let bob = AdminUser::create("bob", "hash", None, &db).await.unwrap();
817 session(db.clone(), &alice, "a1").await;
818 session(db.clone(), &bob, "b1").await;
819
820 assert_eq!(AdminSession::list_all(None, &db).await.unwrap().len(), 2);
821 let alices = AdminSession::list_all(Some(alice.id), &db).await.unwrap();
822 assert_eq!(alices.len(), 1);
823 assert_eq!(alices[0].token_hash, "a1");
824 }
825
826 #[tokio::test]
830 async fn find_by_user_and_fingerprint_is_scoped_to_the_named_user() {
831 let (db, alice) = db_with_user().await;
832 let bob = AdminUser::create("bob", "hash", None, &db).await.unwrap();
833 let alices = session(db.clone(), &alice, "alice-token-hash").await;
834 session(db.clone(), &bob, "bob-token-hash").await;
835
836 let alice_fp = fingerprint(&alices.token_hash);
837 let found = AdminSession::find_by_user_and_fingerprint(alice.id, alice_fp, &db)
838 .await
839 .unwrap()
840 .expect("alice's own session must resolve under her own id");
841 assert_eq!(found.token_hash, alices.token_hash);
842
843 assert!(
846 AdminSession::find_by_user_and_fingerprint(bob.id, alice_fp, &db)
847 .await
848 .unwrap()
849 .is_none()
850 );
851
852 assert!(
854 AdminSession::find_by_user_and_fingerprint(alice.id, "00000000", &db)
855 .await
856 .unwrap()
857 .is_none()
858 );
859 }
860
861 #[tokio::test]
866 async fn search_pages_each_filter_and_counts_what_that_filter_matches() {
867 let (db, alice) = db_with_user().await;
868 assert_eq!(AdminSession::search(None, 50, 0, &db).await.unwrap().1, 0);
869
870 let bob = AdminUser::create("bob", "hash", None, &db).await.unwrap();
871 for token in ["a1", "a2", "a3"] {
872 session(db.clone(), &alice, token).await;
873 }
874 session(db.clone(), &bob, "b1").await;
875
876 let (_, all) = AdminSession::search(None, 50, 0, &db).await.unwrap();
877 assert_eq!(all, 4);
878
879 let (first, total) = AdminSession::search(Some(alice.id), 2, 0, &db)
880 .await
881 .unwrap();
882 let (second, also_total) = AdminSession::search(Some(alice.id), 2, 2, &db)
883 .await
884 .unwrap();
885 assert_eq!((total, also_total), (3, 3), "alice's rows, not the table's");
886 assert_eq!((first.len(), second.len()), (2, 1));
887
888 let walked: Vec<&str> = first
892 .iter()
893 .chain(second.iter())
894 .map(|s| s.token_hash.as_str())
895 .collect();
896 assert_eq!(walked.len(), 3);
897 for token in ["a1", "a2", "a3"] {
898 assert_eq!(
899 walked.iter().filter(|seen| **seen == token).count(),
900 1,
901 "{token} was not on exactly one page"
902 );
903 }
904 assert!(!walked.contains(&"b1"), "bob's session is not alice's page");
905 }
906
907 #[tokio::test]
908 async fn cleanup_removes_expired_and_idle_rows_and_leaves_live_ones() {
909 let (db, user) = db_with_user().await;
910 session(db.clone(), &user, "live").await;
911 session(db.clone(), &user, "expired").await;
912 session(db.clone(), &user, "idle").await;
913
914 let now = now_secs();
915 crate::sql::query("UPDATE admin_sessions SET expires_at = ? WHERE token_hash = 'expired';")
916 .bind(now - 1)
917 .execute(&db)
918 .await
919 .unwrap();
920 crate::sql::query("UPDATE admin_sessions SET last_seen_at = ? WHERE token_hash = 'idle';")
921 .bind(now - IDLE.as_secs() as i64 - 1)
922 .execute(&db)
923 .await
924 .unwrap();
925
926 assert_eq!(AdminSession::cleanup(IDLE, &db).await.unwrap(), 2);
927 let left = AdminSession::list_all(None, &db).await.unwrap();
928 assert_eq!(left.len(), 1);
929 assert_eq!(left[0].token_hash, "live");
930 }
931
932 const PENDING_TTL: Duration = Duration::from_secs(300);
933
934 #[tokio::test]
935 async fn create_pending_writes_the_half_authenticated_state() {
936 let (db, user) = db_with_user().await;
937 let pending = AdminSession::create_pending(
938 NewSession {
939 user_id: user.id,
940 token_hash: "pending-hash",
941 csrf_token: "csrf",
942 created_ip: Some("192.0.2.1".to_string()),
943 user_agent: Some("curl".to_string()),
944 },
945 PENDING_TTL,
946 &db,
947 )
948 .await
949 .unwrap();
950
951 assert_eq!(pending.state, "pending_mfa");
952 assert!(!pending.is_active());
953 assert!(
954 pending.expires_at - pending.created_at <= PENDING_TTL.as_secs() as i64,
955 "a half-authenticated row must not get the full session lifetime"
956 );
957
958 let reloaded = AdminSession::find_by_token_hash("pending-hash", &db)
959 .await
960 .unwrap()
961 .unwrap();
962 assert_eq!(reloaded.state, "pending_mfa");
963 assert_eq!(reloaded.created_ip.as_deref(), Some("192.0.2.1"));
964 assert_eq!(reloaded.mfa_attempts, 0);
965 }
966
967 #[tokio::test]
970 async fn mfa_failures_accumulate_on_the_session_row() {
971 let (db, user) = db_with_user().await;
972 AdminSession::create_pending(
973 NewSession {
974 user_id: user.id,
975 token_hash: "pending-hash",
976 csrf_token: "csrf",
977 created_ip: None,
978 user_agent: None,
979 },
980 PENDING_TTL,
981 &db,
982 )
983 .await
984 .unwrap();
985
986 for expected in 1..=3 {
987 assert_eq!(
988 AdminSession::record_mfa_failure("pending-hash", &db)
989 .await
990 .unwrap(),
991 Some(expected),
992 "the new total comes back, so the caller needs no second read"
993 );
994 }
995
996 let reloaded = AdminSession::find_by_token_hash("pending-hash", &db)
997 .await
998 .unwrap()
999 .unwrap();
1000 assert_eq!(reloaded.mfa_attempts, 3);
1001
1002 AdminSession::delete("pending-hash", &db).await.unwrap();
1005 assert_eq!(
1006 AdminSession::record_mfa_failure("pending-hash", &db)
1007 .await
1008 .unwrap(),
1009 None
1010 );
1011 }
1012
1013 #[tokio::test]
1016 async fn promotion_does_not_carry_the_attempt_counter_across() {
1017 let (db, user) = db_with_user().await;
1018 AdminSession::create_pending(
1019 NewSession {
1020 user_id: user.id,
1021 token_hash: "pending",
1022 csrf_token: "csrf",
1023 created_ip: None,
1024 user_agent: None,
1025 },
1026 PENDING_TTL,
1027 &db,
1028 )
1029 .await
1030 .unwrap();
1031 AdminSession::record_mfa_failure("pending", &db)
1032 .await
1033 .unwrap();
1034
1035 let promoted = AdminSession::promote("pending", "active", "csrf2", TTL, &db)
1036 .await
1037 .unwrap()
1038 .unwrap();
1039 assert_eq!(promoted.mfa_attempts, 0);
1040 }
1041
1042 #[tokio::test]
1043 async fn promote_rotates_the_token_and_can_only_happen_once() {
1044 let (db, user) = db_with_user().await;
1045 AdminSession::create_pending(
1046 NewSession {
1047 user_id: user.id,
1048 token_hash: "pending-hash",
1049 csrf_token: "pending-csrf",
1050 created_ip: Some("192.0.2.1".to_string()),
1051 user_agent: Some("curl".to_string()),
1052 },
1053 PENDING_TTL,
1054 &db,
1055 )
1056 .await
1057 .unwrap();
1058
1059 let promoted =
1060 AdminSession::promote("pending-hash", "active-hash", "active-csrf", TTL, &db)
1061 .await
1062 .unwrap()
1063 .expect("a pending row must promote");
1064
1065 assert_eq!(promoted.token_hash, "active-hash");
1067 assert_ne!(promoted.csrf_token, "pending-csrf");
1068 assert_eq!(promoted.state, "active");
1069 assert_eq!(promoted.user_id, user.id);
1070 assert_eq!(promoted.created_ip.as_deref(), Some("192.0.2.1"));
1072 assert_eq!(promoted.user_agent.as_deref(), Some("curl"));
1073 assert!(promoted.expires_at - promoted.created_at > PENDING_TTL.as_secs() as i64);
1074
1075 assert!(
1077 AdminSession::find_by_token_hash("pending-hash", &db)
1078 .await
1079 .unwrap()
1080 .is_none()
1081 );
1082
1083 assert!(
1086 AdminSession::promote("pending-hash", "second-hash", "c", TTL, &db)
1087 .await
1088 .unwrap()
1089 .is_none()
1090 );
1091 assert_eq!(
1092 AdminSession::list_all(Some(user.id), &db)
1093 .await
1094 .unwrap()
1095 .len(),
1096 1
1097 );
1098 }
1099
1100 #[tokio::test]
1103 async fn promote_refuses_a_session_that_is_already_active() {
1104 let (db, user) = db_with_user().await;
1105 session(db.clone(), &user, "active-hash").await;
1106
1107 assert!(
1108 AdminSession::promote("active-hash", "new-hash", "c", TTL, &db)
1109 .await
1110 .unwrap()
1111 .is_none()
1112 );
1113 assert!(
1114 AdminSession::find_by_token_hash("active-hash", &db)
1115 .await
1116 .unwrap()
1117 .is_some(),
1118 "the existing session must survive a refused promotion"
1119 );
1120 }
1121
1122 #[tokio::test]
1125 async fn cleanup_sweeps_an_abandoned_pending_session_and_leaves_a_fresh_one() {
1126 let (db, user) = db_with_user().await;
1127 AdminSession::create_pending(
1128 NewSession {
1129 user_id: user.id,
1130 token_hash: "fresh",
1131 csrf_token: "c",
1132 created_ip: None,
1133 user_agent: None,
1134 },
1135 PENDING_TTL,
1136 &db,
1137 )
1138 .await
1139 .unwrap();
1140 AdminSession::create_pending(
1141 NewSession {
1142 user_id: user.id,
1143 token_hash: "abandoned",
1144 csrf_token: "c",
1145 created_ip: None,
1146 user_agent: None,
1147 },
1148 PENDING_TTL,
1149 &db,
1150 )
1151 .await
1152 .unwrap();
1153 crate::sql::query(
1154 "UPDATE admin_sessions SET expires_at = ? WHERE token_hash = 'abandoned';",
1155 )
1156 .bind(now_secs() - 1)
1157 .execute(&db)
1158 .await
1159 .unwrap();
1160
1161 assert_eq!(AdminSession::cleanup(IDLE, &db).await.unwrap(), 1);
1162 let left = AdminSession::list_all(None, &db).await.unwrap();
1163 assert_eq!(left.len(), 1);
1164 assert_eq!(left[0].token_hash, "fresh");
1165 }
1166
1167 #[test]
1168 fn expiry_and_idleness_are_judged_at_the_boundary_second() {
1169 let base = AdminSession {
1170 token_hash: "aaaa".to_string(),
1171 user_id: crate::id::mint(),
1172 csrf_token: "c".to_string(),
1173 state: "active".to_string(),
1174 mfa_attempts: 0,
1175 created_at: 1_000,
1176 expires_at: 2_000,
1177 last_seen_at: 1_000,
1178 created_ip: None,
1179 user_agent: None,
1180 };
1181
1182 assert!(!base.is_expired(1_999));
1183 assert!(
1184 base.is_expired(2_000),
1185 "the deadline second is already past"
1186 );
1187
1188 assert!(!base.is_idle(1_000 + 3_599, IDLE));
1189 assert!(base.is_idle(1_000 + 3_600, IDLE));
1190 }
1191
1192 #[tokio::test]
1193 async fn to_json_never_leaks_the_token_hash_or_the_csrf_token() {
1194 let (db, user) = db_with_user().await;
1195 let created = AdminSession::create(
1196 NewSession {
1197 user_id: user.id,
1198 token_hash: "0123456789abcdef0123456789abcdef",
1199 csrf_token: "the-csrf-token",
1200 created_ip: None,
1201 user_agent: None,
1202 },
1203 TTL,
1204 &db,
1205 )
1206 .await
1207 .unwrap();
1208
1209 let json = created.to_json();
1210 let rendered = json.to_string();
1211 assert!(!rendered.contains("0123456789abcdef0123456789abcdef"));
1212 assert!(!rendered.contains("the-csrf-token"));
1213 assert_eq!(json["id"], "01234567");
1214 assert_eq!(json["userId"], user.id.to_string());
1215 assert_eq!(json["state"], "active");
1216 assert_eq!(json["createdIp"], Value::Null);
1217 }
1218}