//! `/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/challenge` and `POST /auth/login/key`, without a password: a challenge //! to sign with one of the user's keys, then the same token for it, signed //! (`access::challenge`). //! * `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 //! (`_continuum/logouts/`, `registry::logouts_path`). //! * `GET /auth/whoami`: who the request's user is, how it proved it, what it may do. //! //! * `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; 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::auth::{AuthenticatedUser, Via}; use super::users::CapRequest; use super::{ApiError, error_response}; use crate::access::token::{self, Claims}; use crate::access::{Capability, challenge, registry}; use crate::locks::MutexExt; 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, } /// 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"; /// Whether the session cookie lets `request` in: one that reads (`GET`, `HEAD`), or /// renews or ends the session. One that changes anything brings the token itself /// (`Authorization`), as a page of another site, or of another port of the same host /// (which a cookie doesn't tell apart), can't make a browser send it. fn cookie_lets_in(request: &Request) -> bool { use axum::http::Method; let method = request.method(); method == Method::GET || method == Method::HEAD || request.uri().path() == "/auth/session" } /// 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. One without /// `Authorization` but with the session cookie: the cookie's token's, if taken (if not, /// as if it brought none), for reading only (`cookie_lets_in`). Others go on to /// `auth::basic_auth`. pub(crate) async fn bearer( State(state): State, mut request: Request, next: axum::middleware::Next, ) -> Response { 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).filter(|_| cookie_lets_in(&request)) { Some(text) => (text, true), None => 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) } .and_then(|claims| { // Ended here where the document can't record it (no registry). let ended = state .ended_sessions .lock_or_recover() .contains_key(&claims.session); if ended { Err(token::Refused::Ended) } else { Ok(claims) } }); 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 } // 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( StatusCode::UNAUTHORIZED, format!("token refused: {refused}"), None, ) } } } #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct LimitRequest { #[serde(default)] limit: Option>, /// A shorter life than `--token-lifetime`, in seconds: a token to hand over, say. #[serde(default)] lifetime: 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| outer.holds(cap))) } /// `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, "login": claims.login, "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 body = body.map(|Json(body)| body).unwrap_or_default(); let limit = body.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 claims = new_login(&state, swarm, now, &user.0, via.0, limit, body.lifetime); info!(user = %user.0, login = via.0, session = %claims.session, "Logged in"); answer(&state, &claims) } /// How long a token lives: `--token-lifetime`, or less if `asked` (seconds) is less. fn lifetime_ms(state: &AppState, asked: Option) -> u64 { let lifetime = state.token_lifetime.as_millis() as u64; asked.map_or(lifetime, |secs| secs.saturating_mul(1000).min(lifetime)) } /// The claims of a new login of `user`, by `login`, now: a session of its own. fn new_login( state: &AppState, swarm: String, now: u64, user: &str, login: &str, limit: Option>, lifetime: Option, ) -> Claims { let session_exp = now.saturating_add(state.session_lifetime.as_millis() as u64); let exp = now .saturating_add(lifetime_ms(state, lifetime)) .min(session_exp); Claims { v: 1, swarm, issuer: state.local_id().to_owned(), user: user.to_owned(), login: login.to_owned(), iat: now, exp, session: token::new_session(), session_exp: ended_with(session_exp, exp, lifetime), login_exp: session_exp, limit, } } /// When a session ends, `session_exp` at the latest, for a token ending at `exp`: with /// it, where it was asked to live less (`lifetime`). A token to hand over is refreshed /// past its end by nobody, its holder included. fn ended_with(session_exp: u64, exp: u64, lifetime: Option) -> u64 { match lifetime { Some(_) => session_exp.min(exp), None => session_exp, } } /// `POST /auth/challenge`: one to sign for a login by key, valid for five minutes. pub(crate) async fn challenge(State(state): State) -> Result { let (_, now) = issuing(&state).await?; let key = state .node_key .as_ref() .ok_or(ApiError(StatusCode::SERVICE_UNAVAILABLE, None))?; let (challenge, exp) = challenge::issue(key, now, state.challenge_lifetime.as_millis() as u64); Ok(Json(json!({ "challenge": challenge, "namespace": challenge::NAMESPACE, "expiresAt": exp, })) .into_response()) } /// A login by key's request. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct KeyLogin { user: String, /// As `POST /auth/challenge` gave it. challenge: String, /// The user's signature of it, armored, as `ssh-keygen -Y sign -n continuum-login` /// writes it. signature: String, #[serde(default)] limit: Option>, } /// `POST /auth/login/key`: a token, as `POST /auth/login` gives one, for a challenge /// signed by one of the user's keys (`401` if not, saying why); `409` in a swarm without /// a registry, which holds no keys. pub(crate) async fn login_key( State(state): State, Json(request): Json, ) -> Result { let limit = LimitRequest { limit: request.limit, lifetime: None, } .limit()?; let (swarm, now) = issuing(&state).await?; let key = state .node_key .as_ref() .ok_or(ApiError(StatusCode::SERVICE_UNAVAILABLE, None))?; { let replica = state.doc.read().await; if !registry::exists(&replica.doc) { return Err(ApiError( StatusCode::CONFLICT, Some("no user registry, which holds the users' keys".to_owned()), )); } let mut used = state.challenges.lock_or_recover(); challenge::take( &replica.doc, key, &request.challenge, &request.signature, &request.user, now, &mut used, state.key_logins_per_user, ) .map_err(|why| { debug!(user = %request.user, reason = %why, "Refused a login by key"); ApiError(StatusCode::UNAUTHORIZED, Some(why)) })?; } let claims = new_login(&state, swarm, now, &request.user, "key", limit, None); info!(user = %request.user, login = "key", session = %claims.session, "Logged in"); answer(&state, &claims) } /// A stream answered to a request with a token (server-sent events, the store's /// streams) ends with the token: when it expires, or as soon as the document says it /// isn't taken any more (`token::still_taken`: its user disabled or removed, its tokens /// revoked, its session ended, its issuer banned), looked at when a change touches /// protocol data, or when a session ends on this node. The page reconnects with the /// token it has then, if any; a refreshed one is a new token, so a stream lasts at most /// a token's lifetime. Answers that aren't streams pass as they are. pub(crate) async fn streams_end( State(state): State, request: Request, next: axum::middleware::Next, ) -> Response { let token = request .extensions() .get::() .map(|token| Arc::clone(&token.claims)); // Listening before the answer is made: a change revoking the token that comes while // it is (the node applying what it was behind on) isn't missed. let changes = token.as_ref().map(|_| state.change_events.subscribe()); let response = next.run(request).await; let (Some(claims), Some(changes)) = (token, changes) else { return response; }; let streaming = response .headers() .get(axum::http::header::CONTENT_TYPE) .and_then(|kind| kind.to_str().ok()) .is_some_and(|kind| { kind.starts_with("text/event-stream") || kind.starts_with("application/x-ndjson") }); if !streaming { return response; } let (parts, body) = response.into_parts(); let ended = token_ends(state, claims, changes); let body = futures::StreamExt::take_until(body.into_data_stream(), Box::pin(ended)); Response::from_parts(parts, Body::from_stream(body)) } /// When the token of `claims` ends: it expires, or the document (or this node's ended /// sessions) no longer takes it, looked at once at first (what changed since the /// request was let in) and with every change of protocol data in `changes` after. async fn token_ends( state: AppState, claims: Arc, mut changes: tokio::sync::broadcast::Receiver>, ) { if let Some(refused) = refused_now(&state, &claims).await { debug!(user = %claims.user, reason = %refused, "Ended a stream with its token"); return; } let left = claims.exp.saturating_sub(state.clock.now_millis().as_u64()); let expiry = tokio::time::sleep(std::time::Duration::from_millis(left)); tokio::pin!(expiry); loop { let session_ended = state.session_ended.notified(); tokio::select! { () = &mut expiry => return, () = session_ended => {} change = changes.recv() => match change { // Protocol data, the root (a switch) among it. Ok(set) if !set.paths.iter().any(|path| { path.first().is_none_or(|first| first == crate::document::meta::ROOT) }) => continue, Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {} Err(tokio::sync::broadcast::error::RecvError::Closed) => return, }, } if let Some(refused) = refused_now(&state, &claims).await { debug!(user = %claims.user, reason = %refused, "Ended a stream with its token"); return; } } } /// Why the token of `claims` isn't taken any more, if it isn't: by the document, or /// this node's ended sessions. async fn refused_now(state: &AppState, claims: &Claims) -> Option { { let replica = state.doc.read().await; token::still_taken(&replica.doc, claims).err() } .or_else(|| { state .ended_sessions .lock_or_recover() .contains_key(&claims.session) .then_some(token::Refused::Ended) }) } /// `POST /auth/refresh`. pub(crate) async fn refresh( State(state): State, 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>, headers: axum::http::HeaderMap, 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 secure = if over_tls(&headers) { "; Secure" } else { "" }; let cookie = format!( "{SESSION_COOKIE}={}; Path=/; HttpOnly; SameSite=Strict; Max-Age={max_age}{secure}", 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) } /// Whether the request came over TLS, as a reverse proxy in front says /// (`X-Forwarded-Proto`, `Forwarded`): its session cookie is then sent over TLS only /// (`Secure`), not over plain HTTP should someone type `http://`. A client saying so /// itself only keeps its own cookie off plain HTTP. fn over_tls(headers: &axum::http::HeaderMap) -> bool { let said = |name: &str| { headers .get_all(name) .iter() .filter_map(|value| value.to_str().ok()) .collect::>() .join(",") }; let forwarded_proto = said("x-forwarded-proto"); let forwarded = said("forwarded"); forwarded_proto .split(',') .next() .is_some_and(|proto| proto.trim().eq_ignore_ascii_case("https")) || forwarded .split(',') .next() .into_iter() .flat_map(|first| first.split(';')) .filter_map(|pair| pair.trim().split_once('=')) .any(|(name, value)| { name.eq_ignore_ascii_case("proto") && value.trim_matches('"').eq_ignore_ascii_case("https") }) } /// `DELETE /auth/session`: the session ended, with the token it comes with (the cookie, /// or `Authorization: Bearer`): recorded in the document (`registry::session_path`), so /// no node takes its tokens any more, where the swarm has a registry; else on this node /// only (`AppState::ended_sessions`), others taking them until they expire. And the /// session cookie forgotten. Without a token, only that. pub(crate) async fn end_session( State(state): State, token: Option>, ) -> Result { if let Some(Extension(token)) = token { end(&state, &token.claims, &token.claims.user).await?; } Ok(( StatusCode::NO_CONTENT, [( axum::http::header::SET_COOKIE, format!("{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"), )], ) .into_response()) } /// Ends the session of `claims`, as `by`: recorded in the document where the swarm has a /// registry, else on this node only. async fn end(state: &AppState, claims: &Claims, by: &str) -> Result<(), ApiError> { let path = registry::session_path(&claims.user, &claims.session); // The login's end, not this token's refresh deadline (a token handed to another // member's dashboard ends its session in minutes): every token of the session is // refused until it. let until = claims.login_exp.max(claims.session_exp); let recorded = if state.is_ready().await { state .mutate_doc_for(Some(by.to_owned()), Sign::Protocol, move |doc| { if !registry::exists(&*doc) { return Ok::<_, ApiError>(false); } crate::document::store::write_path(doc, &path, &json!(until))?; Ok(true) }) .await? .0 } else { false }; if !recorded { let now = state.clock.now_millis().as_u64(); let mut ended = state.ended_sessions.lock_or_recover(); ended.retain(|_, until| *until > now); ended.insert(claims.session.clone(), until); drop(ended); state.session_ended.notify_waiters(); } info!(user = %claims.user, session = %claims.session, by = %by, recorded, "Ended a session"); Ok(()) } /// 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 earlier = &token.claims; if earlier.session_exp <= now { return Err(ApiError( StatusCode::UNAUTHORIZED, Some("the login ended: log in again".to_owned()), )); } let body = body.map(|Json(body)| body).unwrap_or_default(); let lifetime = body.lifetime; let limit = match body.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 exp = now .saturating_add(lifetime_ms(state, lifetime)) .min(earlier.session_exp); let claims = Claims { v: 1, swarm, issuer: state.local_id().to_owned(), iat: now, exp, session_exp: ended_with(earlier.session_exp, exp, lifetime), limit, ..(**earlier).clone() }; Ok(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 entry = registry::user_path(name); let logouts = registry::logouts_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()), Sign::Protocol, 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)); } crate::document::store::write_path(doc, &logouts, &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, token: Option>, ) -> Json { let swarm = state.swarm_id().await; 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!({ "swarm": swarm, "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), "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)); } /// Over TLS as the proxy says: the first hop's protocol, either header. #[test] fn over_tls_as_the_proxy_says() { let with = |name: &'static str, value: &'static str| { let mut headers = axum::http::HeaderMap::new(); headers.insert(name, axum::http::HeaderValue::from_static(value)); over_tls(&headers) }; assert!(with("x-forwarded-proto", "https")); assert!(with("x-forwarded-proto", "HTTPS, http")); assert!(!with("x-forwarded-proto", "http")); assert!(with( "forwarded", "for=1.2.3.4;proto=https;host=example.org" )); assert!(with("forwarded", "proto=\"https\"")); assert!(!with("forwarded", "proto=http, proto=https")); assert!(!over_tls(&axum::http::HeaderMap::new())); } /// A token asked to live less ends its session with it; another, the session goes on. #[test] fn a_short_token_ends_its_session() { assert_eq!(ended_with(7_000, 300, Some(300)), 300); assert_eq!(ended_with(7_000, 300, None), 7_000); assert_eq!(ended_with(200, 300, Some(300)), 200); } }