diff options
| author | tv <tv@krebsco.de> | 2026-11-07 22:35:21 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-07 22:35:21 +0100 |
| commit | e696d2f40636a6613eddfb1a3afdf87aa7ffdedd (patch) | |
| tree | b2734381ef1d010d0efad209f116b12f625c0f40 /src/api/login.rs | |
| parent | 70ff0ec9e50bf506cc6d80b929a81e350289f6cc (diff) | |
auth: logging in by key
POST /auth/challenge and /auth/login/key: a node-signed challenge,
valid five minutes and taken once, signed with one of the user's SSH
keys (ssh-keygen -Y sign -n continuum-login), for a token.
Auth plan M7 (key login, M2b).
Diffstat (limited to 'src/api/login.rs')
| -rw-r--r-- | src/api/login.rs | 102 |
1 files changed, 96 insertions, 6 deletions
diff --git a/src/api/login.rs b/src/api/login.rs index 72a4d9d..75d6982 100644 --- a/src/api/login.rs +++ b/src/api/login.rs @@ -5,6 +5,9 @@ //! 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 @@ -32,7 +35,8 @@ use super::auth::{AuthenticatedUser, Via}; use super::users::CapRequest; use super::{ApiError, error_response}; use crate::access::token::{self, Claims}; -use crate::access::{Capability, registry}; +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`). @@ -173,13 +177,27 @@ pub(crate) async fn login( )); } } + 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<Vec<Capability>>, +) -> Claims { let session_exp = now.saturating_add(state.session_lifetime.as_millis() as u64); - let claims = Claims { + Claims { v: 1, swarm, issuer: state.local_id().to_owned(), - user: user.0.clone(), - login: via.0.to_owned(), + user: user.to_owned(), + login: login.to_owned(), iat: now, exp: now .saturating_add(state.token_lifetime.as_millis() as u64) @@ -187,8 +205,80 @@ pub(crate) async fn login( session: token::new_session(), session_exp, limit, - }; - info!(user = %user.0, login = via.0, session = %claims.session, "Logged in"); + } +} + +/// `POST /auth/challenge`: one to sign for a login by key, valid for five minutes. +pub(crate) async fn challenge(State(state): State<AppState>) -> Result<Response, ApiError> { + 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<Vec<CapRequest>>, +} + +/// `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<AppState>, + Json(request): Json<KeyLogin>, +) -> Result<Response, ApiError> { + 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) } |
