From b99e7ddf38233120ea19485968c12c5548e7fa61 Mon Sep 17 00:00:00 2001 From: tv Date: Sat, 7 Nov 2026 22:35:22 +0100 Subject: auth: a login page and browser sessions GET /login without authentication: log in by password or with a token (from a login by key). POST/DELETE /auth/session keep the token as an HttpOnly, SameSite=Strict cookie; the dashboard sends a browser without a user there and renews the session while open. Auth plan M2/M3 (browsers). --- src/api/login.rs | 111 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 97 insertions(+), 14 deletions(-) (limited to 'src/api/login.rs') diff --git a/src/api/login.rs b/src/api/login.rs index 75d6982..de6e390 100644 --- a/src/api/login.rs +++ b/src/api/login.rs @@ -14,8 +14,13 @@ //! (`_continuum/logouts/`, `registry::logouts_path`). //! * `GET /auth/whoami`: who the request's user is, how it proved it, what it may do. //! -//! Every request may bring a token instead of a password: `Authorization: Bearer ct1…` -//! (`bearer`). +//! * `POST /auth/session`, with a token: `refresh`, and the new token kept in the +//! browser as a cookie (`SESSION_COOKIE`), so its pages (the dashboard) are let in by +//! it; `DELETE /auth/session` forgets it. The login page (`GET /login`, without +//! authentication, with the dashboard) gets one by password or from a token pasted. +//! +//! Every request may bring a token instead of a password: `Authorization: Bearer ct1…`, +//! or, with no `Authorization`, the session cookie (`bearer`). use crate::document::signatures::Sign; use std::sync::Arc; @@ -45,22 +50,46 @@ pub(crate) struct Token { pub(crate) claims: Arc, } +/// The cookie a browser keeps its session's token in (`POST /auth/session`): +/// `HttpOnly`, `SameSite=Strict`, so no script and no other site's page sends it. +pub(crate) const SESSION_COOKIE: &str = "continuum-token"; + +/// The session cookie's token, if the request brings one. +fn session_cookie(request: &Request) -> Option { + request + .headers() + .get_all(axum::http::header::COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(';')) + .filter_map(|pair| pair.trim().split_once('=')) + .find(|(name, _)| *name == SESSION_COOKIE) + .map(|(_, token)| token.to_owned()) +} + /// A request with `Authorization: Bearer `: its user is the token's, if this -/// node takes it (`token::verify`); `401`, saying why, if not. Others go on to -/// `auth::basic_auth`. +/// node takes it (`token::verify`); `401`, saying why, if not. One without +/// `Authorization` but with the session cookie: the cookie's token's, if taken (if not, +/// as if it brought none). Others go on to `auth::basic_auth`. pub(crate) async fn bearer( State(state): State, mut request: Request, next: axum::middleware::Next, ) -> Response { - let Some(text) = request - .headers() - .get(AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.strip_prefix("Bearer ")) - .map(str::to_owned) - else { - return next.run(request).await; + let authorization = request.headers().get(AUTHORIZATION).cloned(); + let (text, cookie) = match authorization { + Some(value) => match value + .to_str() + .ok() + .and_then(|value| value.strip_prefix("Bearer ")) + { + Some(text) => (text.to_owned(), false), + None => return next.run(request).await, + }, + None => match session_cookie(&request) { + Some(text) => (text, true), + None => return next.run(request).await, + }, }; let Some(swarm) = state.swarm_id().await else { return error_response( @@ -85,6 +114,11 @@ pub(crate) async fn bearer( }); next.run(request).await } + // A stale session: as if there were none (the login page, a password). + Err(refused) if cookie => { + debug!(reason = %refused, "Ignored the session cookie's token"); + next.run(request).await + } Err(refused) => { debug!(reason = %refused, "Refused a token"); error_response( @@ -288,13 +322,62 @@ pub(crate) async fn refresh( token: Option>, body: Option>, ) -> Result { + let claims = refreshed(&state, token, body).await?; + answer(&state, &claims) +} + +/// `POST /auth/session`: `refresh`, and the new token kept as the session cookie, for as +/// long as it is valid. +pub(crate) async fn session( + State(state): State, + token: Option>, + body: Option>, +) -> Result { + let claims = refreshed(&state, token, body).await?; + let mut response = answer(&state, &claims)?; + let key = state + .node_key + .as_ref() + .ok_or(ApiError(StatusCode::SERVICE_UNAVAILABLE, None))?; + let max_age = claims.exp.saturating_sub(claims.iat) / 1000; + let cookie = format!( + "{SESSION_COOKIE}={}; Path=/; HttpOnly; SameSite=Strict; Max-Age={max_age}", + token::issue(key, &claims) + ); + if let Ok(value) = axum::http::HeaderValue::from_str(&cookie) { + response + .headers_mut() + .append(axum::http::header::SET_COOKIE, value); + } + Ok(response) +} + +/// `DELETE /auth/session`: the session cookie forgotten (the token itself stays valid +/// until it expires; `POST /auth/logout` ends every one). A stale one is ignored anyway. +pub(crate) async fn end_session() -> Response { + ( + StatusCode::NO_CONTENT, + [( + axum::http::header::SET_COOKIE, + format!("{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"), + )], + ) + .into_response() +} + +/// The claims a refresh of `token` gives (`limit` within its own). +async fn refreshed( + state: &AppState, + token: Option>, + body: Option>, +) -> Result { let Some(Extension(token)) = token else { return Err(ApiError( StatusCode::BAD_REQUEST, Some("refreshing takes a token (Authorization: Bearer …)".to_owned()), )); }; - let (swarm, now) = issuing(&state).await?; + let (swarm, now) = issuing(state).await?; let earlier = &token.claims; if earlier.session_exp <= now { return Err(ApiError( @@ -329,7 +412,7 @@ pub(crate) async fn refresh( limit, ..(**earlier).clone() }; - answer(&state, &claims) + Ok(claims) } /// `POST /auth/logout`. -- cgit v1.3.1