Skip to main content

acme_proxy/sqlite/
admin_session.rs

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