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). --- docs/access.md | 8 +++ docs/dashboard.md | 10 +++- docs/openapi.yaml | 50 ++++++++++++++++++ src/api/auth.rs | 15 ++++++ src/api/dashboard/dashboard.html | 11 ++-- src/api/dashboard/login.html | 82 +++++++++++++++++++++++++++++ src/api/dashboard/mod.rs | 7 +++ src/api/login.rs | 111 ++++++++++++++++++++++++++++++++++----- src/api/mod.rs | 18 +++++++ 9 files changed, 292 insertions(+), 20 deletions(-) create mode 100644 src/api/dashboard/login.html diff --git a/docs/access.md b/docs/access.md index 83c39f9..cc35565 100644 --- a/docs/access.md +++ b/docs/access.md @@ -125,6 +125,14 @@ the signature must be by one of the user's keys, as the registry the node holds says, and the user enabled (`401` if not, saying why). A swarm without a registry has no keys (`409`). +A browser keeps a token as its session: `POST /auth/session`, with a token, answers as +`/auth/refresh` does and sets a cookie holding the new one (`HttpOnly`, +`SameSite=Strict`: no script reads it, no other site's page sends it), which lets in +requests without `Authorization`; `DELETE /auth/session` forgets it. The login page +(`/login`, without authentication, with the dashboard) begins one by password or from a +token pasted, from a login by key, say; the dashboard renews it while open +([`dashboard.md`](dashboard.md)). + `GET /auth/whoami` says who a request's user is, how it proved it, and what it may do. Pages of other origins, the dashboards of other members, may call a node with a token: diff --git a/docs/dashboard.md b/docs/dashboard.md index 05069d1..df2d4f9 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -14,8 +14,14 @@ cargo build --release --features mycelium,dashboard ``` It is at `/admin/dashboard` on the node's HTTP API, by default -`http://127.0.0.1:3000/admin/dashboard`, and asks for a user from `--htpasswd` (or one -a reverse proxy names, `--user-header`), as the rest of the API does. The API listens on localhost unless `--bind-addr` says otherwise; +`http://127.0.0.1:3000/admin/dashboard`, for a user a reverse proxy names +(`--user-header`) or one logged in on the page `/login`, which a browser opening the +dashboard without one is sent to. There, a user logs in with its password +(`--htpasswd`), or with a token: from `continuum-signed login` (by key, +[`access.md`](access.md), Tokens), or any other. The page keeps a session for the +browser, a cookie no script reads and no other site's page sends, renewed while the +dashboard is open; it ends with the token's lifetime, or with "Log out of this +browser". The API listens on localhost unless `--bind-addr` says otherwise; to look at a node on another machine, forward the port (`ssh -L 3000:localhost:3000 `) or put a TLS proxy in front ([`operations.md`](operations.md), Behind a reverse proxy). diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ab14710..bb1d34b 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -895,6 +895,56 @@ paths: '409': { description: No user registry, which holds the keys. } '503': { description: Not ready yet. } + /auth/session: + post: + tags: [Auth] + summary: Keep a token as the browser's session + description: | + As `POST /auth/refresh`, and the new token set as the session cookie + (`continuum-token`, `HttpOnly`, `SameSite=Strict`, for as long as the token is + valid): requests without `Authorization` that bring it are its user's. + security: + - bearer: [] + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/Limit' + responses: + '200': + description: The token, also set as the cookie. + content: + application/json: + schema: { $ref: '#/components/schemas/TokenAnswer' } + '400': { description: No token, or a limit that isn't one or says more than the token's. } + '401': { description: 'The token isn''t taken, or its login ended.' } + '503': { description: Not ready yet. } + delete: + tags: [Auth] + summary: Forget the browser's session + description: The session cookie cleared. The token stays valid until it expires (`POST /auth/logout` ends them all). + responses: + '204': { description: Forgotten. } + '401': + $ref: '#/components/responses/Unauthorized' + + /login: + get: + tags: [Auth] + summary: The page to log in on + description: | + With the dashboard: a page to log in on by password or with a token, without + authentication. It keeps a session (`POST /auth/session`) and opens + `/admin/dashboard`, where a browser without a user is sent (`303`). + security: [] + responses: + '200': + description: The page. + content: + text/html: {} + '404': { description: A build without the dashboard. } + /auth/refresh: post: tags: [Auth] diff --git a/src/api/auth.rs b/src/api/auth.rs index 69c7ddb..f28aa22 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -301,6 +301,21 @@ pub(crate) async fn basic_auth( }; let Some((user, via)) = user else { sleep(Duration::from_millis(1500)).await; + // A browser opening the dashboard: to the page to log in on. + if cfg!(feature = "dashboard") + && request.method() == axum::http::Method::GET + && request.uri().path() == "/admin/dashboard" + && headers + .get(axum::http::header::ACCEPT) + .and_then(|accept| accept.to_str().ok()) + .is_some_and(|accept| accept.contains("text/html")) + { + return ( + StatusCode::SEE_OTHER, + [(axum::http::header::LOCATION, "../login")], + ) + .into_response(); + } // A challenge only where basic authentication can answer it. return match state.users { Some(_) => ( diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index 300f4fe..8146f32 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -2276,8 +2276,9 @@ function unapplySubscriptions() { } overlaid.clear(); } -// Tokens: this page's, from logging in on this node (the browser's password for it, or -// the proxy's), and a narrower one, reading the member list only, to send to others. +// Tokens: this page's, from logging in on this node (the browser's password for it, the +// proxy's, or the session the login page began), and a narrower one, reading the member +// list only, to send to others. // Each refreshed half way through its life. let token = null, narrowToken = null, tokenWhy = "not logged in yet", tokenTimer = null; const READ_MEMBERS = [{ path: ["_continuum", "members"], rights: ["read"] }]; @@ -2293,9 +2294,11 @@ async function tokenCall(url, bearer, body) { async function obtainTokens() { clearTimeout(tokenTimer); try { + // Refreshed as the session (`POST /auth/session`), whose cookie lets the page in; + // the first: by the browser's password or the proxy's, else by that cookie. token = token && token.expiresAt > Date.now() + 60000 - ? await tokenCall("../auth/refresh", token.token) - : await tokenCall("../auth/login"); + ? await tokenCall("../auth/session", token.token) + : await tokenCall("../auth/login").catch(() => tokenCall("../auth/session")); narrowToken = await tokenCall("../auth/refresh", token.token, { limit: READ_MEMBERS }); tokenWhy = null; const life = Math.min(token.expiresAt, narrowToken.expiresAt) - Date.now(); diff --git a/src/api/dashboard/login.html b/src/api/dashboard/login.html new file mode 100644 index 0000000..77668ee --- /dev/null +++ b/src/api/dashboard/login.html @@ -0,0 +1,82 @@ + + + + + +continuum: log in + + + +
+

