From ec70060ae5fa665953741da921e28031b56bc5e0 Mon Sep 17 00:00:00 2001 From: tv Date: Sat, 7 Nov 2026 22:35:09 +0100 Subject: auth: logging in, and tokens as bearers POST /auth/login (by htpasswd or the proxy), /auth/refresh, /auth/logout, GET /auth/whoami; every request may bring Authorization: Bearer instead of a password. --token-lifetime and --session-lifetime. Auth plan M2. --- docs/access.md | 32 +++++ docs/api.md | 5 +- docs/example.toml | 9 ++ docs/openapi.yaml | 112 ++++++++++++++++++ docs/trust.md | 3 +- src/api/access.rs | 44 ++++++- src/api/auth.rs | 21 +++- src/api/login.rs | 341 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mod.rs | 8 ++ src/api/users.rs | 33 ++++++ src/config/mod.rs | 30 +++++ src/main.rs | 3 + src/node.rs | 8 ++ src/simtest/mod.rs | 3 + src/state/mod.rs | 4 + 15 files changed, 648 insertions(+), 8 deletions(-) create mode 100644 src/api/login.rs diff --git a/docs/access.md b/docs/access.md index db86e21..e8c5455 100644 --- a/docs/access.md +++ b/docs/access.md @@ -73,6 +73,38 @@ A user the node lets in but the registry doesn't have may do nothing (`403`). whole swarm (its members, its remakes, its users), so it is given apart, never by accident with the data. A user who may do both has both. +## Tokens + +A user logs in on any node, by what that node checks (its `--htpasswd`, or a proxy's +`--user-header`), and gets a token every node of the swarm takes, sent as +`Authorization: Bearer ` instead of a password: + +```sh +curl -u alice -X POST http://localhost:3000/auth/login # {"token": "ct1.…", …} +curl -H "Authorization: Bearer ct1.…" http://other-node:3000/store +``` + +* **Who signs it**: the node it logged in on, with its node key. Every node takes a + token signed by a member of the swarm (the member list gives the keys: a node ID is + its key), and none signed by another or since forgotten. +* **What it says**: who, how it proved it, when it ends, and optionally less than the + user may do (`limit`, capabilities as `POST /admin/users/{name}` takes them): never + what the user may do. Every node judges a request by the registry it holds then, so + a capability granted or revoked counts on a node as soon as the change reaches it. +* **How long**: `--token-lifetime` (12 hours), refreshed (`POST /auth/refresh`, on any + node, which signs the new one) until `--session-lifetime` (7 days) after the login; + then the user logs in again. A refreshed token may say less (`limit`), never more. +* **Revoking**: a user disabled or removed is refused at once; all of a user's tokens, + by the user (`POST /auth/logout`) or by one who may manage users (`DELETE + /admin/users/{name}/tokens`), which sets `tokensNotBefore` in its entry: tokens issued + before aren't taken any more. A token of a node forgotten by the swarm isn't taken + either. In a swarm without a registry, a token is taken until it expires. +* **What it means to trust**: any member could sign a token for any user, as it could + write anything as anyone ([`trust.md`](trust.md)). A token is a bearer's: whoever holds it is + the user until it ends, so it goes over TLS only, as passwords do. + +`GET /auth/whoami` says who a request's user is, how it proved it, and what it may do. + ## Managing users Through `/admin/users` ([`openapi.yaml`](openapi.yaml), Users): looking needs `read` on diff --git a/docs/api.md b/docs/api.md index 5486378..8c4a534 100644 --- a/docs/api.md +++ b/docs/api.md @@ -31,9 +31,10 @@ curl -u alice:secret -X POST 'localhost:3000/store/stats/visits?increment=1' # * Bodies are JSON (`application/json`, the default), text (`text/plain`, stored as a string), or a value of a type of its own (Types); answers are JSON, or as `Accept` asks. Numbers in JSON are stored as integers where they fit, as floats otherwise. -* Every request needs a user from the node's `--htpasswd` (basic authentication), or +* Every request needs a user from the node's `--htpasswd` (basic authentication), named by a reverse proxy in front that authenticated it (`--user-header`; - [`operations.md`](operations.md), Behind a reverse proxy). What each may do, the swarm's user + [`operations.md`](operations.md), Behind a reverse proxy), or a token from logging in on any node + of the swarm (`Authorization: Bearer`, [`access.md`](access.md), Tokens). What each may do, the swarm's user registry says, by path ([`access.md`](access.md)): `403` where it doesn't let the user, and of a part it may only partly read, a read shows the keys on the way to what it may. In a swarm without a registry, users are all alike: each can read and write diff --git a/docs/example.toml b/docs/example.toml index b98752d..ab8237c 100644 --- a/docs/example.toml +++ b/docs/example.toml @@ -73,6 +73,15 @@ htpasswd = "/etc/continuum/htpasswd" # 0: never. # stream-heartbeat = "30s" +# How long a token this node issues (POST /auth/login, /auth/refresh) is taken, by every +# node of the swarm (docs/access.md, Tokens). Refreshed before it ends, it lasts until +# the login's end (session-lifetime). +# token-lifetime = "12h" + +# How long after a login (POST /auth/login) its tokens may be refreshed; then the user +# logs in again. +# session-lifetime = "7d" + # Where the reverse proxies whose user-header is believed connect from: IP addresses (or # ranges, as ADDRESS/BITS), and unix for the API's Unix sockets (bind-addr). On a host # with other users, leave out the addresses and have the proxy use a Unix socket: others diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 5bf5a50..64c5b9b 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -26,6 +26,7 @@ servers: security: - basic: [] + - bearer: [] tags: - name: Store @@ -38,6 +39,10 @@ tags: description: | Replacing the swarm's document with a new epoch (compaction, replacement, import), and what happened to local writes that a remake couldn't place. + - name: Auth + description: | + Logging in on this node for a token every node of the swarm takes, and using it + (docs/access.md, Tokens). - name: Users description: | The swarm's users and what each may do (docs/access.md): reading needs `read` on @@ -733,6 +738,91 @@ paths: '401': $ref: '#/components/responses/Unauthorized' + /auth/login: + post: + tags: [Auth] + summary: Log in for a token + description: | + As a user this node lets in (htpasswd, or a trusted proxy's user-header): a token + signed by this node, taken by every node of the swarm until `expiresAt` + (`token-lifetime`), refreshable until `sessionExpiresAt` (`session-lifetime`). + With `limit`, the token gives only what it allows too. + security: + - basic: [] + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/Limit' + responses: + '200': + description: The token. + content: + application/json: + schema: { $ref: '#/components/schemas/TokenAnswer' } + '400': { description: A token instead of a password, or a limit that isn't one. } + '401': + $ref: '#/components/responses/Unauthorized' + '403': { description: Not a registered, enabled user. } + '503': { description: Not ready yet. } + + /auth/refresh: + post: + tags: [Auth] + summary: Refresh a token + description: A new token for the same login, signed by this node; with `limit`, narrower still (within the token's). + security: + - bearer: [] + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/Limit' + responses: + '200': + description: The token. + content: + application/json: + schema: { $ref: '#/components/schemas/TokenAnswer' } + '400': { description: No token, or a limit wider than the token's. } + '401': { description: The token isn't taken, or its login ended. } + + /auth/logout: + post: + tags: [Auth] + summary: End all of your logins + description: Every token of the user, of every login, no longer taken (`tokensNotBefore`). + responses: + '204': { description: Done. } + '409': { description: No user registry; tokens are taken until they expire. } + + /auth/whoami: + get: + tags: [Auth] + summary: Who you are here + description: The user, how it proved it (`htpasswd`, `header`, `token`), its registry entry's capabilities, and its token's claims if it came with one. + responses: + '200': + description: Who. + content: + application/json: + schema: + type: object + + /admin/users/{name}/tokens: + parameters: + - { name: name, in: path, required: true, schema: { type: string } } + delete: + tags: [Users] + summary: Revoke every token of a user + responses: + '204': { description: Revoked. } + '403': { description: May not manage users. } + '404': { description: No such user. } + '409': { description: No user registry. } + /admin/users: get: tags: [Users] @@ -1468,6 +1558,10 @@ components: basic: type: http scheme: basic + bearer: + type: http + scheme: bearer + description: A token from `POST /auth/login` on any node of the swarm (docs/access.md, Tokens). parameters: key: @@ -1637,6 +1731,24 @@ components: $ref: '#/components/schemas/Error' schemas: + Limit: + type: object + properties: + limit: + type: array + items: { $ref: '#/components/schemas/CapabilityRequest' } + TokenAnswer: + type: object + properties: + token: { type: string, description: '`ct1.…`: send it as `Authorization: Bearer `.' } + user: { type: string } + issuer: { type: string, description: The node that signed it. } + expiresAt: { type: integer, description: Unix ms. } + sessionExpiresAt: { type: integer, description: Unix ms. } + limit: + type: array + nullable: true + items: { $ref: '#/components/schemas/CapabilityRequest' } CapabilityRequest: type: object required: [path, rights] diff --git a/docs/trust.md b/docs/trust.md index fd37638..8b0f72a 100644 --- a/docs/trust.md +++ b/docs/trust.md @@ -34,7 +34,8 @@ secret like the swarm key: readable by its owner only (a node warns otherwise; a it creates is). **The HTTP API.** Every endpoint but the API description needs a user from -`--htpasswd`, or one a reverse proxy names (`--user-header`): the node believes that +`--htpasswd`, one a reverse proxy names (`--user-header`), or a token a node of the +swarm issued to one of those ([`access.md`](access.md), Tokens): the node believes that header from `--trusted-proxies` alone, but from those blindly, so whoever can send requests from there can be any user. With the default loopback addresses, that is every user of the host; with only the API's Unix socket (`--trusted-proxies unix`), those its diff --git a/src/api/access.rs b/src/api/access.rs index 63aa87b..29d796a 100644 --- a/src/api/access.rs +++ b/src/api/access.rs @@ -47,6 +47,33 @@ impl Access { } } + /// As capabilities: `Caps` as they are; in a swarm without a registry, what every + /// user may do there (the swarm's data, protocol data to read, and to write for + /// `--protocol-writers`), to narrow by a token's limit. + pub(crate) fn caps(&self, state: &AppState, user: &str) -> Caps { + use crate::access::Capability; + match self { + Self::Caps(caps) => caps.clone(), + Self::Everything => { + let protocol: &[&str] = if state.protocol_writers.contains(user) { + &["read", "write"] + } else { + &["read"] + }; + let cap = |path: Vec, rights: &[&str]| Capability { + path, + rights: rights.iter().map(|right| (*right).to_owned()).collect(), + by: String::new(), + since: 0, + }; + Caps::new(vec![ + cap(Vec::new(), &["read", "write"]), + cap(meta::path(&[]), protocol), + ]) + } + } + } + pub(crate) fn visible(&self, path: &[String]) -> Visible { match self { Self::Everything => Visible::All, @@ -209,6 +236,8 @@ enum Need { Store, /// Looking at this node (its figures, peers, dashboard): any capability. Observe, + /// Logging in, refreshing, asking who one is: any registered user. + Auth, /// A right on protocol data. Protocol(Right, Vec), } @@ -224,6 +253,8 @@ fn needed(method: &Method, path: &str) -> Need { let under = |prefix: &str| path == prefix || path.starts_with(&format!("{prefix}/")); if under("/store") || under("/stream") { Need::Store + } else if under("/auth") { + Need::Auth } else if (path == "/peers" && looking) || path == "/admin/stats" || under("/admin/dashboard") { Need::Observe } else if under("/peers") { @@ -288,7 +319,7 @@ pub(crate) async fn authorize( } }; let allowed = match needed(request.method(), request.uri().path()) { - Need::Store => true, + Need::Store | Need::Auth => true, Need::Observe => match &access { Access::Everything => true, Access::Caps(caps) => !caps.is_empty(), @@ -303,6 +334,15 @@ pub(crate) async fn authorize( None, ); } + // A token that says less: only what it allows too. + let access = match request + .extensions() + .get::() + .and_then(|token| token.claims.limit.clone()) + { + None => access, + Some(limit) => Access::Caps(access.caps(&state, &user).within(limit)), + }; request.extensions_mut().insert(access); next.run(request).await } @@ -338,6 +378,8 @@ mod tests { let read = |parts: &[&str]| Need::Protocol(Right::Read, meta::path(parts)); let write = |parts: &[&str]| Need::Protocol(Right::Write, meta::path(parts)); assert_eq!(needed(&Method::GET, "/store"), Need::Store); + assert_eq!(needed(&Method::POST, "/auth/login"), Need::Auth); + assert_eq!(needed(&Method::GET, "/auth/whoami"), Need::Auth); assert_eq!(needed(&Method::PUT, "/store/a/b"), Need::Store); assert_eq!(needed(&Method::GET, "/stream/a"), Need::Store); assert_eq!(needed(&Method::GET, "/storehouse"), read(&[])); diff --git a/src/api/auth.rs b/src/api/auth.rs index f85b64e..69c7ddb 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -203,10 +203,16 @@ fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> { Some((user.to_owned(), pass.to_owned())) } -/// The user a request authenticated as (a request extension, set by [`basic_auth`]). +/// The user a request authenticated as (a request extension, set by [`basic_auth`], or +/// by `login::bearer` for a token). #[derive(Clone, Debug)] pub(crate) struct AuthenticatedUser(pub(crate) String); +/// How the request's user proved who it is (a request extension, beside +/// `AuthenticatedUser`): `htpasswd`, `header` (a trusted proxy's), or `token`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Via(pub(crate) &'static str); + /// The user the trusted proxy the request came from names, if it does. fn proxied_user(state: &AuthState, headers: &HeaderMap, from: Option) -> Option { let proxy = state.proxy.as_ref()?; @@ -279,15 +285,21 @@ pub(crate) async fn basic_auth( mut request: Request, next: axum::middleware::Next, ) -> Response { + // A token, taken already (`login::bearer`). + if request.extensions().get::().is_some() { + return next.run(request).await; + } let from = request .extensions() .get::>() .map(|ConnectInfo(peer)| *peer); let user = match proxied_user(&state, &headers, from) { - Some(user) => Some(user), - None => authenticate(&state, &headers).await, + Some(user) => Some((user, Via("header"))), + None => authenticate(&state, &headers) + .await + .map(|user| (user, Via("htpasswd"))), }; - let Some(user) = user else { + let Some((user, via)) = user else { sleep(Duration::from_millis(1500)).await; // A challenge only where basic authentication can answer it. return match state.users { @@ -302,6 +314,7 @@ pub(crate) async fn basic_auth( }; request.extensions_mut().insert(AuthenticatedUser(user)); + request.extensions_mut().insert(via); next.run(request).await } diff --git a/src/api/login.rs b/src/api/login.rs new file mode 100644 index 0000000..09b8f2e --- /dev/null +++ b/src/api/login.rs @@ -0,0 +1,341 @@ +//! `/auth`: logging in on this node for a token every node of the swarm takes +//! (`access::token`, `access.md`, Tokens), and using it. +//! +//! * `POST /auth/login`, as a user this node lets in (`--htpasswd`, `--user-header`): +//! a token, signed by this node, valid for `--token-lifetime`, refreshable until +//! `--session-lifetime` after the login. Optionally less than the user may do +//! (`{"limit": [{"path": [...], "rights": [...]}, …]}`). +//! * `POST /auth/refresh`, with a token: a new one for the same login, signed by this +//! node, optionally narrower still (`limit`, within the token's). +//! * `POST /auth/logout`: every token of the user, of every login, no longer taken +//! (`tokensNotBefore` in its registry entry). +//! * `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`). + +use std::sync::Arc; + +use axum::{ + Extension, Json, + body::Body, + extract::State, + http::{Request, StatusCode, header::AUTHORIZATION}, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; +use serde_json::{Value as JsonValue, json}; +use tracing::{debug, info}; + +use super::access::Access; +use super::auth::{AuthenticatedUser, Via}; +use super::users::CapRequest; +use super::{ApiError, error_response}; +use crate::access::token::{self, Claims}; +use crate::access::{Capability, Right, registry}; +use crate::state::AppState; + +/// The token a request came with, as taken (a request extension, set by `bearer`). +#[derive(Clone, Debug)] +pub(crate) struct Token { + pub(crate) claims: Arc, +} + +/// 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`. +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 Some(swarm) = state.swarm_id().await else { + return error_response( + StatusCode::SERVICE_UNAVAILABLE, + "not in a swarm yet: tokens are taken once it is", + None, + ); + }; + let now = state.clock.now_millis().as_u64(); + let verified = { + let replica = state.doc.read().await; + token::verify(&replica.doc, &text, &swarm, now) + }; + match verified { + Ok(claims) => { + request + .extensions_mut() + .insert(AuthenticatedUser(claims.user.clone())); + request.extensions_mut().insert(Via("token")); + request.extensions_mut().insert(Token { + claims: Arc::new(claims), + }); + next.run(request).await + } + Err(refused) => { + debug!(reason = %refused, "Refused a token"); + error_response( + StatusCode::UNAUTHORIZED, + format!("token refused: {refused}"), + None, + ) + } + } +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LimitRequest { + #[serde(default)] + limit: Option>, +} + +impl LimitRequest { + fn limit(&self) -> Result>, ApiError> { + self.limit + .as_ref() + .map(|caps| caps.iter().map(|cap| cap.capability("", 0)).collect()) + .transpose() + } +} + +/// `limit` as a token may say it, within `earlier`'s (a refreshed one says no more): each +/// of its capabilities at or below one of `earlier`'s, with some of its rights. +fn within(limit: &[Capability], earlier: &[Capability]) -> bool { + limit.iter().all(|cap| { + earlier + .iter() + .any(|outer| cap.path.starts_with(&outer.path) && cap.rights.is_subset(&outer.rights)) + }) +} + +/// `claims` (issued now, by this node), signed, as the answer to a login or refresh. +fn answer(state: &AppState, claims: &Claims) -> Result { + let key = state + .node_key + .as_ref() + .ok_or(ApiError(StatusCode::SERVICE_UNAVAILABLE, None))?; + Ok(Json(json!({ + "token": token::issue(key, claims), + "user": claims.user, + "issuer": claims.issuer, + "expiresAt": claims.exp, + "sessionExpiresAt": claims.session_exp, + "limit": claims.limit, + })) + .into_response()) +} + +/// The swarm, now, and this node's ID, to issue a token; `503` while not ready. +async fn issuing(state: &AppState) -> Result<(String, u64), ApiError> { + if !state.is_ready().await || state.node_key.is_none() { + return Err(StatusCode::SERVICE_UNAVAILABLE.into()); + } + let swarm = state + .swarm_id() + .await + .ok_or(ApiError(StatusCode::SERVICE_UNAVAILABLE, None))?; + Ok((swarm, state.clock.now_millis().as_u64())) +} + +/// `POST /auth/login`. +pub(crate) async fn login( + State(state): State, + Extension(user): Extension, + Extension(via): Extension, + body: Option>, +) -> Result { + if via == Via("token") { + return Err(ApiError( + StatusCode::BAD_REQUEST, + Some("log in with a password or through the proxy; a token is refreshed at /auth/refresh".to_owned()), + )); + } + let limit = body.map(|Json(body)| body).unwrap_or_default().limit()?; + let (swarm, now) = issuing(&state).await?; + { + let replica = state.doc.read().await; + if registry::exists(&replica.doc) + && registry::user(&replica.doc, &user.0).is_none_or(|entry| entry.disabled) + { + return Err(ApiError( + StatusCode::FORBIDDEN, + Some("not a registered, enabled user (docs/access.md)".to_owned()), + )); + } + } + let session_exp = now.saturating_add(state.session_lifetime.as_millis() as u64); + let claims = Claims { + v: 1, + swarm, + issuer: state.local_id().to_owned(), + user: user.0.clone(), + login: via.0.to_owned(), + iat: now, + exp: now + .saturating_add(state.token_lifetime.as_millis() as u64) + .min(session_exp), + session: token::new_session(), + session_exp, + limit, + }; + info!(user = %user.0, login = via.0, session = %claims.session, "Logged in"); + answer(&state, &claims) +} + +/// `POST /auth/refresh`. +pub(crate) async fn refresh( + State(state): State, + 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 earlier = &token.claims; + if earlier.session_exp <= now { + return Err(ApiError( + StatusCode::UNAUTHORIZED, + Some("the login ended: log in again".to_owned()), + )); + } + let limit = match body.map(|Json(body)| body).unwrap_or_default().limit()? { + None => earlier.limit.clone(), + Some(limit) => { + if earlier + .limit + .as_ref() + .is_some_and(|earlier| !within(&limit, earlier)) + { + return Err(ApiError( + StatusCode::BAD_REQUEST, + Some("a refreshed token says no more than the one it comes from".to_owned()), + )); + } + Some(limit) + } + }; + let claims = Claims { + v: 1, + swarm, + issuer: state.local_id().to_owned(), + iat: now, + exp: now + .saturating_add(state.token_lifetime.as_millis() as u64) + .min(earlier.session_exp), + limit, + ..(**earlier).clone() + }; + answer(&state, &claims) +} + +/// `POST /auth/logout`. +pub(crate) async fn logout( + State(state): State, + Extension(user): Extension, +) -> Result { + revoke_tokens(&state, &user.0, &user.0).await?; + Ok(StatusCode::NO_CONTENT) +} + +/// Makes every token of user `name` issued until now no longer taken +/// (`registry::User::tokens_not_before`), as `by`. `409` in a swarm without a +/// registry: there, a token is taken until it expires. +pub(crate) async fn revoke_tokens(state: &AppState, name: &str, by: &str) -> Result<(), ApiError> { + if !state.is_ready().await { + return Err(StatusCode::SERVICE_UNAVAILABLE.into()); + } + let mut entry = registry::user_path(name); + // Issued in this very millisecond: revoked too. + let after = state.clock.now_millis().as_u64() + 1; + state + .mutate_doc_for(Some(by.to_owned()), move |doc| { + if !registry::exists(&*doc) { + return Err(ApiError( + StatusCode::CONFLICT, + Some( + "no user registry: tokens are taken until they expire (docs/access.md)" + .to_owned(), + ), + )); + } + if crate::document::store::read_path(&*doc, &entry)?.is_none() { + return Err(ApiError(StatusCode::NOT_FOUND, None)); + } + entry.push("tokensNotBefore".to_owned()); + crate::document::store::write_path(doc, &entry, &json!(after))?; + Ok(()) + }) + .await?; + info!(user = %name, by = %by, "Revoked every token of a user"); + Ok(()) +} + +/// `GET /auth/whoami`. +pub(crate) async fn whoami( + State(state): State, + Extension(user): Extension, + Extension(via): Extension, + Extension(access): Extension, + token: Option>, +) -> Json { + let replica = state.doc.read().await; + let entry = registry::user(&replica.doc, &user.0); + let token = token.map(|Extension(token)| { + json!({ + "issuer": token.claims.issuer, + "login": token.claims.login, + "issuedAt": token.claims.iat, + "expiresAt": token.claims.exp, + "session": token.claims.session, + "sessionExpiresAt": token.claims.session_exp, + "limit": token.claims.limit, + }) + }); + Json(json!({ + "user": user.0, + "via": via.0, + "registry": registry::exists(&replica.doc), + "caps": entry.as_ref().map(|entry| &entry.caps), + "disabled": entry.as_ref().map(|entry| entry.disabled), + "managesUsers": access.allows(Right::Write, ®istry::users_dir()), + "token": token, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cap(path: &[&str], rights: &[&str]) -> Capability { + Capability { + path: path.iter().map(|s| (*s).to_owned()).collect(), + rights: rights.iter().map(|s| (*s).to_owned()).collect(), + by: String::new(), + since: 0, + } + } + + #[test] + fn a_refreshed_token_says_no_more() { + let earlier = [cap(&["notes"], &["read", "write"])]; + assert!(within(&[cap(&["notes", "a"], &["read"])], &earlier)); + assert!(within(&[cap(&["notes"], &["read", "write"])], &earlier)); + assert!(!within(&[cap(&[], &["read"])], &earlier)); + assert!(!within(&[cap(&["logs"], &["read"])], &earlier)); + assert!(!within(&[cap(&["notes"], &["read", "grant"])], &earlier)); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 03b4fdd..b62163e 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -12,6 +12,7 @@ mod document; mod health; pub(crate) mod listen; mod log; +mod login; mod peers; mod stats; mod store; @@ -62,7 +63,12 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> .route("/admin/changes", get(changes::get_changes)) .route("/admin/changes/{*key}", get(changes::get_changes_at)) .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/logout", axum::routing::post(login::logout)) + .route("/auth/whoami", get(login::whoami)) .route("/admin/users", get(users::list).post(users::add_several)) + .route("/admin/users/{name}/tokens", delete(users::revoke_tokens)) .route( "/admin/users/{name}", get(users::get).post(users::add).delete(users::remove), @@ -97,6 +103,8 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> access::authorize, )) .layer(middleware::from_fn_with_state(auth_state, basic_auth)) + // Outside authentication: a token, if the request brings one, names the user. + .layer(middleware::from_fn_with_state(state.clone(), login::bearer)) // The hand-written spec (`docs/openapi.yaml`), and the Swagger UI showing it. .merge( // Relative to the UI's page (`/swagger-ui/`), so it is found behind a proxy that diff --git a/src/api/users.rs b/src/api/users.rs index e164ecd..7c65eb5 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -9,6 +9,8 @@ //! * `DELETE /admin/users/{name}`: removes a user (`disabled` keeps who wrote what //! readable: usually the better choice). //! * `PUT /admin/users/{name}/disabled`: `true` or `false`. +//! * `DELETE /admin/users/{name}/tokens`: every token of the user no longer taken +//! (`login::revoke_tokens`). //! * `POST /admin/users/{name}/caps`: grants a capability (`{"path": […], "rights": //! […]}`), answered with its ID; `DELETE /admin/users/{name}/caps/{id}` revokes one. //! * `POST /admin/users`: adds several at once, by name, each with the capabilities @@ -47,6 +49,24 @@ pub(crate) struct CapRequest { } impl CapRequest { + /// As a capability, granted `by` at `since`. + pub(crate) fn capability( + &self, + by: &str, + since: u64, + ) -> Result { + let rights = self.rights()?; + Ok(crate::access::Capability { + path: self.path.clone(), + rights: rights + .iter() + .map(|right| right.as_str().to_owned()) + .collect(), + by: by.to_owned(), + since, + }) + } + fn rights(&self) -> Result, ApiError> { if self.rights.is_empty() { return Err(bad("a capability gives at least one right")); @@ -300,6 +320,19 @@ pub(crate) async fn revoke( Ok(StatusCode::NO_CONTENT) } +/// `DELETE /admin/users/{name}/tokens`: every token of the user, of every login, no +/// longer taken (`login::revoke_tokens`). +pub(crate) async fn revoke_tokens( + State(state): State, + Extension(user): Extension, + Extension(access): Extension, + Path(name): Path, +) -> Result { + may_manage(&state, &user.0, &access)?; + super::login::revoke_tokens(&state, &name, &user.0).await?; + Ok(StatusCode::NO_CONTENT) +} + /// `POST /admin/users`. pub(crate) async fn add_several( State(state): State, diff --git a/src/config/mod.rs b/src/config/mod.rs index 5306c5e..60ad413 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -123,6 +123,20 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ None, "HTTP API users (from htpasswd or user-header) who may write protocol data under /store/_continuum (remake records, members, forget records), e.g. to repair a stuck remake, in a swarm without a user registry. Such writes act on the whole swarm and are logged. Epoch metadata and the swarm ID are never writable. With a registry its capabilities decide instead (docs/access.md), but for a registry nobody may manage users of any more: then these may do everything on this node, to set it right.", ), + option( + "token-lifetime", + Kind::Duration, + "DURATION", + Some("12h"), + "How long a token this node issues (POST /auth/login, /auth/refresh) is taken, by every node of the swarm (docs/access.md, Tokens). Refreshed before it ends, it lasts until the login's end (session-lifetime).", + ), + option( + "session-lifetime", + Kind::Duration, + "DURATION", + Some("7d"), + "How long after a login (POST /auth/login) its tokens may be refreshed; then the user logs in again.", + ), option( "bootstrap-users", Kind::List, @@ -543,6 +557,16 @@ pub(crate) struct Settings { pub(crate) protocol_writers: Vec, #[serde(default)] pub(crate) bootstrap_users: Vec, + #[serde( + default = "default_token_lifetime", + with = "crate::units::duration_option" + )] + pub(crate) token_lifetime: Duration, + #[serde( + default = "default_session_lifetime", + with = "crate::units::duration_option" + )] + pub(crate) session_lifetime: Duration, #[serde(default)] pub(crate) debug_api: bool, #[serde(default = "default_log_format")] @@ -700,6 +724,12 @@ fn default_document_append() -> Duration { fn default_document_rewrite() -> Duration { Duration::from_secs(3600) } +fn default_token_lifetime() -> Duration { + Duration::from_secs(12 * 3600) +} +fn default_session_lifetime() -> Duration { + Duration::from_secs(7 * 24 * 3600) +} fn default_keepalive() -> Duration { Duration::from_secs(60) } diff --git a/src/main.rs b/src/main.rs index fb3c0ad..12e57dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -251,6 +251,9 @@ async fn main() -> Result<()> { swarm_key, protocol_writers: args.protocol_writers.iter().cloned().collect(), first_users, + node_key: Some(Arc::clone(&node_key)), + token_lifetime: args.token_lifetime, + session_lifetime: args.session_lifetime, files, state_file, document_file, diff --git a/src/node.rs b/src/node.rs index 8ba1373..c3be694 100644 --- a/src/node.rs +++ b/src/node.rs @@ -64,6 +64,11 @@ pub(crate) struct NodeConfig { /// (`access::registry::first_users`); empty elsewhere, and where none were named /// (the simulations: no registry). pub(crate) first_users: crate::access::registry::FirstUsers, + /// To sign the tokens this node issues (`access::token`); none in the simulations. + pub(crate) node_key: Option>, + /// `--token-lifetime`, `--session-lifetime`. + pub(crate) token_lifetime: Duration, + pub(crate) session_lifetime: Duration, pub(crate) files: Arc, pub(crate) state_file: Option>, pub(crate) document_file: Option>, @@ -144,6 +149,9 @@ impl Node { links: Default::default(), protocol_writers: Arc::new(config.protocol_writers), first_users: Arc::new(config.first_users), + node_key: config.node_key, + token_lifetime: config.token_lifetime, + session_lifetime: config.session_lifetime, state_file: config.state_file, document_file: config.document_file, files: config.files, diff --git a/src/simtest/mod.rs b/src/simtest/mod.rs index 5a202b5..c68fe57 100644 --- a/src/simtest/mod.rs +++ b/src/simtest/mod.rs @@ -213,6 +213,9 @@ impl Sim { swarm_key: None, protocol_writers: HashSet::new(), first_users: Vec::new(), + node_key: None, + token_lifetime: Duration::from_secs(3600), + session_lifetime: Duration::from_secs(86400), files: Arc::new(Files::default()), state_file: None, document_file: None, diff --git a/src/state/mod.rs b/src/state/mod.rs index c420361..223c0c9 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -145,6 +145,10 @@ pub(crate) struct AppState { pub(crate) protocol_writers: Arc>, /// The swarm's first users, recorded by a genesis node (`NodeConfig::first_users`). pub(crate) first_users: Arc, + /// Signs the tokens this node issues (`NodeConfig::node_key`). + pub(crate) node_key: Option>, + pub(crate) token_lifetime: Duration, + pub(crate) session_lifetime: Duration, /// This node's own files, and how much it read and wrote them. pub(crate) files: Arc, /// This node's document on disk (`--document-file`), if configured. -- cgit v1.3.1