Skip to main content

kasl_server/
session.rs

1//! Passwords and browser sessions.
2//!
3//! Two kinds of credential live on this server and they are deliberately not
4//! the same thing. An agent presents a long random token the server issued,
5//! hashed with SHA-256 because there is no dictionary to slow anyone down with
6//! (see [`crate::auth`]). A person types a password they chose, which is
7//! guessable at scale, so it gets Argon2id and a per-password salt.
8//!
9//! Sessions are server-side. A signed self-contained token would save a query
10//! per request and cost the one thing this server cannot give up: the ability
11//! to end someone's access now, on the afternoon they leave (ADR 0007).
12
13use anyhow::{Context, Result};
14use argon2::{
15    Argon2,
16    password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
17};
18use chrono::{DateTime, Duration, Utc};
19use sqlx::PgPool;
20use uuid::Uuid;
21
22use crate::auth::hash_token;
23
24/// How long a session lives without being used.
25///
26/// A working fortnight: long enough that nobody logs in twice a day, short
27/// enough that a forgotten laptop stops being a way in.
28pub const SESSION_LIFETIME_DAYS: i64 = 14;
29
30/// The cookie the browser carries. Named for the product so it is obvious in a
31/// developer console which server put it there.
32pub const SESSION_COOKIE: &str = "kasl_session";
33
34/// Hashes a password for storage.
35pub fn hash_password(password: &str) -> Result<String> {
36    let salt = SaltString::generate(&mut OsRng);
37    Argon2::default()
38        .hash_password(password.as_bytes(), &salt)
39        .map(|hash| hash.to_string())
40        .map_err(|error| anyhow::anyhow!("failed to hash the password: {error}"))
41}
42
43/// Checks a password against a stored hash.
44///
45/// Any failure is `false`, never an error: a malformed hash in the database and
46/// a wrong password are the same answer to whoever is asking, and telling them
47/// apart is information the caller has no business acting on differently.
48pub fn verify_password(password: &str, stored: &str) -> bool {
49    let Ok(parsed) = PasswordHash::new(stored) else {
50        tracing::error!("a stored password hash could not be parsed; the account cannot be logged into");
51        return false;
52    };
53
54    Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
55}
56
57/// A new session token: what the browser gets, and what the database stores.
58pub struct IssuedSession {
59    /// Handed to the client once, in a cookie, and never stored anywhere.
60    pub token: String,
61    pub expires_at: DateTime<Utc>,
62}
63
64/// Creates a session for a user.
65pub async fn issue(pool: &PgPool, user_id: Uuid) -> Result<IssuedSession> {
66    use rand::RngExt;
67
68    // 32 bytes from the OS: the token is the entire credential, so it has to be
69    // unguessable rather than merely unique.
70    let bytes: [u8; 32] = rand::rng().random();
71    let token = bytes.iter().fold(String::with_capacity(64), |mut acc, byte| {
72        use std::fmt::Write;
73        let _ = write!(acc, "{byte:02x}");
74        acc
75    });
76    let expires_at = Utc::now() + Duration::days(SESSION_LIFETIME_DAYS);
77
78    sqlx::query("INSERT INTO sessions (user_id, token_hash, expires_at) VALUES ($1, $2, $3)")
79        .bind(user_id)
80        .bind(hash_token(&token))
81        .bind(expires_at)
82        .execute(pool)
83        .await
84        .context("failed to store the session")?;
85
86    Ok(IssuedSession { token, expires_at })
87}
88
89/// Who a session token belongs to, if it is still good for anything.
90#[derive(Debug, Clone, Copy)]
91pub struct SessionUser {
92    pub session_id: Uuid,
93    pub user_id: Uuid,
94    pub role: crate::model::UserRole,
95}
96
97/// Resolves a token to its user, refusing expired sessions and inactive people.
98///
99/// The expiry is checked in the query rather than in Rust: a row that outlived
100/// its welcome must not authenticate anyone even if the sweep that deletes it
101/// has not run.
102pub async fn authenticate(pool: &PgPool, token: &str) -> Result<Option<SessionUser>> {
103    let row: Option<(Uuid, Uuid, crate::model::UserRole)> = sqlx::query_as(
104        "SELECT s.id, s.user_id, u.role FROM sessions s
105         JOIN users u ON u.id = s.user_id
106         WHERE s.token_hash = $1 AND s.expires_at > now() AND u.active",
107    )
108    .bind(hash_token(token))
109    .fetch_optional(pool)
110    .await?;
111
112    let Some((session_id, user_id, role)) = row else { return Ok(None) };
113
114    // Rolling expiry, best-effort: someone working through the day should not
115    // be logged out mid-afternoon, and failing to extend costs them nothing
116    // worse than logging in again.
117    if let Err(error) = sqlx::query("UPDATE sessions SET last_used_at = now(), expires_at = now() + ($2 || ' days')::interval WHERE id = $1")
118        .bind(session_id)
119        .bind(SESSION_LIFETIME_DAYS.to_string())
120        .execute(pool)
121        .await
122    {
123        tracing::warn!(%error, %session_id, "failed to extend the session");
124    }
125
126    Ok(Some(SessionUser { session_id, user_id, role }))
127}
128
129/// Ends one session - what "log out" does.
130pub async fn revoke(pool: &PgPool, session_id: Uuid) -> Result<()> {
131    sqlx::query("DELETE FROM sessions WHERE id = $1").bind(session_id).execute(pool).await?;
132    Ok(())
133}
134
135/// Ends every session a user has - what "log out everywhere" does, and what
136/// deactivating an employee should be followed by.
137pub async fn revoke_all(pool: &PgPool, user_id: Uuid) -> Result<u64> {
138    let deleted = sqlx::query("DELETE FROM sessions WHERE user_id = $1")
139        .bind(user_id)
140        .execute(pool)
141        .await?
142        .rows_affected();
143    Ok(deleted)
144}
145
146/// Deletes sessions that have expired.
147///
148/// Not required for correctness - `authenticate` already refuses them - but a
149/// table that only grows is a table nobody wants to meet in a year.
150pub async fn sweep_expired(pool: &PgPool) -> Result<u64> {
151    let deleted = sqlx::query("DELETE FROM sessions WHERE expires_at <= now()")
152        .execute(pool)
153        .await?
154        .rows_affected();
155    Ok(deleted)
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn a_password_verifies_against_its_own_hash_and_nothing_else() {
164        let hash = hash_password("correct horse battery staple").unwrap();
165        assert!(verify_password("correct horse battery staple", &hash));
166        assert!(!verify_password("Correct horse battery staple", &hash), "verification is exact");
167        assert!(!verify_password("", &hash));
168    }
169
170    #[test]
171    fn the_stored_form_reveals_nothing() {
172        let hash = hash_password("hunter2").unwrap();
173        assert!(!hash.contains("hunter2"), "the password must not survive in the hash");
174        assert!(hash.starts_with("$argon2id$"), "a memory-hard hash, not a bare digest: {hash}");
175    }
176
177    #[test]
178    fn the_same_password_hashes_differently_every_time() {
179        // The salt is what makes two employees who chose the same password
180        // indistinguishable in a database dump.
181        let first = hash_password("same").unwrap();
182        let second = hash_password("same").unwrap();
183        assert_ne!(first, second);
184        assert!(verify_password("same", &first) && verify_password("same", &second));
185    }
186
187    #[test]
188    fn a_damaged_hash_refuses_rather_than_admits() {
189        // Truncation, a stray edit in psql, a half-written migration: none of it
190        // may become a way in.
191        assert!(!verify_password("anything", ""));
192        assert!(!verify_password("anything", "not-a-hash"));
193        assert!(!verify_password("anything", "$argon2id$v=19$m=19456,t=2,p=1$truncated"));
194    }
195}