Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
Matt W1//! Server-side sessions (spec §6).
Matt W2//!
Matt W3//! "Sessions are server-side rows with an opaque ID in a cookie: HttpOnly,
Matt W4//! Secure, SameSite=Lax, 14-day sliding expiry. Store nothing in the cookie but
Matt W5//! the ID."
Matt W6//!
Matt W7//! The cookie carries a [`generate_token`] value — 32 bytes from the OS CSPRNG,
Matt W8//! hex-encoded — and never the row's `id`. The two are deliberately different
Matt W9//! things: `id` is a UUIDv7, which is time-ordered and carries a counter that
Matt W10//! only reseeds once per millisecond, so ids minted close together share their
Matt W11//! leading bits and any *other* id from the same generator (a comment id, say,
Matt W12//! which is rendered into review pages) narrows the search space for the rest.
Matt W13//! That is fine for a primary key and disqualifying for a bearer secret.
Matt W14//!
Matt W15//! Only the SHA-256 of the token is stored, so a leaked dump of this table
Matt W16//! contains nothing that can be presented back to the server. SHA-256 rather
Matt W17//! than Argon2 on purpose: the token has 256 bits of entropy, so there is no
Matt W18//! guessing attack for a slow hash to frustrate, and this runs on every request.
Matt W19
Matt W20use anyhow::Result;
Matt W21use chrono::{DateTime, Duration, Utc};
Matt W22use df_db::ids::new_id;
Matt W23use df_db::models::{Session, User};
Matt W24use rand::RngCore;
Matt W25use sha2::{Digest, Sha256};
Matt W26use sqlx::PgPool;
Matt W27use uuid::Uuid;
Matt W28
Matt W29pub const COOKIE_NAME: &str = "dogfood_session";
Matt W30
Matt W31/// Entropy in a session token. 32 bytes is the usual bar for a bearer secret
Matt W32/// and leaves no meaningful margin for a birthday collision across the table.
Matt W33const TOKEN_BYTES: usize = 32;
Matt W34
Matt W35/// Length of the hex form, which is what the cookie carries.
Matt W36const TOKEN_HEX_LEN: usize = TOKEN_BYTES * 2;
Matt W37
Matt W38/// A freshly created session. The token exists only here and in the cookie.
Matt W39pub struct NewSession {
Matt W40 pub id: Uuid,
Matt W41 /// The cookie value. Not recoverable from the database afterwards.
Matt W42 pub token: String,
Matt W43 pub expires_at: DateTime<Utc>,
Matt W44}
Matt W45
Matt W46/// Mint a session token.
Matt W47pub fn generate_token() -> String {
Matt W48 let mut bytes = [0u8; TOKEN_BYTES];
Matt W49 rand::thread_rng().fill_bytes(&mut bytes);
Matt W50 hex::encode(bytes)
Matt W51}
Matt W52
Matt W53/// What the database stores in place of the token.
Matt W54fn hash_token(token: &str) -> String {
Matt W55 hex::encode(Sha256::digest(token.as_bytes()))
Matt W56}
Matt W57
Matt W58/// Reject anything that is not shaped like one of our tokens.
Matt W59///
Matt W60/// Cheap, and it means a request carrying a junk cookie — the shape a flood
Matt W61/// takes — is refused before it costs a database round trip.
Matt W62fn well_formed(token: &str) -> bool {
Matt W63 token.len() == TOKEN_HEX_LEN && token.bytes().all(|b| b.is_ascii_hexdigit())
Matt W64}
Matt W65
Matt W66/// Create a session row and return the token that addresses it.
Matt W67pub async fn create(
Matt W68 db: &PgPool,
Matt W69 user_id: Uuid,
Matt W70 ttl_days: i64,
Matt W71 user_agent: Option<&str>,
Matt W72 ip: Option<std::net::IpAddr>,
Matt W73 id_token: Option<&str>,
Matt W74) -> Result<NewSession> {
Matt W75 let id = new_id();
Matt W76 let token = generate_token();
Matt W77 let expires_at = Utc::now() + Duration::days(ttl_days);
Matt W78
Matt W79 sqlx::query(
Matt W80 "INSERT INTO sessions (id, user_id, token_hash, expires_at, user_agent, ip, id_token)
Matt W81 VALUES ($1, $2, $3, $4, $5, $6, $7)",
Matt W82 )
Matt W83 .bind(id)
Matt W84 .bind(user_id)
Matt W85 .bind(hash_token(&token))
Matt W86 .bind(expires_at)
Matt W87 // Truncated: a hostile client can send a multi-kilobyte User-Agent and
Matt W88 // there is no reason to store it.
Matt W89 .bind(user_agent.map(|ua| ua.chars().take(255).collect::<String>()))
Matt W90 .bind(ip.map(sqlx::types::ipnetwork::IpNetwork::from))
Matt W91 .bind(id_token)
Matt W92 .execute(db)
Matt W93 .await?;
Matt W94
Matt W95 Ok(NewSession { id, token, expires_at })
Matt W96}
Matt W97
Matt W98/// Load the user for a session token, if the session exists and has not expired.
Matt W99///
Matt W100/// Returns the session so the caller can decide whether to slide the expiry.
Matt W101pub async fn load(db: &PgPool, token: &str) -> Result<Option<(Session, User)>> {
Matt W102 if !well_formed(token) {
Matt W103 return Ok(None);
Matt W104 }
Matt W105
Matt W106 let row = sqlx::query_as::<_, (Uuid, Uuid, DateTime<Utc>, Uuid, String, String, Option<String>, Option<String>, Option<String>, bool, DateTime<Utc>)>(
Matt W107 "SELECT s.id, s.user_id, s.expires_at,
Matt W108 u.id, u.subject, u.handle, u.display_name, u.email, u.avatar_url,
Matt W109 u.is_admin, u.created_at
Matt W110 FROM sessions s
Matt W111 JOIN users u ON u.id = s.user_id
Matt W112 WHERE s.token_hash = $1 AND s.expires_at > now()",
Matt W113 )
Matt W114 .bind(hash_token(token))
Matt W115 .fetch_optional(db)
Matt W116 .await?;
Matt W117
Matt W118 Ok(row.map(|r| {
Matt W119 (
Matt W120 Session { id: r.0, user_id: r.1, expires_at: r.2 },
Matt W121 User {
Matt W122 id: r.3,
Matt W123 subject: r.4,
Matt W124 handle: r.5,
Matt W125 display_name: r.6,
Matt W126 email: r.7,
Matt W127 avatar_url: r.8,
Matt W128 is_admin: r.9,
Matt W129 created_at: r.10,
Matt W130 },
Matt W131 )
Matt W132 }))
Matt W133}
Matt W134
Matt W135/// Extend a session's expiry — the "sliding" part of the 14-day window.
Matt W136///
Matt W137/// Only worth writing when the session is far enough along that the write is
Matt W138/// not on every request; the caller decides via [`should_slide`].
Matt W139pub async fn slide(db: &PgPool, session_id: Uuid, ttl_days: i64) -> Result<()> {
Matt W140 sqlx::query("UPDATE sessions SET expires_at = $2 WHERE id = $1")
Matt W141 .bind(session_id)
Matt W142 .bind(Utc::now() + Duration::days(ttl_days))
Matt W143 .execute(db)
Matt W144 .await?;
Matt W145 Ok(())
Matt W146}
Matt W147
Matt W148/// Whether a session is worth sliding on this request.
Matt W149///
Matt W150/// Writing on every request would mean a database write per page view. Sliding
Matt W151/// only once the session is past half its life keeps it to roughly one write
Matt W152/// per user per week while still giving active users an unbroken session.
Matt W153pub fn should_slide(expires_at: DateTime<Utc>, ttl_days: i64) -> bool {
Matt W154 let remaining = expires_at - Utc::now();
Matt W155 remaining < Duration::days(ttl_days) / 2
Matt W156}
Matt W157
Matt W158/// Destroy a session, returning the ID token stashed at login.
Matt W159///
Matt W160/// One statement rather than a read followed by a delete: logout needs the
Matt W161/// token for `id_token_hint` on RP-Initiated Logout, and doing it atomically
Matt W162/// means two logouts racing cannot both believe they ended the session.
Matt W163///
Matt W164/// `None` covers every "nothing to hand the provider" case — an unknown token,
Matt W165/// a session created down the pending-handle path with no token to store — and
Matt W166/// callers treat them the same way: clear the local session and go home.
Matt W167pub async fn destroy(db: &PgPool, token: &str) -> Result<Option<String>> {
Matt W168 if !well_formed(token) {
Matt W169 return Ok(None);
Matt W170 }
Matt W171
Matt W172 let row: Option<(Option<String>,)> =
Matt W173 sqlx::query_as("DELETE FROM sessions WHERE token_hash = $1 RETURNING id_token")
Matt W174 .bind(hash_token(token))
Matt W175 .fetch_optional(db)
Matt W176 .await?;
Matt W177 Ok(row.and_then(|r| r.0))
Matt W178}
Matt W179
Matt W180/// Invalidate every session for a user. Used when an account is disabled.
Matt W181pub async fn destroy_all_for_user(db: &PgPool, user_id: Uuid) -> Result<u64> {
Matt W182 let r = sqlx::query("DELETE FROM sessions WHERE user_id = $1")
Matt W183 .bind(user_id)
Matt W184 .execute(db)
Matt W185 .await?;
Matt W186 Ok(r.rows_affected())
Matt W187}
Matt W188
Matt W189/// Delete expired sessions. Run periodically from the worker.
Matt W190pub async fn sweep_expired(db: &PgPool) -> Result<u64> {
Matt W191 let r = sqlx::query("DELETE FROM sessions WHERE expires_at < now()")
Matt W192 .execute(db)
Matt W193 .await?;
Matt W194 Ok(r.rows_affected())
Matt W195}
Matt W196
Matt W197#[cfg(test)]
Matt W198mod tests {
Matt W199 use super::*;
Matt W200
Matt W201 #[test]
Matt W202 fn slides_only_in_the_second_half_of_the_window() {
Matt W203 let ttl = 14;
Matt W204 // Fresh session: no write.
Matt W205 assert!(!should_slide(Utc::now() + Duration::days(14), ttl));
Matt W206 assert!(!should_slide(Utc::now() + Duration::days(8), ttl));
Matt W207 // Past halfway: extend it.
Matt W208 assert!(should_slide(Utc::now() + Duration::days(6), ttl));
Matt W209 assert!(should_slide(Utc::now() + Duration::hours(1), ttl));
Matt W210 }
Matt W211
Matt W212 #[test]
Matt W213 fn an_already_expired_session_would_slide_but_is_never_loaded() {
Matt W214 // load() filters on expires_at > now(), so this case cannot reach
Matt W215 // should_slide in practice; asserted so the interaction stays obvious.
Matt W216 assert!(should_slide(Utc::now() - Duration::days(1), 14));
Matt W217 }
Matt W218
Matt W219 // ─── the token is a bearer secret, not an identifier ─────────────────────
Matt W220
Matt W221 #[test]
Matt W222 fn tokens_are_full_length_hex_and_unique() {
Matt W223 use std::collections::HashSet;
Matt W224
Matt W225 let set: HashSet<String> = (0..1000).map(|_| generate_token()).collect();
Matt W226 assert_eq!(set.len(), 1000, "tokens must not repeat");
Matt W227
Matt W228 for t in &set {
Matt W229 assert_eq!(t.len(), TOKEN_HEX_LEN);
Matt W230 assert!(t.bytes().all(|b| b.is_ascii_hexdigit()), "not hex: {t}");
Matt W231 assert!(well_formed(t));
Matt W232 }
Matt W233 }
Matt W234
Matt W235 #[test]
Matt W236 fn successive_tokens_share_no_prefix() {
Matt W237 // The property a UUIDv7 does not have, and the whole reason for this
Matt W238 // type: consecutive ids from a v7 generator agree in their leading ~96
Matt W239 // bits, so one of them narrows the search space for its neighbours.
Matt W240 let a = generate_token();
Matt W241 let b = generate_token();
Matt W242 let shared = a
Matt W243 .bytes()
Matt W244 .zip(b.bytes())
Matt W245 .take_while(|(x, y)| x == y)
Matt W246 .count();
Matt W247 assert!(shared < 8, "tokens share a {shared}-character prefix: {a} / {b}");
Matt W248 }
Matt W249
Matt W250 #[test]
Matt W251 fn the_stored_hash_is_not_the_token() {
Matt W252 let t = generate_token();
Matt W253 let h = hash_token(&t);
Matt W254 assert_ne!(h, t, "storing the token itself defeats the point");
Matt W255 assert_eq!(h, hash_token(&t), "hashing must be deterministic to look up");
Matt W256 assert_ne!(h, hash_token(&generate_token()));
Matt W257 }
Matt W258
Matt W259 #[test]
Matt W260 fn malformed_cookies_are_refused_before_the_database() {
Matt W261 // Each of these would otherwise be a query per request, which is the
Matt W262 // work an unauthenticated flood gets for free.
Matt W263 for bad in [
Matt W264 "",
Matt W265 "not-hex",
Matt W266 &"a".repeat(TOKEN_HEX_LEN - 1),
Matt W267 &"a".repeat(TOKEN_HEX_LEN + 1),
Matt W268 &"g".repeat(TOKEN_HEX_LEN),
Matt W269 // The old cookie value: a session id must no longer authenticate.
Matt W270 &Uuid::now_v7().to_string(),
Matt W271 ] {
Matt W272 assert!(!well_formed(bad), "must reject {bad:?}");
Matt W273 }
Matt W274 }
Matt W275}

275 lines · Rust