Skip to main content

sl_map_web/
auth.rs

1//! Authentication primitives: extractors, middleware, password hashing,
2//! token generation, and session helpers.
3
4use std::time::Duration;
5
6use argon2::password_hash::{Salt, SaltString};
7use argon2::{Argon2, PasswordHash, PasswordHasher as _, PasswordVerifier as _};
8use axum::extract::{FromRequestParts, Request, State};
9use axum::http::header;
10use axum::http::request::Parts;
11use axum::middleware::Next;
12use axum::response::{IntoResponse as _, Redirect, Response};
13use axum_extra::extract::cookie::{Cookie, Key, SameSite, SignedCookieJar};
14use base64::Engine as _;
15use chrono::{DateTime, Utc};
16use rand::Rng as _;
17use secrecy::ExposeSecret as _;
18use sha2::{Digest as _, Sha256};
19use sqlx::SqlitePool;
20use subtle::ConstantTimeEq as _;
21use uuid::Uuid;
22
23use crate::config::Config;
24use crate::error::Error;
25use crate::state::AppState;
26
27/// `(user_id_bytes, legacy_name, username, sessions.expires_at)` — the
28/// columns selected in the [`CurrentUser`] extractor's session/user join.
29pub type SessionRow = (Vec<u8>, String, String, DateTime<Utc>);
30
31/// `(user_id_bytes, expires_at, used_at)` — columns selected when looking
32/// up a set-password token.
33pub type SetPasswordTokenRow = (Vec<u8>, DateTime<Utc>, Option<DateTime<Utc>>);
34
35/// Cookie attributes used for the session cookie. Centralised so the login
36/// handler and the logout (cookie-removal) handler agree on `Path`, `Secure`,
37/// `SameSite`, and the cookie name.
38fn build_session_cookie<'a>(
39    name: String,
40    value: String,
41    cfg: &Config,
42    max_age_seconds: i64,
43) -> Cookie<'a> {
44    let mut cookie = Cookie::new(name, value);
45    cookie.set_path("/");
46    cookie.set_http_only(true);
47    cookie.set_secure(cfg.cookie_secure);
48    cookie.set_same_site(SameSite::Lax);
49    cookie.set_max_age(time::Duration::seconds(max_age_seconds));
50    cookie
51}
52
53/// Build a removal cookie that clears the session on the client.
54#[must_use]
55pub fn removal_cookie<'a>(cfg: &Config) -> Cookie<'a> {
56    let mut cookie = Cookie::new(cfg.cookie_name.clone(), String::new());
57    cookie.set_path("/");
58    cookie.set_http_only(true);
59    cookie.set_secure(cfg.cookie_secure);
60    cookie.set_same_site(SameSite::Lax);
61    cookie.set_max_age(time::Duration::seconds(0));
62    cookie
63}
64
65/// Information about the currently authenticated user, attached to handlers
66/// that opt in to the [`CurrentUser`] extractor.
67#[derive(Debug, Clone)]
68pub struct CurrentUser {
69    /// the SL avatar UUID (primary key in the `users` table).
70    pub user_id: Uuid,
71    /// the legacy "Firstname Lastname" form, used for UI display.
72    pub legacy_name: String,
73    /// the `firstname.lastname` form, used as the login username.
74    pub username: String,
75}
76
77impl FromRequestParts<AppState> for CurrentUser {
78    type Rejection = Error;
79
80    async fn from_request_parts(
81        parts: &mut Parts,
82        state: &AppState,
83    ) -> Result<Self, Self::Rejection> {
84        let jar = SignedCookieJar::<Key>::from_headers(&parts.headers, state.cookie_key.clone());
85        let Some(cookie) = jar.get(&state.config.cookie_name) else {
86            return Err(Error::Unauthenticated);
87        };
88        let Some(session_id) = decode_session_id(cookie.value()) else {
89            return Err(Error::Unauthenticated);
90        };
91        let session_id_hash = Sha256::digest(session_id).to_vec();
92        let now = Utc::now();
93        // load the session and its user in one shot; reject if expired.
94        let row: Option<SessionRow> = sqlx::query_as(
95            "SELECT users.user_id, users.legacy_name, users.username, sessions.expires_at \
96             FROM sessions \
97             JOIN users ON users.user_id = sessions.user_id \
98             WHERE sessions.session_id_hash = ?1",
99        )
100        .bind(session_id_hash.as_slice())
101        .fetch_optional(&state.db)
102        .await
103        .map_err(|err| {
104            tracing::error!("session lookup failed: {err}");
105            Error::Database
106        })?;
107        let Some((uid_bytes, legacy_name, username, expires_at)) = row else {
108            return Err(Error::Unauthenticated);
109        };
110        if expires_at <= now {
111            return Err(Error::Unauthenticated);
112        }
113        let user_id = uuid_from_bytes(&uid_bytes).ok_or(Error::Unauthenticated)?;
114        // best-effort bump of last_seen_at; failures here should not break
115        // request handling. Throttled in SQL: chatty clients (SSE, polling)
116        // would otherwise hold SQLite's writer lock on every request and
117        // serialise all other writes behind them.
118        // Fallback to MIN_UTC if the subtraction underflows (impossible in
119        // practice — would require `now` near year -262144); a MIN_UTC
120        // cutoff matches no rows, so we simply skip the bump that tick.
121        let stale_cutoff = now
122            .checked_sub_signed(chrono::Duration::seconds(60))
123            .unwrap_or(chrono::DateTime::<Utc>::MIN_UTC);
124        if let Err(err) = sqlx::query(
125            "UPDATE sessions SET last_seen_at = ?1 \
126             WHERE session_id_hash = ?2 AND last_seen_at < ?3",
127        )
128        .bind(now)
129        .bind(session_id_hash.as_slice())
130        .bind(stale_cutoff)
131        .execute(&state.db)
132        .await
133        {
134            tracing::warn!("failed to bump sessions.last_seen_at: {err}");
135        }
136        Ok(Self {
137            user_id,
138            legacy_name,
139            username,
140        })
141    }
142}
143
144/// Middleware that enforces a valid session on the wrapped routes.
145///
146/// On reject:
147/// * a GET request that prefers `text/html` is redirected to `/login?next=...`
148/// * any other request gets a JSON 401.
149///
150/// # Errors
151///
152/// Returns the [`Error::Unauthenticated`] variant; the
153/// [`axum::response::IntoResponse`] impl will turn it into the appropriate
154/// response.
155pub async fn require_session(State(state): State<AppState>, req: Request, next: Next) -> Response {
156    let (mut parts, body) = req.into_parts();
157    match CurrentUser::from_request_parts(&mut parts, &state).await {
158        Ok(user) => {
159            parts.extensions.insert(user);
160            let req = Request::from_parts(parts, body);
161            next.run(req).await
162        }
163        Err(_) => {
164            let wants_html = parts.method == axum::http::Method::GET && prefers_html(&parts);
165            if wants_html {
166                let next_url = parts
167                    .uri
168                    .path_and_query()
169                    .map_or_else(|| "/".to_owned(), |pq| pq.as_str().to_owned());
170                let encoded = percent_encode(&next_url).unwrap_or_else(|| "/".to_owned());
171                let target = format!("/login?next={encoded}");
172                Redirect::to(&target).into_response()
173            } else {
174                Error::Unauthenticated.into_response()
175            }
176        }
177    }
178}
179
180/// Heuristic: does the request prefer `text/html`? We don't need a full
181/// content-negotiation parser; presence of `text/html` in `Accept` is enough
182/// for the redirect-vs-401 decision.
183fn prefers_html(parts: &Parts) -> bool {
184    parts
185        .headers
186        .get(header::ACCEPT)
187        .and_then(|v| v.to_str().ok())
188        .is_some_and(|s| s.contains("text/html"))
189}
190
191/// Percent-encode a path so it's safe inside a query parameter. Returns
192/// `None` only if the input contains characters that cannot be represented.
193fn percent_encode(input: &str) -> Option<String> {
194    // Restrict to characters that are safe unescaped inside a query
195    // parameter value. Everything else is percent-encoded.
196    let mut out = String::with_capacity(input.len());
197    for byte in input.bytes() {
198        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~' | b'/') {
199            out.push(char::from(byte));
200        } else {
201            use std::fmt::Write as _;
202            write!(out, "%{byte:02X}").ok()?;
203        }
204    }
205    Some(out)
206}
207
208/// Bearer-token guard for the LSL-facing registration endpoint. Constant-
209/// time-compares against the configured shared secret.
210#[derive(Debug)]
211pub struct LslBearer;
212
213impl FromRequestParts<AppState> for LslBearer {
214    type Rejection = Error;
215
216    async fn from_request_parts(
217        parts: &mut Parts,
218        state: &AppState,
219    ) -> Result<Self, Self::Rejection> {
220        let Some(value) = parts.headers.get(header::AUTHORIZATION) else {
221            return Err(Error::Unauthenticated);
222        };
223        let bytes = value.as_bytes();
224        let prefix = b"Bearer ";
225        if bytes.len() <= prefix.len() || !bytes.starts_with(prefix) {
226            return Err(Error::Unauthenticated);
227        }
228        let presented = bytes.get(prefix.len()..).unwrap_or(&[]);
229        let expected = state
230            .config
231            .lsl_registration_bearer_token
232            .expose_secret()
233            .as_bytes();
234        // Run ct_eq over `expected.len()` bytes on every path so the
235        // mismatched-length case takes the same time as a wrong-content
236        // compare and the bearer's length doesn't leak via timing.
237        let len_ok = presented.len() == expected.len();
238        let lhs = if len_ok { presented } else { expected };
239        let eq: bool = lhs.ct_eq(expected).into();
240        if len_ok && eq {
241            Ok(Self)
242        } else {
243            Err(Error::Unauthenticated)
244        }
245    }
246}
247
248/// Hash a password using Argon2id with default parameters and a fresh
249/// random salt.
250///
251/// # Errors
252///
253/// Returns [`Error::PasswordHash`] if the underlying hasher fails (which
254/// should not happen in practice — it can only fail on out-of-memory).
255pub fn hash_password(password: &str) -> Result<String, Error> {
256    let mut salt_bytes = [0_u8; Salt::RECOMMENDED_LENGTH];
257    rand::rng().fill_bytes(&mut salt_bytes);
258    let salt =
259        SaltString::encode_b64(&salt_bytes).map_err(|err| Error::PasswordHash(err.to_string()))?;
260    let argon = Argon2::default();
261    let hash = argon
262        .hash_password(password.as_bytes(), &salt)
263        .map_err(|err| Error::PasswordHash(err.to_string()))?;
264    Ok(hash.to_string())
265}
266
267/// Verify a password against a PHC-encoded Argon2 hash. Returns `Ok(false)`
268/// on mismatch and `Ok(true)` on match.
269///
270/// # Errors
271///
272/// Returns [`Error::PasswordHash`] if the stored hash cannot be parsed.
273pub fn verify_password(password: &str, stored_hash: &str) -> Result<bool, Error> {
274    let parsed =
275        PasswordHash::new(stored_hash).map_err(|err| Error::PasswordHash(err.to_string()))?;
276    match Argon2::default().verify_password(password.as_bytes(), &parsed) {
277        Ok(()) => Ok(true),
278        Err(argon2::password_hash::Error::Password) => Ok(false),
279        Err(err) => Err(Error::PasswordHash(err.to_string())),
280    }
281}
282
283/// Generate a fresh 32-byte random token and return both the raw bytes
284/// (to be base64-encoded into the link) and the SHA-256 hash (to be stored
285/// in the DB).
286#[must_use]
287pub fn generate_token() -> (String, Vec<u8>) {
288    let mut raw = [0_u8; 32];
289    rand::rng().fill_bytes(&mut raw);
290    let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
291    let hash = Sha256::digest(raw).to_vec();
292    (encoded, hash)
293}
294
295/// Hash an incoming token string (the one carried in the URL) so it can be
296/// looked up in the `set_password_tokens` table.
297#[must_use]
298pub fn hash_token(raw_token: &str) -> Option<Vec<u8>> {
299    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
300        .decode(raw_token)
301        .ok()?;
302    if bytes.len() != 32 {
303        return None;
304    }
305    Some(Sha256::digest(&bytes).to_vec())
306}
307
308/// Generate a fresh 32-byte session id. The id is what goes into the cookie
309/// (base64-encoded, then signed) and what we look up in the `sessions`
310/// table.
311#[must_use]
312pub fn generate_session_id() -> ([u8; 32], String) {
313    let mut raw = [0_u8; 32];
314    rand::rng().fill_bytes(&mut raw);
315    let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
316    (raw, encoded)
317}
318
319/// Decode a session-id cookie value back into the 32 raw bytes.
320fn decode_session_id(value: &str) -> Option<[u8; 32]> {
321    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
322        .decode(value)
323        .ok()?;
324    if bytes.len() != 32 {
325        return None;
326    }
327    let mut out = [0_u8; 32];
328    out.copy_from_slice(&bytes);
329    Some(out)
330}
331
332/// Convert a 16-byte SQLite BLOB back into a [`Uuid`]. Returns `None` if the
333/// blob is the wrong length.
334#[must_use]
335pub fn uuid_from_bytes(bytes: &[u8]) -> Option<Uuid> {
336    let arr: [u8; 16] = bytes.try_into().ok()?;
337    Some(Uuid::from_bytes(arr))
338}
339
340/// Insert a fresh session for the given user and produce the signed cookie
341/// to set on the response.
342///
343/// # Errors
344///
345/// Returns [`Error::Database`] on insert failure.
346pub async fn create_session(
347    pool: &SqlitePool,
348    cfg: &Config,
349    user_id: Uuid,
350    client_ip: Option<String>,
351) -> Result<Cookie<'static>, Error> {
352    let (raw, encoded) = generate_session_id();
353    let now = Utc::now();
354    let expires_at = now
355        .checked_add_signed(chrono::Duration::seconds(cfg.session_ttl_seconds))
356        .unwrap_or(chrono::DateTime::<Utc>::MAX_UTC);
357    let session_id_hash = Sha256::digest(raw).to_vec();
358    sqlx::query(
359        "INSERT INTO sessions \
360            (session_id_hash, user_id, expires_at, created_at, last_seen_at, client_ip) \
361         VALUES (?1, ?2, ?3, ?4, ?4, ?5)",
362    )
363    .bind(session_id_hash)
364    .bind(user_id.as_bytes().to_vec())
365    .bind(expires_at)
366    .bind(now)
367    .bind(client_ip)
368    .execute(pool)
369    .await
370    .map_err(|err| {
371        tracing::error!("failed to insert session row: {err}");
372        Error::Database
373    })?;
374    Ok(build_session_cookie(
375        cfg.cookie_name.clone(),
376        encoded,
377        cfg,
378        cfg.session_ttl_seconds,
379    ))
380}
381
382/// Delete a session row by the raw 32-byte id from the cookie. Used on
383/// logout. Hashes internally so callers continue to pass cookie bytes.
384///
385/// # Errors
386///
387/// Returns [`Error::Database`] on delete failure.
388pub async fn delete_session(pool: &SqlitePool, session_id: &[u8]) -> Result<(), Error> {
389    let session_id_hash = Sha256::digest(session_id).to_vec();
390    sqlx::query("DELETE FROM sessions WHERE session_id_hash = ?1")
391        .bind(session_id_hash)
392        .execute(pool)
393        .await
394        .map_err(|err| {
395            tracing::error!("failed to delete session row: {err}");
396            Error::Database
397        })?;
398    Ok(())
399}
400
401/// Look up the raw session id from the cookie jar of the current request.
402/// Returns `None` if there is no cookie or the cookie does not decode.
403#[must_use]
404pub fn session_id_from_jar(jar: &SignedCookieJar, cookie_name: &str) -> Option<[u8; 32]> {
405    let cookie = jar.get(cookie_name)?;
406    decode_session_id(cookie.value())
407}
408
409/// `(user_id_bytes, legacy_name, username, password_hash)` — the four
410/// columns returned by every users lookup variant.
411pub type UserRow = (Vec<u8>, String, String, Option<String>);
412
413/// Look up a user row by identifier. Tries (in order): UUID parse,
414/// `firstname.lastname` username, "Firstname Lastname" legacy name. Used by
415/// both the login flow and the invitation creation flow, which is why it
416/// lives here in `auth.rs` rather than in a route module.
417///
418/// # Errors
419///
420/// Returns [`Error::Database`] if any of the DB lookups fail.
421pub async fn lookup_user_by_identifier(
422    pool: &SqlitePool,
423    identifier: &str,
424) -> Result<Option<UserRow>, Error> {
425    if let Ok(uuid) = Uuid::parse_str(identifier) {
426        let bytes = uuid.as_bytes().to_vec();
427        let row: Option<UserRow> = sqlx::query_as(
428            "SELECT user_id, legacy_name, username, password_hash FROM users WHERE user_id = ?1",
429        )
430        .bind(bytes)
431        .fetch_optional(pool)
432        .await
433        .map_err(|err| {
434            tracing::error!("user lookup (uuid) failed: {err}");
435            Error::Database
436        })?;
437        if row.is_some() {
438            return Ok(row);
439        }
440    }
441    let by_username: Option<UserRow> = sqlx::query_as(
442        "SELECT user_id, legacy_name, username, password_hash FROM users WHERE username = ?1",
443    )
444    .bind(identifier)
445    .fetch_optional(pool)
446    .await
447    .map_err(|err| {
448        tracing::error!("user lookup (username) failed: {err}");
449        Error::Database
450    })?;
451    if by_username.is_some() {
452        return Ok(by_username);
453    }
454    let by_legacy: Option<UserRow> = sqlx::query_as(
455        "SELECT user_id, legacy_name, username, password_hash FROM users WHERE legacy_name = ?1",
456    )
457    .bind(identifier)
458    .fetch_optional(pool)
459    .await
460    .map_err(|err| {
461        tracing::error!("user lookup (legacy_name) failed: {err}");
462        Error::Database
463    })?;
464    Ok(by_legacy)
465}
466
467/// Background task that prunes expired sessions and set-password tokens.
468/// Spawned alongside the existing job evictor in `bin/sl_map_web.rs`.
469pub async fn run_cleanup(pool: SqlitePool) {
470    let mut interval = tokio::time::interval(Duration::from_secs(300));
471    loop {
472        interval.tick().await;
473        let now = Utc::now();
474        if let Err(err) = sqlx::query("DELETE FROM sessions WHERE expires_at <= ?1")
475            .bind(now)
476            .execute(&pool)
477            .await
478        {
479            tracing::warn!("session cleanup failed: {err}");
480        }
481        if let Err(err) = sqlx::query(
482            "DELETE FROM set_password_tokens WHERE expires_at <= ?1 OR used_at IS NOT NULL",
483        )
484        .bind(now)
485        .execute(&pool)
486        .await
487        {
488            tracing::warn!("set-password token cleanup failed: {err}");
489        }
490    }
491}