Skip to main content

acme_proxy_store/
admin_session.rs

1//! The web admin's browser sessions — the `admin_sessions` table.
2//!
3//! The row is keyed by a hash of the session token, which never reaches this
4//! crate in the clear ([`AdminSession`] says why). Expiry is not a property of
5//! the sweep: `webadmin::session` checks both deadlines on every request, so an
6//! expired row is refused whether or not [`AdminSession::cleanup`] has run.
7
8use 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/// One logged-in browser session of an [`crate::admin_user::AdminUser`].
20///
21/// **This layer never sees the session token.** `token_hash` arrives already
22/// hashed from `webadmin::session`, which is the only place the plaintext
23/// exists: it goes into a `Set-Cookie` and is never written down. A read of
24/// this table -- a backup, a `.dump`, an injection -- therefore yields nothing
25/// that can be replayed. That is also why there is no bare `find_by_id`: the
26/// hash *is* the lookup key, and knowing it means already holding the token.
27///
28/// [`AdminSession::find_by_user_and_fingerprint`] is not that: it resolves the
29/// *displayed* fingerprint (see `to_json`'s `id`) back to a row, but only
30/// within one caller-supplied `user_id` -- so reaching a row still requires
31/// already being authorized to see that user's sessions, the web panel's
32/// "revoke one of these" buttons being the only reason it exists.
33///
34/// ## Methods
35///
36/// - `create`: persist a session for a user
37/// - `find_by_token_hash`: the per-request resolution path
38/// - `find_by_user_and_fingerprint`: resolve a displayed `id` back to a row,
39///   scoped to the one user it can belong to -- see the note there
40/// - `touch`: advance the idle deadline
41/// - `delete` / `delete_for_user` / `delete_for_user_except`: logout, revoke all,
42///   and the "keep the session doing the changing" case a password change needs
43/// - `list_all`: admin CLI visibility
44/// - `cleanup`: the reaper's sweep
45/// - `to_json`: admin-facing rendering (never the token hash or the CSRF token)
46#[derive(Debug, Clone)]
47pub struct AdminSession {
48    /// Hex-encoded SHA-256 of the cookie's bearer token.
49    pub token_hash: String,
50    pub user_id: Uuid,
51    /// Per-session CSRF token, plaintext: it authorises nothing on its own.
52    pub csrf_token: String,
53    /// `active`, or `pending_mfa` once a second factor exists to be outstanding.
54    pub state: String,
55    /// Second-factor codes rejected against this session. Advanced only by
56    /// [`AdminSession::record_mfa_failure`], which is where the reasoning is.
57    pub mfa_attempts: i64,
58    pub created_at: i64,
59    /// Absolute deadline. Never extended.
60    pub expires_at: i64,
61    /// Idle deadline, advanced by [`AdminSession::touch`].
62    pub last_seen_at: i64,
63    /// Forensics only -- never compared against the request being served.
64    pub created_ip: Option<String>,
65    pub user_agent: Option<String>,
66}
67
68/// Everything a new session row needs from its caller.
69///
70/// A struct rather than three positional `&str`s, and that is a security
71/// property rather than a style one: `token_hash` and `csrf_token` were
72/// adjacent same-typed parameters, so transposing them at a call site compiled
73/// cleanly and produced a session whose CSRF token *is* its session token hash
74/// — a value that has already crossed the wire in a cookie. Named fields make
75/// that unwriteable.
76///
77/// It also retires two `#[allow(clippy::too_many_arguments)]`.
78#[derive(Debug, Clone)]
79pub struct NewSession<'a> {
80    pub user_id: Uuid,
81    /// Hex-encoded SHA-256 of the cookie token. Minted by the caller: this
82    /// layer holds no RNG and so cannot accidentally reuse one.
83    pub token_hash: &'a str,
84    pub csrf_token: &'a str,
85    /// Forensics only -- see the `created_ip` column.
86    pub created_ip: Option<String>,
87    pub user_agent: Option<String>,
88}
89
90/// Every column of `admin_sessions`, in one place: each read must select the same set
91/// or `from_row` fails on whichever forgot one.
92///
93/// A `macro_rules!` rather than a `const` so the expansion is a string
94/// *literal*, which is what `sqlx::query`'s `SqlSafeStr` bound requires.
95macro_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    /// Persists a fresh `active` session expiring `ttl` from now.
119    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    /// Persists a fresh **`pending_mfa`** session: a password was accepted and
128    /// nothing more.
129    ///
130    /// `ttl` is `webadmin::session::PENDING_MFA_TTL`, not the configured session
131    /// lifetime -- a half-authenticated row should not outlive the tab that
132    /// created it, and that short absolute deadline is one of the two bounds on
133    /// how long an attacker holding a password may keep guessing codes (the
134    /// other is [`AdminSession::record_mfa_failure`]).
135    ///
136    /// The reaper needs no special case for these: `expires_at` is set the same
137    /// way, so `cleanup`'s existing `expires_at <= ?` sweeps them.
138    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    /// The body both constructors share. Private: `state` is not a parameter
147    /// any caller outside this file gets to choose, and there is deliberately no
148    /// setter for it -- see [`AdminSession::promote`].
149    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            // Saturating, for the same reason `Nonce::verify`'s cutoff is: a
164            // configured TTL large enough to overflow must not panic.
165            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        // A fingerprint of the *hash*, not the token -- enough to follow one
189        // session across log lines, and derived from something already useless
190        // to a reader.
191        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    /// Replaces a `pending_mfa` session with a fresh `active` one: a new token,
200    /// a new CSRF token, a full `ttl`, and the same user and forensics.
201    ///
202    /// A **rotation**, not an `UPDATE state`. The pending token is a real bearer
203    /// token that a browser stored and that has crossed the wire, minted before
204    /// authentication completed; its privilege level changing means its value
205    /// changes -- the same rule that makes `sign_in` delete whatever session the
206    /// request already carried. The CSRF token rotates for free, which matters
207    /// because the challenge page had to be handed one.
208    ///
209    /// One transaction, and the DELETE's `rows_affected` is the concurrency
210    /// guard: two submissions of one code promote exactly once. `None` means
211    /// nothing pending sat under `pending_token_hash` -- already promoted,
212    /// already swept, or never there.
213    ///
214    /// The side effect worth having: with no setter for `state` anywhere, the
215    /// column is write-once at INSERT, so no code path can move a session
216    /// between states in place.
217    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        // The DELETE, not the SELECT, is what makes this exclusive: two
240        // transactions can both read the pending row, but only one removes it.
241        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            // Deliberately not carried over: the promoted session is a fresh
256            // one, and the counter only ever bounded the pending row it
257            // replaced.
258            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    /// Counts one rejected second-factor code against this session, returning
294    /// the new total.
295    ///
296    /// **The bound an attacker cannot shed.** `webadmin::session::LoginLimiter`
297    /// keys on the peer address, and a `pending_mfa` cookie is valid from any
298    /// address on purpose (see `created_ip`: pinning breaks CGNAT and mobile).
299    /// Somebody holding a correct password could therefore mint one pending
300    /// session and spend `admin.login_max_attempts` guesses per source address,
301    /// which a single IPv6 /64 supplies 2^64 of. This counter travels with the
302    /// session instead, so rotating addresses buys nothing.
303    ///
304    /// One `UPDATE ... RETURNING`, which is also what makes it race-free: this
305    /// listener carries no admission control by design, so a read-then-write
306    /// would let K concurrent submissions all observe zero and each get a free
307    /// guess. `None` means the row is already gone -- swept, promoted, or
308    /// deleted by a concurrent submission that hit the cap first.
309    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    /// The per-request resolution path. Returns the row whatever its state --
334    /// expiry, idleness and `state` are the caller's to judge, since each maps
335    /// to a different refusal.
336    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    /// Resolves a session's displayed fingerprint (`to_json`'s `id`) back to a
353    /// row, scoped to `user_id` -- the web panel's "revoke this session"
354    /// button, for either the caller's own sessions or, on the operators
355    /// surface, another operator's.
356    ///
357    /// Scoping to one user rather than searching every session by fingerprint
358    /// is the whole security property: a caller who has not already been
359    /// authorized to act on `user_id` cannot reach a row this way even by
360    /// observing its fingerprint elsewhere. Built over [`Self::list_all`]
361    /// rather than a dedicated query -- one operator's live sessions are a
362    /// small scan, and the fingerprint is derived, not a column, so there is
363    /// nothing for SQL to compare it against directly.
364    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    /// Advances the idle deadline. Callers rate-limit this -- see
376    /// `webadmin::session::SESSION_TOUCH_INTERVAL` -- because a polling page
377    /// would otherwise take the WAL writer lock on every request.
378    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    /// Logout. Returns whether a row existed.
391    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    /// Revokes every session of one user -- `admin session revoke --user`, and
405    /// the "log me out everywhere" case. Returns how many went.
406    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    /// Revokes every session of one user *except* the one named -- what a
421    /// password change needs, so the operator making it is not logged out by
422    /// their own action while every other browser is.
423    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    /// Revokes every session on the server -- `admin session revoke --all`,
444    /// the "log everybody out" lever after a scare. Returns how many went.
445    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        // `scope` rather than the `user_id = "*"` this used to carry: three
451        // different operations shared this event name, and a magic value in a
452        // field is not a thing an operator can filter on.
453        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    /// Every session, or every session of one user, newest first.
463    ///
464    /// A **scan**, not a listing: its one caller is
465    /// `admin::users::confirm_delete_user`, which counts what the delete will
466    /// cascade to so the prompt can name it. Nothing renders it -- see
467    /// [`AdminUser::list_all`](crate::admin_user::AdminUser::list_all)
468    /// for why that is what lets it sit beside [`AdminSession::search`].
469    pub async fn list_all(
470        user_id: Option<Uuid>,
471        database: &Database,
472    ) -> Result<Vec<AdminSession>, sqlx::Error> {
473        // Two literal statements rather than one built up: `sql::query` takes
474        // only a `&'static str`, which is what stops a column list or a
475        // predicate ever being interpolated in.
476        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    /// One page of the same listing, plus the total those filters match
500    /// unpaged.
501    ///
502    /// `admin session list`'s window. Newest first like [`Self::list_all`], and
503    /// tie-broken on `token_hash` for the same reason `Eab::search` breaks on
504    /// `kid`: `created_at` is a whole second, and a browser signing in twice
505    /// lands two rows inside one.
506    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        // Two literal statements rather than one built up, [`Self::list_all`]'s
513        // reason: `sql::query` takes only a `&'static str`, which is what stops
514        // a column list or a predicate ever being interpolated in.
515        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    /// The reaper's sweep: everything past its absolute deadline, plus
560    /// everything idle longer than `idle_timeout`. Unlike nonces, sessions
561    /// outlive a restart, so a startup-only sweep would leak.
562    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    /// Past its absolute deadline.
584    #[must_use]
585    pub fn is_expired(&self, now: i64) -> bool {
586        now >= self.expires_at
587    }
588
589    /// Unused for longer than `idle_timeout`.
590    #[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    /// Whether the session has completed authentication. A `pending_mfa`
596    /// session has a valid password behind it and nothing more.
597    #[must_use]
598    pub fn is_active(&self) -> bool {
599        self.state == "active"
600    }
601
602    /// The admin-facing rendering. **Never** the token hash (it is the lookup
603    /// key, and printing it in `admin session list` would put every live
604    /// session's key on a terminal) nor the CSRF token.
605    ///
606    /// `id` is a fingerprint of the hash: enough to name one session to
607    /// `admin session revoke`, not enough to reconstruct the key.
608    #[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        // Backdate so the advance is observable within one clock second.
726        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    /// The web panel's "revoke this session" lookup: found within the owning
827    /// user, absent for a fingerprint that belongs to someone else's session or
828    /// to none at all.
829    #[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        // The same fingerprint, scoped to a *different* user, resolves to
844        // nothing -- the property the whole method exists for.
845        assert!(
846            AdminSession::find_by_user_and_fingerprint(bob.id, alice_fp, &db)
847                .await
848                .unwrap()
849                .is_none()
850        );
851
852        // An unknown fingerprint, even under the right user, is `None` too.
853        assert!(
854            AdminSession::find_by_user_and_fingerprint(alice.id, "00000000", &db)
855                .await
856                .unwrap()
857                .is_none()
858        );
859    }
860
861    /// `admin session list`'s window, in both of its forms: the whole table, and
862    /// one operator's. The **total narrows with the filter** -- a page of one
863    /// user's sessions reporting the whole table's count would be a page control
864    /// promising rows it will never show.
865    #[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        // Newest first, and the pages are alice's sessions exactly once. All
889        // three tie on `created_at`, so the `token_hash ASC` tiebreak is what
890        // keeps a row from swapping between the two pages.
891        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    /// The counter the address-keyed limiter cannot provide, and the reason it
968    /// is one `UPDATE ... RETURNING` rather than a read-then-write.
969    #[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        // A row that is already gone -- swept, promoted, or deleted by whoever
1003        // hit the cap first -- is `None`, not an error.
1004        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    /// A promoted session starts clean: the counter only ever bounded the
1014    /// pending row it replaced.
1015    #[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        // A rotation, not an UPDATE: a new bearer token and a new CSRF token.
1066        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        // Forensics follow the operator, not the token.
1071        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        // The old token is gone the moment the new one exists.
1076        assert!(
1077            AdminSession::find_by_token_hash("pending-hash", &db)
1078                .await
1079                .unwrap()
1080                .is_none()
1081        );
1082
1083        // The concurrency guard: a second submission of one code promotes
1084        // nothing, rather than minting a second session.
1085        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    /// An `active` session is not something to promote, and must not be
1101    /// consumed by an attempt to.
1102    #[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    /// The reaper needs no `pending_mfa` special case, and this is what says so:
1123    /// a pending row's own short `expires_at` is what sweeps it.
1124    #[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}