aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/login.rs
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-07 22:35:09 +0100
committertv <tv@krebsco.de>2026-11-07 22:35:09 +0100
commitec70060ae5fa665953741da921e28031b56bc5e0 (patch)
treecd1f6f5ae90d3ad87b9667da20d6e85b62394ab1 /src/api/login.rs
parenteed8f86912f53f549d9f9068f9bdf2c8e23aa0f7 (diff)
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.
Diffstat (limited to 'src/api/login.rs')
-rw-r--r--src/api/login.rs341
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, &registry::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));
+ }
+}