| 1 | //! Server-side sessions (spec §6). |
| 2 | //! |
| 3 | //! "Sessions are server-side rows with an opaque ID in a cookie: HttpOnly, |
| 4 | //! Secure, SameSite=Lax, 14-day sliding expiry. Store nothing in the cookie but |
| 5 | //! the ID." |
| 6 | //! |
| 7 | //! The cookie carries a [`generate_token`] value — 32 bytes from the OS CSPRNG, |
| 8 | //! hex-encoded — and never the row's `id`. The two are deliberately different |
| 9 | //! things: `id` is a UUIDv7, which is time-ordered and carries a counter that |
| 10 | //! only reseeds once per millisecond, so ids minted close together share their |
| 11 | //! leading bits and any *other* id from the same generator (a comment id, say, |
| 12 | //! which is rendered into review pages) narrows the search space for the rest. |
| 13 | //! That is fine for a primary key and disqualifying for a bearer secret. |
| 14 | //! |
| 15 | //! Only the SHA-256 of the token is stored, so a leaked dump of this table |
| 16 | //! contains nothing that can be presented back to the server. SHA-256 rather |
| 17 | //! than Argon2 on purpose: the token has 256 bits of entropy, so there is no |
| 18 | //! guessing attack for a slow hash to frustrate, and this runs on every request. |
| 19 | |
| 20 | use anyhow::Result; |
| 21 | use chrono::{DateTime, Duration, Utc}; |
| 22 | use df_db::ids::new_id; |
| 23 | use df_db::models::{Session, User}; |
| 24 | use rand::RngCore; |
| 25 | use sha2::{Digest, Sha256}; |
| 26 | use sqlx::PgPool; |
| 27 | use uuid::Uuid; |
| 28 | |
| 29 | pub const COOKIE_NAME: &str = "dogfood_session"; |
| 30 | |
| 31 | /// Entropy in a session token. 32 bytes is the usual bar for a bearer secret |
| 32 | /// and leaves no meaningful margin for a birthday collision across the table. |
| 33 | const TOKEN_BYTES: usize = 32; |
| 34 | |
| 35 | /// Length of the hex form, which is what the cookie carries. |
| 36 | const TOKEN_HEX_LEN: usize = TOKEN_BYTES * 2; |
| 37 | |
| 38 | /// A freshly created session. The token exists only here and in the cookie. |
| 39 | pub struct NewSession { |
| 40 | pub id: Uuid, |
| 41 | /// The cookie value. Not recoverable from the database afterwards. |
| 42 | pub token: String, |
| 43 | pub expires_at: DateTime<Utc>, |
| 44 | } |
| 45 | |
| 46 | /// Mint a session token. |
| 47 | pub fn generate_token() -> String { |
| 48 | let mut bytes = [0u8; TOKEN_BYTES]; |
| 49 | rand::thread_rng().fill_bytes(&mut bytes); |
| 50 | hex::encode(bytes) |
| 51 | } |
| 52 | |
| 53 | /// What the database stores in place of the token. |
| 54 | fn hash_token(token: &str) -> String { |
| 55 | hex::encode(Sha256::digest(token.as_bytes())) |
| 56 | } |
| 57 | |
| 58 | /// Reject anything that is not shaped like one of our tokens. |
| 59 | /// |
| 60 | /// Cheap, and it means a request carrying a junk cookie — the shape a flood |
| 61 | /// takes — is refused before it costs a database round trip. |
| 62 | fn well_formed(token: &str) -> bool { |
| 63 | token.len() == TOKEN_HEX_LEN && token.bytes().all(|b| b.is_ascii_hexdigit()) |
| 64 | } |
| 65 | |
| 66 | /// Create a session row and return the token that addresses it. |
| 67 | pub async fn create( |
| 68 | db: &PgPool, |
| 69 | user_id: Uuid, |
| 70 | ttl_days: i64, |
| 71 | user_agent: Option<&str>, |
| 72 | ip: Option<std::net::IpAddr>, |
| 73 | id_token: Option<&str>, |
| 74 | ) -> Result<NewSession> { |
| 75 | let id = new_id(); |
| 76 | let token = generate_token(); |
| 77 | let expires_at = Utc::now() + Duration::days(ttl_days); |
| 78 | |
| 79 | sqlx::query( |
| 80 | "INSERT INTO sessions (id, user_id, token_hash, expires_at, user_agent, ip, id_token) |
| 81 | VALUES ($1, $2, $3, $4, $5, $6, $7)", |
| 82 | ) |
| 83 | .bind(id) |
| 84 | .bind(user_id) |
| 85 | .bind(hash_token(&token)) |
| 86 | .bind(expires_at) |
| 87 | // Truncated: a hostile client can send a multi-kilobyte User-Agent and |
| 88 | // there is no reason to store it. |
| 89 | .bind(user_agent.map(|ua| ua.chars().take(255).collect::<String>())) |
| 90 | .bind(ip.map(sqlx::types::ipnetwork::IpNetwork::from)) |
| 91 | .bind(id_token) |
| 92 | .execute(db) |
| 93 | .await?; |
| 94 | |
| 95 | Ok(NewSession { id, token, expires_at }) |
| 96 | } |
| 97 | |
| 98 | /// Load the user for a session token, if the session exists and has not expired. |
| 99 | /// |
| 100 | /// Returns the session so the caller can decide whether to slide the expiry. |
| 101 | pub async fn load(db: &PgPool, token: &str) -> Result<Option<(Session, User)>> { |
| 102 | if !well_formed(token) { |
| 103 | return Ok(None); |
| 104 | } |
| 105 | |
| 106 | let row = sqlx::query_as::<_, (Uuid, Uuid, DateTime<Utc>, Uuid, String, String, Option<String>, Option<String>, Option<String>, bool, DateTime<Utc>)>( |
| 107 | "SELECT s.id, s.user_id, s.expires_at, |
| 108 | u.id, u.subject, u.handle, u.display_name, u.email, u.avatar_url, |
| 109 | u.is_admin, u.created_at |
| 110 | FROM sessions s |
| 111 | JOIN users u ON u.id = s.user_id |
| 112 | WHERE s.token_hash = $1 AND s.expires_at > now()", |
| 113 | ) |
| 114 | .bind(hash_token(token)) |
| 115 | .fetch_optional(db) |
| 116 | .await?; |
| 117 | |
| 118 | Ok(row.map(|r| { |
| 119 | ( |
| 120 | Session { id: r.0, user_id: r.1, expires_at: r.2 }, |
| 121 | User { |
| 122 | id: r.3, |
| 123 | subject: r.4, |
| 124 | handle: r.5, |
| 125 | display_name: r.6, |
| 126 | email: r.7, |
| 127 | avatar_url: r.8, |
| 128 | is_admin: r.9, |
| 129 | created_at: r.10, |
| 130 | }, |
| 131 | ) |
| 132 | })) |
| 133 | } |
| 134 | |
| 135 | /// Extend a session's expiry — the "sliding" part of the 14-day window. |
| 136 | /// |
| 137 | /// Only worth writing when the session is far enough along that the write is |
| 138 | /// not on every request; the caller decides via [`should_slide`]. |
| 139 | pub async fn slide(db: &PgPool, session_id: Uuid, ttl_days: i64) -> Result<()> { |
| 140 | sqlx::query("UPDATE sessions SET expires_at = $2 WHERE id = $1") |
| 141 | .bind(session_id) |
| 142 | .bind(Utc::now() + Duration::days(ttl_days)) |
| 143 | .execute(db) |
| 144 | .await?; |
| 145 | Ok(()) |
| 146 | } |
| 147 | |
| 148 | /// Whether a session is worth sliding on this request. |
| 149 | /// |
| 150 | /// Writing on every request would mean a database write per page view. Sliding |
| 151 | /// only once the session is past half its life keeps it to roughly one write |
| 152 | /// per user per week while still giving active users an unbroken session. |
| 153 | pub fn should_slide(expires_at: DateTime<Utc>, ttl_days: i64) -> bool { |
| 154 | let remaining = expires_at - Utc::now(); |
| 155 | remaining < Duration::days(ttl_days) / 2 |
| 156 | } |
| 157 | |
| 158 | /// Destroy a session, returning the ID token stashed at login. |
| 159 | /// |
| 160 | /// One statement rather than a read followed by a delete: logout needs the |
| 161 | /// token for `id_token_hint` on RP-Initiated Logout, and doing it atomically |
| 162 | /// means two logouts racing cannot both believe they ended the session. |
| 163 | /// |
| 164 | /// `None` covers every "nothing to hand the provider" case — an unknown token, |
| 165 | /// a session created down the pending-handle path with no token to store — and |
| 166 | /// callers treat them the same way: clear the local session and go home. |
| 167 | pub async fn destroy(db: &PgPool, token: &str) -> Result<Option<String>> { |
| 168 | if !well_formed(token) { |
| 169 | return Ok(None); |
| 170 | } |
| 171 | |
| 172 | let row: Option<(Option<String>,)> = |
| 173 | sqlx::query_as("DELETE FROM sessions WHERE token_hash = $1 RETURNING id_token") |
| 174 | .bind(hash_token(token)) |
| 175 | .fetch_optional(db) |
| 176 | .await?; |
| 177 | Ok(row.and_then(|r| r.0)) |
| 178 | } |
| 179 | |
| 180 | /// Invalidate every session for a user. Used when an account is disabled. |
| 181 | pub async fn destroy_all_for_user(db: &PgPool, user_id: Uuid) -> Result<u64> { |
| 182 | let r = sqlx::query("DELETE FROM sessions WHERE user_id = $1") |
| 183 | .bind(user_id) |
| 184 | .execute(db) |
| 185 | .await?; |
| 186 | Ok(r.rows_affected()) |
| 187 | } |
| 188 | |
| 189 | /// Delete expired sessions. Run periodically from the worker. |
| 190 | pub async fn sweep_expired(db: &PgPool) -> Result<u64> { |
| 191 | let r = sqlx::query("DELETE FROM sessions WHERE expires_at < now()") |
| 192 | .execute(db) |
| 193 | .await?; |
| 194 | Ok(r.rows_affected()) |
| 195 | } |
| 196 | |
| 197 | #[cfg(test)] |
| 198 | mod tests { |
| 199 | use super::*; |
| 200 | |
| 201 | #[test] |
| 202 | fn slides_only_in_the_second_half_of_the_window() { |
| 203 | let ttl = 14; |
| 204 | // Fresh session: no write. |
| 205 | assert!(!should_slide(Utc::now() + Duration::days(14), ttl)); |
| 206 | assert!(!should_slide(Utc::now() + Duration::days(8), ttl)); |
| 207 | // Past halfway: extend it. |
| 208 | assert!(should_slide(Utc::now() + Duration::days(6), ttl)); |
| 209 | assert!(should_slide(Utc::now() + Duration::hours(1), ttl)); |
| 210 | } |
| 211 | |
| 212 | #[test] |
| 213 | fn an_already_expired_session_would_slide_but_is_never_loaded() { |
| 214 | // load() filters on expires_at > now(), so this case cannot reach |
| 215 | // should_slide in practice; asserted so the interaction stays obvious. |
| 216 | assert!(should_slide(Utc::now() - Duration::days(1), 14)); |
| 217 | } |
| 218 | |
| 219 | // ─── the token is a bearer secret, not an identifier ───────────────────── |
| 220 | |
| 221 | #[test] |
| 222 | fn tokens_are_full_length_hex_and_unique() { |
| 223 | use std::collections::HashSet; |
| 224 | |
| 225 | let set: HashSet<String> = (0..1000).map(|_| generate_token()).collect(); |
| 226 | assert_eq!(set.len(), 1000, "tokens must not repeat"); |
| 227 | |
| 228 | for t in &set { |
| 229 | assert_eq!(t.len(), TOKEN_HEX_LEN); |
| 230 | assert!(t.bytes().all(|b| b.is_ascii_hexdigit()), "not hex: {t}"); |
| 231 | assert!(well_formed(t)); |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | #[test] |
| 236 | fn successive_tokens_share_no_prefix() { |
| 237 | // The property a UUIDv7 does not have, and the whole reason for this |
| 238 | // type: consecutive ids from a v7 generator agree in their leading ~96 |
| 239 | // bits, so one of them narrows the search space for its neighbours. |
| 240 | let a = generate_token(); |
| 241 | let b = generate_token(); |
| 242 | let shared = a |
| 243 | .bytes() |
| 244 | .zip(b.bytes()) |
| 245 | .take_while(|(x, y)| x == y) |
| 246 | .count(); |
| 247 | assert!(shared < 8, "tokens share a {shared}-character prefix: {a} / {b}"); |
| 248 | } |
| 249 | |
| 250 | #[test] |
| 251 | fn the_stored_hash_is_not_the_token() { |
| 252 | let t = generate_token(); |
| 253 | let h = hash_token(&t); |
| 254 | assert_ne!(h, t, "storing the token itself defeats the point"); |
| 255 | assert_eq!(h, hash_token(&t), "hashing must be deterministic to look up"); |
| 256 | assert_ne!(h, hash_token(&generate_token())); |
| 257 | } |
| 258 | |
| 259 | #[test] |
| 260 | fn malformed_cookies_are_refused_before_the_database() { |
| 261 | // Each of these would otherwise be a query per request, which is the |
| 262 | // work an unauthenticated flood gets for free. |
| 263 | for bad in [ |
| 264 | "", |
| 265 | "not-hex", |
| 266 | &"a".repeat(TOKEN_HEX_LEN - 1), |
| 267 | &"a".repeat(TOKEN_HEX_LEN + 1), |
| 268 | &"g".repeat(TOKEN_HEX_LEN), |
| 269 | // The old cookie value: a session id must no longer authenticate. |
| 270 | &Uuid::now_v7().to_string(), |
| 271 | ] { |
| 272 | assert!(!well_formed(bad), "must reject {bad:?}"); |
| 273 | } |
| 274 | } |
| 275 | } |
275 lines · Rust