Skip to main content

acme_proxy/sqlite/
admin_session.rs

1use std::time::Duration;
2
3use serde_json::Value;
4use sqlx::Row;
5use sqlx::sqlite::SqliteRow;
6use tracing::{debug, info};
7
8use crate::sqlite::db::Database;
9use crate::sqlite::nonce::{fingerprint, now_secs};
10use crate::sqlite::order::rfc3339;
11
12/// One logged-in browser session of an [`crate::sqlite::admin_user::AdminUser`].
13///
14/// **This layer never sees the session token.** `token_hash` arrives already
15/// hashed from `webadmin::session`, which is the only place the plaintext
16/// exists: it goes into a `Set-Cookie` and is never written down. A read of
17/// this table -- a backup, a `.dump`, an injection -- therefore yields nothing
18/// that can be replayed. That is also why there is no `find_by_id`: the hash
19/// *is* the lookup key, and knowing it means already holding the token.
20///
21/// ## Methods
22///
23/// - `create`: persist a session for a user
24/// - `find_by_token_hash`: the per-request resolution path
25/// - `touch`: advance the idle deadline
26/// - `delete` / `delete_for_user` / `delete_for_user_except`: logout, revoke all,
27///   and the "keep the session doing the changing" case a password change needs
28/// - `list_all`: admin CLI visibility
29/// - `cleanup`: the reaper's sweep
30/// - `to_json`: admin-facing rendering (never the token hash or the CSRF token)
31#[derive(Debug, Clone)]
32pub struct AdminSession {
33    /// Hex-encoded SHA-256 of the cookie's bearer token.
34    pub token_hash: String,
35    pub user_id: String,
36    /// Per-session CSRF token, plaintext: it authorises nothing on its own.
37    pub csrf_token: String,
38    /// `active`, or `pending_mfa` once a second factor exists to be outstanding.
39    pub state: String,
40    /// Second-factor codes rejected against this session. Advanced only by
41    /// [`AdminSession::record_mfa_failure`], which is where the reasoning is.
42    pub mfa_attempts: i64,
43    pub created_at: i64,
44    /// Absolute deadline. Never extended.
45    pub expires_at: i64,
46    /// Idle deadline, advanced by [`AdminSession::touch`].
47    pub last_seen_at: i64,
48    /// Forensics only -- never compared against the request being served.
49    pub created_ip: Option<String>,
50    pub user_agent: Option<String>,
51}
52
53/// Everything a new session row needs from its caller.
54///
55/// A struct rather than three positional `&str`s, and that is a security
56/// property rather than a style one: `token_hash` and `csrf_token` were
57/// adjacent same-typed parameters, so transposing them at a call site compiled
58/// cleanly and produced a session whose CSRF token *is* its session token hash
59/// — a value that has already crossed the wire in a cookie. Named fields make
60/// that unwriteable.
61///
62/// It also retires two `#[allow(clippy::too_many_arguments)]`.
63#[derive(Debug, Clone)]
64pub struct NewSession<'a> {
65    pub user_id: &'a str,
66    /// Hex-encoded SHA-256 of the cookie token. Minted by the caller: this
67    /// layer holds no RNG and so cannot accidentally reuse one.
68    pub token_hash: &'a str,
69    pub csrf_token: &'a str,
70    /// Forensics only -- see the `created_ip` column.
71    pub created_ip: Option<String>,
72    pub user_agent: Option<String>,
73}
74
75/// Every column of `admin_sessions`, in one place: each read must select the same set
76/// or `from_row` fails on whichever forgot one.
77///
78/// A `macro_rules!` rather than a `const` so the expansion is a string
79/// *literal*, which is what `sqlx::query`'s `SqlSafeStr` bound requires.
80macro_rules! columns {
81    () => {
82        "token_hash, user_id, csrf_token, state, mfa_attempts, created_at, \
83         expires_at, last_seen_at, created_ip, user_agent"
84    };
85}
86
87impl AdminSession {
88    fn from_row(row: SqliteRow) -> Result<Self, sqlx::Error> {
89        Ok(AdminSession {
90            token_hash: row.try_get("token_hash")?,
91            user_id: row.try_get("user_id")?,
92            csrf_token: row.try_get("csrf_token")?,
93            state: row.try_get("state")?,
94            mfa_attempts: row.try_get("mfa_attempts")?,
95            created_at: row.try_get("created_at")?,
96            expires_at: row.try_get("expires_at")?,
97            last_seen_at: row.try_get("last_seen_at")?,
98            created_ip: row.try_get("created_ip")?,
99            user_agent: row.try_get("user_agent")?,
100        })
101    }
102
103    /// Persists a fresh `active` session expiring `ttl` from now.
104    pub async fn create(
105        new: NewSession<'_>,
106        ttl: Duration,
107        database: &Database,
108    ) -> Result<AdminSession, sqlx::Error> {
109        Self::create_with_state("active", new, ttl, database).await
110    }
111
112    /// Persists a fresh **`pending_mfa`** session: a password was accepted and
113    /// nothing more.
114    ///
115    /// `ttl` is `webadmin::session::PENDING_MFA_TTL`, not the configured session
116    /// lifetime -- a half-authenticated row should not outlive the tab that
117    /// created it, and that short absolute deadline is one of the two bounds on
118    /// how long an attacker holding a password may keep guessing codes (the
119    /// other is [`AdminSession::record_mfa_failure`]).
120    ///
121    /// The reaper needs no special case for these: `expires_at` is set the same
122    /// way, so `cleanup`'s existing `expires_at <= ?` sweeps them.
123    pub async fn create_pending(
124        new: NewSession<'_>,
125        ttl: Duration,
126        database: &Database,
127    ) -> Result<AdminSession, sqlx::Error> {
128        Self::create_with_state("pending_mfa", new, ttl, database).await
129    }
130
131    /// The body both constructors share. Private: `state` is not a parameter
132    /// any caller outside this file gets to choose, and there is deliberately no
133    /// setter for it -- see [`AdminSession::promote`].
134    async fn create_with_state(
135        state: &str,
136        new: NewSession<'_>,
137        ttl: Duration,
138        database: &Database,
139    ) -> Result<AdminSession, sqlx::Error> {
140        let now = now_secs();
141        let session = AdminSession {
142            token_hash: new.token_hash.to_string(),
143            user_id: new.user_id.to_string(),
144            csrf_token: new.csrf_token.to_string(),
145            state: state.to_string(),
146            mfa_attempts: 0,
147            created_at: now,
148            // Saturating, for the same reason `Nonce::verify`'s cutoff is: a
149            // configured TTL large enough to overflow must not panic.
150            expires_at: now.saturating_add(ttl.as_secs() as i64),
151            last_seen_at: now,
152            created_ip: new.created_ip,
153            user_agent: new.user_agent,
154        };
155
156        sqlx::query(
157            "INSERT INTO admin_sessions (token_hash, user_id, csrf_token, state, created_at, \
158             expires_at, last_seen_at, created_ip, user_agent) \
159             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);",
160        )
161        .bind(&session.token_hash)
162        .bind(&session.user_id)
163        .bind(&session.csrf_token)
164        .bind(&session.state)
165        .bind(session.created_at)
166        .bind(session.expires_at)
167        .bind(session.last_seen_at)
168        .bind(&session.created_ip)
169        .bind(&session.user_agent)
170        .execute(&database.pool)
171        .await?;
172
173        // A fingerprint of the *hash*, not the token -- enough to follow one
174        // session across log lines, and derived from something already useless
175        // to a reader.
176        info!(event = "db_admin_session_created",
177              outcome = "success",
178              session_fp = %fingerprint(&session.token_hash),
179              user_id = %session.user_id,
180              state = %session.state);
181        Ok(session)
182    }
183
184    /// Replaces a `pending_mfa` session with a fresh `active` one: a new token,
185    /// a new CSRF token, a full `ttl`, and the same user and forensics.
186    ///
187    /// A **rotation**, not an `UPDATE state`. The pending token is a real bearer
188    /// token that a browser stored and that has crossed the wire, minted before
189    /// authentication completed; its privilege level changing means its value
190    /// changes -- the same rule that makes `sign_in` delete whatever session the
191    /// request already carried. The CSRF token rotates for free, which matters
192    /// because the challenge page had to be handed one.
193    ///
194    /// One transaction, and the DELETE's `rows_affected` is the concurrency
195    /// guard: two submissions of one code promote exactly once. `None` means
196    /// nothing pending sat under `pending_token_hash` -- already promoted,
197    /// already swept, or never there.
198    ///
199    /// The side effect worth having: with no setter for `state` anywhere, the
200    /// column is write-once at INSERT, so no code path can move a session
201    /// between states in place.
202    pub async fn promote(
203        pending_token_hash: &str,
204        new_token_hash: &str,
205        new_csrf_token: &str,
206        ttl: Duration,
207        database: &Database,
208    ) -> Result<Option<AdminSession>, sqlx::Error> {
209        let mut tx = database.pool.begin().await?;
210
211        let row = sqlx::query(concat!(
212            "SELECT ",
213            columns!(),
214            " FROM admin_sessions WHERE token_hash = ? AND state = 'pending_mfa';"
215        ))
216        .bind(pending_token_hash)
217        .fetch_optional(&mut *tx)
218        .await?;
219
220        let Some(pending) = row.map(AdminSession::from_row).transpose()? else {
221            return Ok(None);
222        };
223
224        // The DELETE, not the SELECT, is what makes this exclusive: two
225        // transactions can both read the pending row, but only one removes it.
226        let removed = sqlx::query("DELETE FROM admin_sessions WHERE token_hash = ?;")
227            .bind(pending_token_hash)
228            .execute(&mut *tx)
229            .await?;
230        if removed.rows_affected() != 1 {
231            return Ok(None);
232        }
233
234        let now = now_secs();
235        let session = AdminSession {
236            token_hash: new_token_hash.to_string(),
237            user_id: pending.user_id,
238            csrf_token: new_csrf_token.to_string(),
239            state: "active".to_string(),
240            // Deliberately not carried over: the promoted session is a fresh
241            // one, and the counter only ever bounded the pending row it
242            // replaced.
243            mfa_attempts: 0,
244            created_at: now,
245            expires_at: now.saturating_add(ttl.as_secs() as i64),
246            last_seen_at: now,
247            created_ip: pending.created_ip,
248            user_agent: pending.user_agent,
249        };
250
251        sqlx::query(
252            "INSERT INTO admin_sessions (token_hash, user_id, csrf_token, state, created_at, \
253             expires_at, last_seen_at, created_ip, user_agent) \
254             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);",
255        )
256        .bind(&session.token_hash)
257        .bind(&session.user_id)
258        .bind(&session.csrf_token)
259        .bind(&session.state)
260        .bind(session.created_at)
261        .bind(session.expires_at)
262        .bind(session.last_seen_at)
263        .bind(&session.created_ip)
264        .bind(&session.user_agent)
265        .execute(&mut *tx)
266        .await?;
267
268        tx.commit().await?;
269
270        info!(event = "db_admin_session_promoted",
271              outcome = "success",
272              session_fp = %fingerprint(&session.token_hash),
273              replaced = %fingerprint(pending_token_hash),
274              user_id = %session.user_id);
275        Ok(Some(session))
276    }
277
278    /// Counts one rejected second-factor code against this session, returning
279    /// the new total.
280    ///
281    /// **The bound an attacker cannot shed.** `webadmin::session::LoginLimiter`
282    /// keys on the peer address, and a `pending_mfa` cookie is valid from any
283    /// address on purpose (see `created_ip`: pinning breaks CGNAT and mobile).
284    /// Somebody holding a correct password could therefore mint one pending
285    /// session and spend `admin.login_max_attempts` guesses per source address,
286    /// which a single IPv6 /64 supplies 2^64 of. This counter travels with the
287    /// session instead, so rotating addresses buys nothing.
288    ///
289    /// One `UPDATE ... RETURNING`, which is also what makes it race-free: this
290    /// listener carries no admission control by design, so a read-then-write
291    /// would let K concurrent submissions all observe zero and each get a free
292    /// guess. `None` means the row is already gone -- swept, promoted, or
293    /// deleted by a concurrent submission that hit the cap first.
294    pub async fn record_mfa_failure(
295        token_hash: &str,
296        database: &Database,
297    ) -> Result<Option<i64>, sqlx::Error> {
298        let row = sqlx::query(
299            "UPDATE admin_sessions SET mfa_attempts = mfa_attempts + 1 \
300             WHERE token_hash = ? RETURNING mfa_attempts;",
301        )
302        .bind(token_hash)
303        .fetch_optional(&database.pool)
304        .await?;
305
306        let attempts = row
307            .map(|row| row.try_get::<i64, _>("mfa_attempts"))
308            .transpose()?;
309        if let Some(attempts) = attempts {
310            debug!(event = "db_admin_session_mfa_failure_recorded",
311                   outcome = "success",
312                   session_fp = %fingerprint(token_hash),
313                   attempts);
314        }
315        Ok(attempts)
316    }
317
318    /// The per-request resolution path. Returns the row whatever its state --
319    /// expiry, idleness and `state` are the caller's to judge, since each maps
320    /// to a different refusal.
321    pub async fn find_by_token_hash(
322        token_hash: &str,
323        database: &Database,
324    ) -> Result<Option<AdminSession>, sqlx::Error> {
325        let row = sqlx::query(concat!(
326            "SELECT ",
327            columns!(),
328            " FROM admin_sessions WHERE token_hash = ?;"
329        ))
330        .bind(token_hash)
331        .fetch_optional(&database.pool)
332        .await?;
333
334        row.map(AdminSession::from_row).transpose()
335    }
336
337    /// Advances the idle deadline. Callers rate-limit this -- see
338    /// `webadmin::session::SESSION_TOUCH_INTERVAL` -- because a polling page
339    /// would otherwise take the WAL writer lock on every request.
340    pub async fn touch(&mut self, database: &Database) -> Result<(), sqlx::Error> {
341        let now = now_secs();
342        sqlx::query("UPDATE admin_sessions SET last_seen_at = ? WHERE token_hash = ?;")
343            .bind(now)
344            .bind(&self.token_hash)
345            .execute(&database.pool)
346            .await?;
347
348        self.last_seen_at = now;
349        Ok(())
350    }
351
352    /// Logout. Returns whether a row existed.
353    pub async fn delete(token_hash: &str, database: &Database) -> Result<bool, sqlx::Error> {
354        let result = sqlx::query("DELETE FROM admin_sessions WHERE token_hash = ?;")
355            .bind(token_hash)
356            .execute(&database.pool)
357            .await?;
358
359        let deleted = result.rows_affected() > 0;
360        if deleted {
361            info!(event = "db_admin_session_deleted", outcome = "success", session_fp = %fingerprint(token_hash));
362        }
363        Ok(deleted)
364    }
365
366    /// Revokes every session of one user -- `admin session revoke --user`, and
367    /// the "log me out everywhere" case. Returns how many went.
368    pub async fn delete_for_user(user_id: &str, database: &Database) -> Result<u64, sqlx::Error> {
369        let result = sqlx::query("DELETE FROM admin_sessions WHERE user_id = ?;")
370            .bind(user_id)
371            .execute(&database.pool)
372            .await?;
373
374        info!(event = "db_admin_sessions_revoked",
375              outcome = "success",
376              scope = "user",
377              user_id = %user_id,
378              rows_removed = result.rows_affected());
379        Ok(result.rows_affected())
380    }
381
382    /// Revokes every session of one user *except* the one named -- what a
383    /// password change needs, so the operator making it is not logged out by
384    /// their own action while every other browser is.
385    pub async fn delete_for_user_except(
386        user_id: &str,
387        keep_token_hash: &str,
388        database: &Database,
389    ) -> Result<u64, sqlx::Error> {
390        let result =
391            sqlx::query("DELETE FROM admin_sessions WHERE user_id = ? AND token_hash != ?;")
392                .bind(user_id)
393                .bind(keep_token_hash)
394                .execute(&database.pool)
395                .await?;
396
397        info!(event = "db_admin_sessions_revoked",
398              outcome = "success",
399              scope = "user_except_current",
400              user_id = %user_id,
401              rows_removed = result.rows_affected());
402        Ok(result.rows_affected())
403    }
404
405    /// Revokes every session on the server -- `admin session revoke --all`,
406    /// the "log everybody out" lever after a scare. Returns how many went.
407    pub async fn delete_all(database: &Database) -> Result<u64, sqlx::Error> {
408        let result = sqlx::query("DELETE FROM admin_sessions;")
409            .execute(&database.pool)
410            .await?;
411
412        // `scope` rather than the `user_id = "*"` this used to carry: three
413        // different operations shared this event name, and a magic value in a
414        // field is not a thing an operator can filter on.
415        info!(
416            event = "db_admin_sessions_revoked",
417            outcome = "success",
418            scope = "all",
419            rows_removed = result.rows_affected()
420        );
421        Ok(result.rows_affected())
422    }
423
424    /// Every session, or every session of one user, newest first.
425    pub async fn list_all(
426        user_id: Option<&str>,
427        database: &Database,
428    ) -> Result<Vec<AdminSession>, sqlx::Error> {
429        // Two literal statements rather than one built up: `sqlx::query` takes
430        // only `&'static str`, which is what stops a column list or a
431        // predicate ever being interpolated in.
432        let rows = match user_id {
433            Some(id) => sqlx::query(concat!(
434                "SELECT ",
435                columns!(),
436                " FROM admin_sessions WHERE user_id = ? ORDER BY created_at DESC, token_hash ASC;"
437            ))
438            .bind(id)
439            .fetch_all(&database.pool)
440            .await?,
441            None => {
442                sqlx::query(concat!(
443                    "SELECT ",
444                    columns!(),
445                    " FROM admin_sessions ORDER BY created_at DESC, token_hash ASC;"
446                ))
447                .fetch_all(&database.pool)
448                .await?
449            }
450        };
451
452        rows.into_iter().map(AdminSession::from_row).collect()
453    }
454
455    /// The reaper's sweep: everything past its absolute deadline, plus
456    /// everything idle longer than `idle_timeout`. Unlike nonces, sessions
457    /// outlive a restart, so a startup-only sweep would leak.
458    pub async fn cleanup(idle_timeout: Duration, database: &Database) -> Result<u64, sqlx::Error> {
459        let now = now_secs();
460        let idle_cutoff = now.saturating_sub(idle_timeout.as_secs() as i64);
461
462        let result =
463            sqlx::query("DELETE FROM admin_sessions WHERE expires_at <= ? OR last_seen_at <= ?;")
464                .bind(now)
465                .bind(idle_cutoff)
466                .execute(&database.pool)
467                .await?;
468
469        debug!(
470            event = "db_admin_session_cleanup_completed",
471            outcome = "success",
472            rows_removed = result.rows_affected(),
473            idle_cutoff = idle_cutoff
474        );
475        Ok(result.rows_affected())
476    }
477
478    /// Past its absolute deadline.
479    #[must_use]
480    pub fn is_expired(&self, now: i64) -> bool {
481        now >= self.expires_at
482    }
483
484    /// Unused for longer than `idle_timeout`.
485    #[must_use]
486    pub fn is_idle(&self, now: i64, idle_timeout: Duration) -> bool {
487        now.saturating_sub(self.last_seen_at) >= idle_timeout.as_secs() as i64
488    }
489
490    /// Whether the session has completed authentication. A `pending_mfa`
491    /// session has a valid password behind it and nothing more.
492    #[must_use]
493    pub fn is_active(&self) -> bool {
494        self.state == "active"
495    }
496
497    /// The admin-facing rendering. **Never** the token hash (it is the lookup
498    /// key, and printing it in `admin session list` would put every live
499    /// session's key on a terminal) nor the CSRF token.
500    ///
501    /// `id` is a fingerprint of the hash: enough to name one session to
502    /// `admin session revoke`, not enough to reconstruct the key.
503    #[must_use]
504    pub fn to_json(&self) -> Value {
505        serde_json::json!({
506            "id": fingerprint(&self.token_hash),
507            "userId": self.user_id,
508            "state": self.state,
509            "createdAt": rfc3339(self.created_at),
510            "expiresAt": rfc3339(self.expires_at),
511            "lastSeenAt": rfc3339(self.last_seen_at),
512            "createdIp": self.created_ip,
513            "userAgent": self.user_agent,
514        })
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::sqlite::admin_user::AdminUser;
522    use std::sync::Arc;
523
524    const TTL: Duration = Duration::from_secs(43_200);
525    const IDLE: Duration = Duration::from_secs(3_600);
526
527    async fn db_with_user() -> (Arc<Database>, AdminUser) {
528        let db = Arc::new(Database::connect_in_memory().await.unwrap());
529        let user = AdminUser::create("alice", "hash", &db).await.unwrap();
530        (db, user)
531    }
532
533    async fn session(db: Arc<Database>, user: &AdminUser, token_hash: &str) -> AdminSession {
534        AdminSession::create(
535            NewSession {
536                user_id: &user.id,
537                token_hash,
538                csrf_token: "csrf",
539                created_ip: Some("192.0.2.1".to_string()),
540                user_agent: Some("curl/8".to_string()),
541            },
542            TTL,
543            &db,
544        )
545        .await
546        .unwrap()
547    }
548
549    #[tokio::test]
550    async fn create_persists_an_active_session_and_round_trips() {
551        let (db, user) = db_with_user().await;
552        let created = session(db.clone(), &user, "aaaa").await;
553        assert!(created.is_active());
554        assert_eq!(
555            created.expires_at,
556            created.created_at + TTL.as_secs() as i64
557        );
558        assert_eq!(created.last_seen_at, created.created_at);
559
560        let found = AdminSession::find_by_token_hash("aaaa", &db)
561            .await
562            .unwrap()
563            .unwrap();
564        assert_eq!(found.user_id, user.id);
565        assert_eq!(found.csrf_token, "csrf");
566        assert_eq!(found.created_ip.as_deref(), Some("192.0.2.1"));
567        assert_eq!(found.user_agent.as_deref(), Some("curl/8"));
568    }
569
570    #[tokio::test]
571    async fn find_by_unknown_token_hash_returns_none() {
572        let (db, _user) = db_with_user().await;
573        assert!(
574            AdminSession::find_by_token_hash("nope", &db)
575                .await
576                .unwrap()
577                .is_none()
578        );
579    }
580
581    #[tokio::test]
582    async fn a_session_for_an_unknown_user_is_refused_by_the_foreign_key() {
583        let db = Arc::new(Database::connect_in_memory().await.unwrap());
584        let error = AdminSession::create(
585            NewSession {
586                user_id: "ghost",
587                token_hash: "aaaa",
588                csrf_token: "csrf",
589                created_ip: None,
590                user_agent: None,
591            },
592            TTL,
593            &db,
594        )
595        .await
596        .unwrap_err();
597        assert!(
598            error.to_string().to_lowercase().contains("foreign key"),
599            "expected a FOREIGN KEY violation, got: {error}"
600        );
601    }
602
603    #[tokio::test]
604    async fn the_state_check_refuses_a_value_outside_the_schema() {
605        let (db, user) = db_with_user().await;
606        session(db.clone(), &user, "aaaa").await;
607        let error =
608            sqlx::query("UPDATE admin_sessions SET state = 'whatever' WHERE token_hash = ?;")
609                .bind("aaaa")
610                .execute(&db.pool)
611                .await
612                .unwrap_err();
613        assert!(error.to_string().to_lowercase().contains("check"));
614    }
615
616    #[tokio::test]
617    async fn touch_advances_the_idle_deadline_and_persists() {
618        let (db, user) = db_with_user().await;
619        let mut created = session(db.clone(), &user, "aaaa").await;
620        // Backdate so the advance is observable within one clock second.
621        sqlx::query("UPDATE admin_sessions SET last_seen_at = ? WHERE token_hash = ?;")
622            .bind(created.created_at - 500)
623            .bind("aaaa")
624            .execute(&db.pool)
625            .await
626            .unwrap();
627
628        created.touch(&db).await.unwrap();
629        let reloaded = AdminSession::find_by_token_hash("aaaa", &db)
630            .await
631            .unwrap()
632            .unwrap();
633        assert_eq!(reloaded.last_seen_at, created.last_seen_at);
634        assert!(reloaded.last_seen_at > created.created_at - 500);
635    }
636
637    #[tokio::test]
638    async fn delete_reports_whether_a_row_existed() {
639        let (db, user) = db_with_user().await;
640        session(db.clone(), &user, "aaaa").await;
641        assert!(AdminSession::delete("aaaa", &db).await.unwrap());
642        assert!(!AdminSession::delete("aaaa", &db).await.unwrap());
643    }
644
645    #[tokio::test]
646    async fn delete_for_user_removes_every_session_of_that_user_only() {
647        let (db, alice) = db_with_user().await;
648        let bob = AdminUser::create("bob", "hash", &db).await.unwrap();
649        session(db.clone(), &alice, "a1").await;
650        session(db.clone(), &alice, "a2").await;
651        session(db.clone(), &bob, "b1").await;
652
653        assert_eq!(
654            AdminSession::delete_for_user(&alice.id, &db).await.unwrap(),
655            2
656        );
657        assert!(
658            AdminSession::find_by_token_hash("b1", &db)
659                .await
660                .unwrap()
661                .is_some()
662        );
663    }
664
665    #[tokio::test]
666    async fn delete_for_user_except_keeps_the_named_session() {
667        let (db, user) = db_with_user().await;
668        session(db.clone(), &user, "keep").await;
669        session(db.clone(), &user, "drop1").await;
670        session(db.clone(), &user, "drop2").await;
671
672        assert_eq!(
673            AdminSession::delete_for_user_except(&user.id, "keep", &db)
674                .await
675                .unwrap(),
676            2
677        );
678        assert!(
679            AdminSession::find_by_token_hash("keep", &db)
680                .await
681                .unwrap()
682                .is_some()
683        );
684        assert!(
685            AdminSession::find_by_token_hash("drop1", &db)
686                .await
687                .unwrap()
688                .is_none()
689        );
690    }
691
692    #[tokio::test]
693    async fn deleting_a_user_cascades_to_their_sessions() {
694        let (db, user) = db_with_user().await;
695        session(db.clone(), &user, "aaaa").await;
696        assert!(AdminUser::delete(&user.id, &db).await.unwrap());
697        assert!(
698            AdminSession::find_by_token_hash("aaaa", &db)
699                .await
700                .unwrap()
701                .is_none(),
702            "ON DELETE CASCADE needs `foreign_keys` on, which connect_in_memory pins"
703        );
704    }
705
706    #[tokio::test]
707    async fn list_all_filters_by_user_and_is_empty_when_there_are_none() {
708        let (db, alice) = db_with_user().await;
709        assert!(AdminSession::list_all(None, &db).await.unwrap().is_empty());
710
711        let bob = AdminUser::create("bob", "hash", &db).await.unwrap();
712        session(db.clone(), &alice, "a1").await;
713        session(db.clone(), &bob, "b1").await;
714
715        assert_eq!(AdminSession::list_all(None, &db).await.unwrap().len(), 2);
716        let alices = AdminSession::list_all(Some(&alice.id), &db).await.unwrap();
717        assert_eq!(alices.len(), 1);
718        assert_eq!(alices[0].token_hash, "a1");
719    }
720
721    #[tokio::test]
722    async fn cleanup_removes_expired_and_idle_rows_and_leaves_live_ones() {
723        let (db, user) = db_with_user().await;
724        session(db.clone(), &user, "live").await;
725        session(db.clone(), &user, "expired").await;
726        session(db.clone(), &user, "idle").await;
727
728        let now = now_secs();
729        sqlx::query("UPDATE admin_sessions SET expires_at = ? WHERE token_hash = 'expired';")
730            .bind(now - 1)
731            .execute(&db.pool)
732            .await
733            .unwrap();
734        sqlx::query("UPDATE admin_sessions SET last_seen_at = ? WHERE token_hash = 'idle';")
735            .bind(now - IDLE.as_secs() as i64 - 1)
736            .execute(&db.pool)
737            .await
738            .unwrap();
739
740        assert_eq!(AdminSession::cleanup(IDLE, &db).await.unwrap(), 2);
741        let left = AdminSession::list_all(None, &db).await.unwrap();
742        assert_eq!(left.len(), 1);
743        assert_eq!(left[0].token_hash, "live");
744    }
745
746    const PENDING_TTL: Duration = Duration::from_secs(300);
747
748    #[tokio::test]
749    async fn create_pending_writes_the_half_authenticated_state() {
750        let (db, user) = db_with_user().await;
751        let pending = AdminSession::create_pending(
752            NewSession {
753                user_id: &user.id,
754                token_hash: "pending-hash",
755                csrf_token: "csrf",
756                created_ip: Some("192.0.2.1".to_string()),
757                user_agent: Some("curl".to_string()),
758            },
759            PENDING_TTL,
760            &db,
761        )
762        .await
763        .unwrap();
764
765        assert_eq!(pending.state, "pending_mfa");
766        assert!(!pending.is_active());
767        assert!(
768            pending.expires_at - pending.created_at <= PENDING_TTL.as_secs() as i64,
769            "a half-authenticated row must not get the full session lifetime"
770        );
771
772        let reloaded = AdminSession::find_by_token_hash("pending-hash", &db)
773            .await
774            .unwrap()
775            .unwrap();
776        assert_eq!(reloaded.state, "pending_mfa");
777        assert_eq!(reloaded.created_ip.as_deref(), Some("192.0.2.1"));
778        assert_eq!(reloaded.mfa_attempts, 0);
779    }
780
781    /// The counter the address-keyed limiter cannot provide, and the reason it
782    /// is one `UPDATE ... RETURNING` rather than a read-then-write.
783    #[tokio::test]
784    async fn mfa_failures_accumulate_on_the_session_row() {
785        let (db, user) = db_with_user().await;
786        AdminSession::create_pending(
787            NewSession {
788                user_id: &user.id,
789                token_hash: "pending-hash",
790                csrf_token: "csrf",
791                created_ip: None,
792                user_agent: None,
793            },
794            PENDING_TTL,
795            &db,
796        )
797        .await
798        .unwrap();
799
800        for expected in 1..=3 {
801            assert_eq!(
802                AdminSession::record_mfa_failure("pending-hash", &db)
803                    .await
804                    .unwrap(),
805                Some(expected),
806                "the new total comes back, so the caller needs no second read"
807            );
808        }
809
810        let reloaded = AdminSession::find_by_token_hash("pending-hash", &db)
811            .await
812            .unwrap()
813            .unwrap();
814        assert_eq!(reloaded.mfa_attempts, 3);
815
816        // A row that is already gone -- swept, promoted, or deleted by whoever
817        // hit the cap first -- is `None`, not an error.
818        AdminSession::delete("pending-hash", &db).await.unwrap();
819        assert_eq!(
820            AdminSession::record_mfa_failure("pending-hash", &db)
821                .await
822                .unwrap(),
823            None
824        );
825    }
826
827    /// A promoted session starts clean: the counter only ever bounded the
828    /// pending row it replaced.
829    #[tokio::test]
830    async fn promotion_does_not_carry_the_attempt_counter_across() {
831        let (db, user) = db_with_user().await;
832        AdminSession::create_pending(
833            NewSession {
834                user_id: &user.id,
835                token_hash: "pending",
836                csrf_token: "csrf",
837                created_ip: None,
838                user_agent: None,
839            },
840            PENDING_TTL,
841            &db,
842        )
843        .await
844        .unwrap();
845        AdminSession::record_mfa_failure("pending", &db)
846            .await
847            .unwrap();
848
849        let promoted = AdminSession::promote("pending", "active", "csrf2", TTL, &db)
850            .await
851            .unwrap()
852            .unwrap();
853        assert_eq!(promoted.mfa_attempts, 0);
854    }
855
856    #[tokio::test]
857    async fn promote_rotates_the_token_and_can_only_happen_once() {
858        let (db, user) = db_with_user().await;
859        AdminSession::create_pending(
860            NewSession {
861                user_id: &user.id,
862                token_hash: "pending-hash",
863                csrf_token: "pending-csrf",
864                created_ip: Some("192.0.2.1".to_string()),
865                user_agent: Some("curl".to_string()),
866            },
867            PENDING_TTL,
868            &db,
869        )
870        .await
871        .unwrap();
872
873        let promoted =
874            AdminSession::promote("pending-hash", "active-hash", "active-csrf", TTL, &db)
875                .await
876                .unwrap()
877                .expect("a pending row must promote");
878
879        // A rotation, not an UPDATE: a new bearer token and a new CSRF token.
880        assert_eq!(promoted.token_hash, "active-hash");
881        assert_ne!(promoted.csrf_token, "pending-csrf");
882        assert_eq!(promoted.state, "active");
883        assert_eq!(promoted.user_id, user.id);
884        // Forensics follow the operator, not the token.
885        assert_eq!(promoted.created_ip.as_deref(), Some("192.0.2.1"));
886        assert_eq!(promoted.user_agent.as_deref(), Some("curl"));
887        assert!(promoted.expires_at - promoted.created_at > PENDING_TTL.as_secs() as i64);
888
889        // The old token is gone the moment the new one exists.
890        assert!(
891            AdminSession::find_by_token_hash("pending-hash", &db)
892                .await
893                .unwrap()
894                .is_none()
895        );
896
897        // The concurrency guard: a second submission of one code promotes
898        // nothing, rather than minting a second session.
899        assert!(
900            AdminSession::promote("pending-hash", "second-hash", "c", TTL, &db)
901                .await
902                .unwrap()
903                .is_none()
904        );
905        assert_eq!(
906            AdminSession::list_all(Some(&user.id), &db)
907                .await
908                .unwrap()
909                .len(),
910            1
911        );
912    }
913
914    /// An `active` session is not something to promote, and must not be
915    /// consumed by an attempt to.
916    #[tokio::test]
917    async fn promote_refuses_a_session_that_is_already_active() {
918        let (db, user) = db_with_user().await;
919        session(db.clone(), &user, "active-hash").await;
920
921        assert!(
922            AdminSession::promote("active-hash", "new-hash", "c", TTL, &db)
923                .await
924                .unwrap()
925                .is_none()
926        );
927        assert!(
928            AdminSession::find_by_token_hash("active-hash", &db)
929                .await
930                .unwrap()
931                .is_some(),
932            "the existing session must survive a refused promotion"
933        );
934    }
935
936    /// The reaper needs no `pending_mfa` special case, and this is what says so:
937    /// a pending row's own short `expires_at` is what sweeps it.
938    #[tokio::test]
939    async fn cleanup_sweeps_an_abandoned_pending_session_and_leaves_a_fresh_one() {
940        let (db, user) = db_with_user().await;
941        AdminSession::create_pending(
942            NewSession {
943                user_id: &user.id,
944                token_hash: "fresh",
945                csrf_token: "c",
946                created_ip: None,
947                user_agent: None,
948            },
949            PENDING_TTL,
950            &db,
951        )
952        .await
953        .unwrap();
954        AdminSession::create_pending(
955            NewSession {
956                user_id: &user.id,
957                token_hash: "abandoned",
958                csrf_token: "c",
959                created_ip: None,
960                user_agent: None,
961            },
962            PENDING_TTL,
963            &db,
964        )
965        .await
966        .unwrap();
967        sqlx::query("UPDATE admin_sessions SET expires_at = ? WHERE token_hash = 'abandoned';")
968            .bind(now_secs() - 1)
969            .execute(&db.pool)
970            .await
971            .unwrap();
972
973        assert_eq!(AdminSession::cleanup(IDLE, &db).await.unwrap(), 1);
974        let left = AdminSession::list_all(None, &db).await.unwrap();
975        assert_eq!(left.len(), 1);
976        assert_eq!(left[0].token_hash, "fresh");
977    }
978
979    #[test]
980    fn expiry_and_idleness_are_judged_at_the_boundary_second() {
981        let base = AdminSession {
982            token_hash: "aaaa".to_string(),
983            user_id: "u".to_string(),
984            csrf_token: "c".to_string(),
985            state: "active".to_string(),
986            mfa_attempts: 0,
987            created_at: 1_000,
988            expires_at: 2_000,
989            last_seen_at: 1_000,
990            created_ip: None,
991            user_agent: None,
992        };
993
994        assert!(!base.is_expired(1_999));
995        assert!(
996            base.is_expired(2_000),
997            "the deadline second is already past"
998        );
999
1000        assert!(!base.is_idle(1_000 + 3_599, IDLE));
1001        assert!(base.is_idle(1_000 + 3_600, IDLE));
1002    }
1003
1004    #[tokio::test]
1005    async fn to_json_never_leaks_the_token_hash_or_the_csrf_token() {
1006        let (db, user) = db_with_user().await;
1007        let created = AdminSession::create(
1008            NewSession {
1009                user_id: &user.id,
1010                token_hash: "0123456789abcdef0123456789abcdef",
1011                csrf_token: "the-csrf-token",
1012                created_ip: None,
1013                user_agent: None,
1014            },
1015            TTL,
1016            &db,
1017        )
1018        .await
1019        .unwrap();
1020
1021        let json = created.to_json();
1022        let rendered = json.to_string();
1023        assert!(!rendered.contains("0123456789abcdef0123456789abcdef"));
1024        assert!(!rendered.contains("the-csrf-token"));
1025        assert_eq!(json["id"], "01234567");
1026        assert_eq!(json["userId"], user.id);
1027        assert_eq!(json["state"], "active");
1028        assert_eq!(json["createdIp"], Value::Null);
1029    }
1030}