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. --- 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 ++++++ 5 files changed, 442 insertions(+), 5 deletions(-) create mode 100644 src/api/login.rs (limited to 'src/api') 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, -- cgit v1.3.1