diff options
Diffstat (limited to 'src/api/login.rs')
| -rw-r--r-- | src/api/login.rs | 341 |
1 files changed, 341 insertions, 0 deletions
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<Claims>, +} + +/// 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`. +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 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<Vec<CapRequest>>, +} + +impl LimitRequest { + fn limit(&self) -> Result<Option<Vec<Capability>>, 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<Response, ApiError> { + 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<AppState>, + Extension(user): Extension<AuthenticatedUser>, + Extension(via): Extension<Via>, + body: Option<Json<LimitRequest>>, +) -> Result<Response, ApiError> { + 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<AppState>, + token: Option<Extension<Token>>, + body: Option<Json<LimitRequest>>, +) -> Result<Response, 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 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<AppState>, + Extension(user): Extension<AuthenticatedUser>, +) -> Result<StatusCode, ApiError> { + 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<AppState>, + Extension(user): Extension<AuthenticatedUser>, + Extension(via): Extension<Via>, + Extension(access): Extension<Access>, + token: Option<Extension<Token>>, +) -> Json<JsonValue> { + 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)); + } +} |
