diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/auth.rs | 15 | ||||
| -rw-r--r-- | src/api/dashboard/dashboard.html | 11 | ||||
| -rw-r--r-- | src/api/dashboard/login.html | 82 | ||||
| -rw-r--r-- | src/api/dashboard/mod.rs | 7 | ||||
| -rw-r--r-- | src/api/login.rs | 111 | ||||
| -rw-r--r-- | src/api/mod.rs | 18 |
6 files changed, 226 insertions, 18 deletions
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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>continuum: log in</title> +<style> + :root { color-scheme: light dark; --fg: #1d2330; --bg: #f6f7f9; --line: #c9ced8; --accent: #2a6fdb; --bad: #b3261e; } + @media (prefers-color-scheme: dark) { :root { --fg: #e4e7ee; --bg: #14171d; --line: #3a404c; --accent: #6ea2ff; --bad: #ff8a80; } } + body { margin: 0; font: 15px/1.45 system-ui, sans-serif; color: var(--fg); background: var(--bg); } + main { max-width: 22rem; margin: 12vh auto; padding: 0 16px; } + h1 { font-size: 1.3rem; margin: 0 0 1.2rem; } + form { display: grid; gap: .6rem; margin-bottom: 1rem; } + label { display: grid; gap: .2rem; font-size: .9rem; } + input { font: inherit; padding: .45rem .55rem; border: 1px solid var(--line); border-radius: 6px; background: transparent; color: inherit; } + button { font: inherit; padding: .45rem .8rem; border: 1px solid var(--accent); border-radius: 6px; background: var(--accent); color: #fff; cursor: pointer; justify-self: start; } + button.quiet { background: transparent; color: var(--accent); } + .or { margin: 1.4rem 0 .6rem; font-size: .9rem; opacity: .8; } + #why { min-height: 1.4em; color: var(--bad); } + code { font-size: .85em; } +</style> +</head> +<body> +<main> + <h1>Log in to continuum</h1> + <form id="password"> + <label>User <input name="user" autocomplete="username" required></label> + <label>Password <input name="password" type="password" autocomplete="current-password" required></label> + <button>Log in</button> + </form> + <p class="or">or with a token, from <code>continuum-signed login</code>:</p> + <form id="token"> + <label>Token <input name="token" autocomplete="off" spellcheck="false" required></label> + <button>Use it</button> + </form> + <p id="why" role="alert"></p> + <button id="out" class="quiet" type="button">Log out of this browser</button> +</main> +<script> +// Logs in by password (POST /auth/login) or takes a token, then keeps a session for this +// browser (POST /auth/session, a cookie no script reads) and opens the dashboard. +const why = document.getElementById("why"); +function basic(user, password) { + let text = ""; + for (const byte of new TextEncoder().encode(user + ":" + password)) text += String.fromCharCode(byte); + return "Basic " + btoa(text); +} +async function call(url, method, authorization) { + const headers = { "Content-Type": "application/json" }; + if (authorization) headers.Authorization = authorization; + const response = await fetch(url, { method, headers, credentials: "same-origin", + body: method === "POST" ? "{}" : undefined }); + const answer = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(answer.error || response.status + " " + response.statusText); + return answer; +} +async function begin(token) { + await call("auth/session", "POST", "Bearer " + token); + location.href = "admin/dashboard"; +} +function failed(error) { why.textContent = "Not logged in: " + error.message; } +document.getElementById("password").addEventListener("submit", async event => { + event.preventDefault(); + why.textContent = ""; + const form = event.target; + try { + const answer = await call("auth/login", "POST", basic(form.user.value, form.password.value)); + await begin(answer.token); + } catch (error) { failed(error); } +}); +document.getElementById("token").addEventListener("submit", async event => { + event.preventDefault(); + why.textContent = ""; + try { await begin(event.target.token.value.trim()); } catch (error) { failed(error); } +}); +document.getElementById("out").addEventListener("click", async () => { + await fetch("auth/session", { method: "DELETE", credentials: "same-origin" }).catch(() => {}); + why.textContent = "Logged out of this browser."; +}); +</script> +</body> +</html> 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/<name>`, `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<Claims>, } +/// 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<Body>) -> Option<String> { + 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 <token>`: 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<AppState>, mut request: Request<Body>, 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<Extension<Token>>, body: Option<Json<LimitRequest>>, ) -> Result<Response, ApiError> { + 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<AppState>, + token: Option<Extension<Token>>, + body: Option<Json<LimitRequest>>, +) -> Result<Response, ApiError> { + 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<Extension<Token>>, + body: Option<Json<LimitRequest>>, +) -> Result<Claims, ApiError> { 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 { ( |
