Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
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
20use anyhow::Result;
21use chrono::{DateTime, Duration, Utc};
22use df_db::ids::new_id;
23use df_db::models::{Session, User};
24use rand::RngCore;
25use sha2::{Digest, Sha256};
26use sqlx::PgPool;
27use uuid::Uuid;
28
29pub 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.
33const TOKEN_BYTES: usize = 32;
34
35/// Length of the hex form, which is what the cookie carries.
36const TOKEN_HEX_LEN: usize = TOKEN_BYTES * 2;
37
38/// A freshly created session. The token exists only here and in the cookie.
39pub 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.
47pub 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.
54fn 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.
62fn 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.
67pub 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.
101pub 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`].
139pub 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.
153pub 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.
167pub 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.
181pub 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.
190pub 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)]
198mod 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