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)]
91pub struct SessionUser {
92    pub session_id: Uuid,
93    pub user_id: Uuid,
94    pub role: crate::model::UserRole,
95    /// Read alongside the rest so an audit entry can name the actor without a
96    /// second query per recorded action.
97    pub email: String,
98}
99
100/// Resolves a token to its user, refusing expired sessions and inactive people.
101///
102/// The expiry is checked in the query rather than in Rust: a row that outlived
103/// its welcome must not authenticate anyone even if the sweep that deletes it
104/// has not run.
105pub async fn authenticate(pool: &PgPool, token: &str) -> Result<Option<SessionUser>> {
106    let row: Option<(Uuid, Uuid, crate::model::UserRole, String)> = sqlx::query_as(
107        "SELECT s.id, s.user_id, u.role, u.email FROM sessions s
108         JOIN users u ON u.id = s.user_id
109         WHERE s.token_hash = $1 AND s.expires_at > now() AND u.active",
110    )
111    .bind(hash_token(token))
112    .fetch_optional(pool)
113    .await?;
114
115    let Some((session_id, user_id, role, email)) = row else { return Ok(None) };
116
117    // Rolling expiry, best-effort: someone working through the day should not
118    // be logged out mid-afternoon, and failing to extend costs them nothing
119    // worse than logging in again.
120    if let Err(error) = sqlx::query("UPDATE sessions SET last_used_at = now(), expires_at = now() + ($2 || ' days')::interval WHERE id = $1")
121        .bind(session_id)
122        .bind(SESSION_LIFETIME_DAYS.to_string())
123        .execute(pool)
124        .await
125    {
126        tracing::warn!(%error, %session_id, "failed to extend the session");
127    }
128
129    Ok(Some(SessionUser {
130        session_id,
131        user_id,
132        role,
133        email,
134    }))
135}
136
137/// Ends one session - what "log out" does.
138pub async fn revoke(pool: &PgPool, session_id: Uuid) -> Result<()> {
139    sqlx::query("DELETE FROM sessions WHERE id = $1").bind(session_id).execute(pool).await?;
140    Ok(())
141}
142
143/// Ends every session a user has - what "log out everywhere" does, and what
144/// deactivating an employee should be followed by.
145pub async fn revoke_all(pool: &PgPool, user_id: Uuid) -> Result<u64> {
146    let deleted = sqlx::query("DELETE FROM sessions WHERE user_id = $1")
147        .bind(user_id)
148        .execute(pool)
149        .await?
150        .rows_affected();
151    Ok(deleted)
152}
153
154/// Deletes sessions that have expired.
155///
156/// Not required for correctness - `authenticate` already refuses them - but a
157/// table that only grows is a table nobody wants to meet in a year.
158pub async fn sweep_expired(pool: &PgPool) -> Result<u64> {
159    let deleted = sqlx::query("DELETE FROM sessions WHERE expires_at <= now()")
160        .execute(pool)
161        .await?
162        .rows_affected();
163    Ok(deleted)
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn a_password_verifies_against_its_own_hash_and_nothing_else() {
172        let hash = hash_password("correct horse battery staple").unwrap();
173        assert!(verify_password("correct horse battery staple", &hash));
174        assert!(!verify_password("Correct horse battery staple", &hash), "verification is exact");
175        assert!(!verify_password("", &hash));
176    }
177
178    #[test]
179    fn the_stored_form_reveals_nothing() {
180        let hash = hash_password("hunter2").unwrap();
181        assert!(!hash.contains("hunter2"), "the password must not survive in the hash");
182        assert!(hash.starts_with("$argon2id$"), "a memory-hard hash, not a bare digest: {hash}");
183    }
184
185    #[test]
186    fn the_same_password_hashes_differently_every_time() {
187        // The salt is what makes two employees who chose the same password
188        // indistinguishable in a database dump.
189        let first = hash_password("same").unwrap();
190        let second = hash_password("same").unwrap();
191        assert_ne!(first, second);
192        assert!(verify_password("same", &first) && verify_password("same", &second));
193    }
194
195    #[test]
196    fn a_damaged_hash_refuses_rather_than_admits() {
197        // Truncation, a stray edit in psql, a half-written migration: none of it
198        // may become a way in.
199        assert!(!verify_password("anything", ""));
200        assert!(!verify_password("anything", "not-a-hash"));
201        assert!(!verify_password("anything", "$argon2id$v=19$m=19456,t=2,p=1$truncated"));
202    }
203}