aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/access.md8
-rw-r--r--docs/dashboard.md10
-rw-r--r--docs/openapi.yaml50
-rw-r--r--src/api/auth.rs15
-rw-r--r--src/api/dashboard/dashboard.html11
-rw-r--r--src/api/dashboard/login.html82
-rw-r--r--src/api/dashboard/mod.rs7
-rw-r--r--src/api/login.rs111
-rw-r--r--src/api/mod.rs18
9 files changed, 292 insertions, 20 deletions
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
<machine>`) 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 @@
+<!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 {
(