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::{PasswordHasher, PasswordVerifier, phc::PasswordHash},
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    // The salt comes from the crate's own generator rather than one built here:
37    // since 0.6 that is what `hash_password` does, and a salt is exactly the
38    // parameter a caller should not be trusted to supply.
39    Argon2::default()
40        .hash_password(password.as_bytes())
41        .map(|hash| hash.to_string())
42        .map_err(|error| anyhow::anyhow!("failed to hash the password: {error}"))
43}
44
45/// Checks a password against a stored hash.
46///
47/// Any failure is `false`, never an error: a malformed hash in the database and
48/// a wrong password are the same answer to whoever is asking, and telling them
49/// apart is information the caller has no business acting on differently.
50pub fn verify_password(password: &str, stored: &str) -> bool {
51    let Ok(parsed) = PasswordHash::new(stored) else {
52        tracing::error!("a stored password hash could not be parsed; the account cannot be logged into");
53        return false;
54    };
55
56    Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
57}
58
59/// A new session token: what the browser gets, and what the database stores.
60pub struct IssuedSession {
61    /// Handed to the client once, in a cookie, and never stored anywhere.
62    pub token: String,
63    pub expires_at: DateTime<Utc>,
64}
65
66/// Creates a session for a user.
67pub async fn issue(pool: &PgPool, user_id: Uuid) -> Result<IssuedSession> {
68    use rand::RngExt;
69
70    // 32 bytes from the OS: the token is the entire credential, so it has to be
71    // unguessable rather than merely unique.
72    let bytes: [u8; 32] = rand::rng().random();
73    let token = bytes.iter().fold(String::with_capacity(64), |mut acc, byte| {
74        use std::fmt::Write;
75        let _ = write!(acc, "{byte:02x}");
76        acc
77    });
78    let expires_at = Utc::now() + Duration::days(SESSION_LIFETIME_DAYS);
79
80    sqlx::query("INSERT INTO sessions (user_id, token_hash, expires_at) VALUES ($1, $2, $3)")
81        .bind(user_id)
82        .bind(hash_token(&token))
83        .bind(expires_at)
84        .execute(pool)
85        .await
86        .context("failed to store the session")?;
87
88    Ok(IssuedSession { token, expires_at })
89}
90
91/// Who a session token belongs to, if it is still good for anything.
92#[derive(Debug, Clone)]
93pub struct SessionUser {
94    pub session_id: Uuid,
95    pub user_id: Uuid,
96    pub role: crate::model::UserRole,
97    /// Read alongside the rest so an audit entry can name the actor without a
98    /// second query per recorded action.
99    pub email: String,
100}
101
102/// Resolves a token to its user, refusing expired sessions and inactive people.
103///
104/// The expiry is checked in the query rather than in Rust: a row that outlived
105/// its welcome must not authenticate anyone even if the sweep that deletes it
106/// has not run.
107pub async fn authenticate(pool: &PgPool, token: &str) -> Result<Option<SessionUser>> {
108    let row: Option<(Uuid, Uuid, crate::model::UserRole, String)> = sqlx::query_as(
109        "SELECT s.id, s.user_id, u.role, u.email FROM sessions s
110         JOIN users u ON u.id = s.user_id
111         WHERE s.token_hash = $1 AND s.expires_at > now() AND u.active",
112    )
113    .bind(hash_token(token))
114    .fetch_optional(pool)
115    .await?;
116
117    let Some((session_id, user_id, role, email)) = row else { return Ok(None) };
118
119    // Rolling expiry, best-effort: someone working through the day should not
120    // be logged out mid-afternoon, and failing to extend costs them nothing
121    // worse than logging in again.
122    if let Err(error) = sqlx::query("UPDATE sessions SET last_used_at = now(), expires_at = now() + ($2 || ' days')::interval WHERE id = $1")
123        .bind(session_id)
124        .bind(SESSION_LIFETIME_DAYS.to_string())
125        .execute(pool)
126        .await
127    {
128        tracing::warn!(%error, %session_id, "failed to extend the session");
129    }
130
131    Ok(Some(SessionUser {
132        session_id,
133        user_id,
134        role,
135        email,
136    }))
137}
138
139/// Ends one session - what "log out" does.
140pub async fn revoke(pool: &PgPool, session_id: Uuid) -> Result<()> {
141    sqlx::query("DELETE FROM sessions WHERE id = $1").bind(session_id).execute(pool).await?;
142    Ok(())
143}
144
145/// Ends every session a user has - what "log out everywhere" does, and what
146/// deactivating an employee should be followed by.
147pub async fn revoke_all(pool: &PgPool, user_id: Uuid) -> Result<u64> {
148    let deleted = sqlx::query("DELETE FROM sessions WHERE user_id = $1")
149        .bind(user_id)
150        .execute(pool)
151        .await?
152        .rows_affected();
153    Ok(deleted)
154}
155
156/// Deletes sessions that have expired.
157///
158/// Not required for correctness - `authenticate` already refuses them - but a
159/// table that only grows is a table nobody wants to meet in a year.
160pub async fn sweep_expired(pool: &PgPool) -> Result<u64> {
161    let deleted = sqlx::query("DELETE FROM sessions WHERE expires_at <= now()")
162        .execute(pool)
163        .await?
164        .rows_affected();
165    Ok(deleted)
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn a_password_verifies_against_its_own_hash_and_nothing_else() {
174        let hash = hash_password("correct horse battery staple").unwrap();
175        assert!(verify_password("correct horse battery staple", &hash));
176        assert!(!verify_password("Correct horse battery staple", &hash), "verification is exact");
177        assert!(!verify_password("", &hash));
178    }
179
180    #[test]
181    fn a_hash_from_an_earlier_release_still_verifies() {
182        // Written before upgrading argon2, from a hash this crate produced at
183        // 0.12.0. Password hashing is the one dependency whose output lives in
184        // the customer's database: a version that stopped reading the previous
185        // format would lock every existing account out of a running
186        // installation, and no migration could recover the passwords.
187        //
188        // The fixture is a hash of a known string generated here, never a real
189        // account's - a repository is the wrong place for either.
190        const RELEASED_0_12_0: &str = "$argon2id$v=19$m=19456,t=2,p=1$IuuVYrGRFHiMJVcyee67FQ$hKIMtAWUiR0BAJvDvau0apBSl9+rv/5T9kWC3aKerjg";
191
192        assert!(
193            verify_password("a fixture password", RELEASED_0_12_0),
194            "an upgrade must not invalidate stored passwords"
195        );
196        assert!(!verify_password("a different password", RELEASED_0_12_0));
197    }
198
199    #[test]
200    fn the_stored_form_reveals_nothing() {
201        let hash = hash_password("hunter2").unwrap();
202        assert!(!hash.contains("hunter2"), "the password must not survive in the hash");
203        assert!(hash.starts_with("$argon2id$"), "a memory-hard hash, not a bare digest: {hash}");
204    }
205
206    #[test]
207    fn the_same_password_hashes_differently_every_time() {
208        // The salt is what makes two employees who chose the same password
209        // indistinguishable in a database dump.
210        let first = hash_password("same").unwrap();
211        let second = hash_password("same").unwrap();
212        assert_ne!(first, second);
213        assert!(verify_password("same", &first) && verify_password("same", &second));
214    }
215
216    #[test]
217    fn a_damaged_hash_refuses_rather_than_admits() {
218        // Truncation, a stray edit in psql, a half-written migration: none of it
219        // may become a way in.
220        assert!(!verify_password("anything", ""));
221        assert!(!verify_password("anything", "not-a-hash"));
222        assert!(!verify_password("anything", "$argon2id$v=19$m=19456,t=2,p=1$truncated"));
223    }
224}