Log in to continuum

+
+ + + +
+

or with a token, from continuum-signed login:

+
+ + +
+ + +
+ + + diff --git a/src/api/dashboard/mod.rs b/src/api/dashboard/mod.rs index 9cb4ea4..24fdacb 100644 --- a/src/api/dashboard/mod.rs +++ b/src/api/dashboard/mod.rs @@ -602,6 +602,13 @@ fn page() -> &'static (String, String) { }) } +/// `GET /login`: a page to log in on, by password or with a token, without +/// authentication (it holds nothing): it keeps a session for the browser +/// (`POST /auth/session`) and opens the dashboard. +pub(crate) async fn login_page() -> Html<&'static str> { + Html(include_str!("login.html")) +} + /// `GET /admin/dashboard`: the page. async fn page_html() -> Html<&'static str> { Html(page().1.as_str()) 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`. diff --git a/src/api/mod.rs b/src/api/mod.rs index 2a523a4..9405a06 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -68,6 +68,10 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> .route("/admin/config", get(config::get_config)) .route("/auth/login", axum::routing::post(login::login)) .route("/auth/refresh", axum::routing::post(login::refresh)) + .route( + "/auth/session", + axum::routing::post(login::session).delete(login::end_session), + ) .route("/auth/logout", axum::routing::post(login::logout)) .route("/auth/whoami", get(login::whoami)) .route("/admin/users", get(users::list).post(users::add_several)) @@ -122,6 +126,8 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> .route("/auth/challenge", axum::routing::post(login::challenge)) .route("/auth/login/key", axum::routing::post(login::login_key)) .route("/health", get(health::node_health)) + // To log in to the dashboard on, without authentication. + .route("/login", get(dashboard_login)) .route("/ready", get(health::ready)) // Outermost, so authentication failures get the error body too. .layer(middleware::from_fn(json_errors)) @@ -262,6 +268,18 @@ async fn json_errors(request: Request, next: Next) -> Response { Response::from_parts(parts, Body::from(body)) } +/// `GET /login` (`dashboard::login_page`); without the dashboard, none. +async fn dashboard_login() -> Response { + #[cfg(feature = "dashboard")] + { + dashboard::login_page().await.into_response() + } + #[cfg(not(feature = "dashboard"))] + { + StatusCode::NOT_FOUND.into_response() + } +} + /// `GET /openapi.yaml`: the API description (`docs/openapi.yaml`, built in). async fn openapi_spec() -> impl IntoResponse { ( -- cgit v1.3.1