//! `/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. //! //! Every request may bring a token instead of a password: `Authorization: Bearer ct1…` //! (`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, } /// 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 claims = new_login(&state, swarm, now, &user.0, via.0, limit); info!(user = %user.0, login = via.0, session = %claims.session, "Logged in"); answer(&state, &claims) } /// 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>, ) -> Claims { let session_exp = now.saturating_add(state.session_lifetime.as_millis() as u64); Claims { v: 1, swarm, issuer: state.local_id().to_owned(), user: user.to_owned(), login: login.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, } } /// `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); 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, } .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, ) .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); info!(user = %request.user, login = "key", 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 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 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), "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)); } }