aboutsummaryrefslogtreecommitdiffstats
path: root/src/api
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-07 22:35:21 +0100
committertv <tv@krebsco.de>2026-11-07 22:35:21 +0100
commite696d2f40636a6613eddfb1a3afdf87aa7ffdedd (patch)
treeb2734381ef1d010d0efad209f116b12f625c0f40 /src/api
parent70ff0ec9e50bf506cc6d80b929a81e350289f6cc (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')
-rw-r--r--src/api/login.rs102
-rw-r--r--src/api/mod.rs6
2 files changed, 101 insertions, 7 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)
}
diff --git a/src/api/mod.rs b/src/api/mod.rs
index a12e219..2a523a4 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -40,7 +40,8 @@ use crate::document::store::StoreError;
use crate::state::AppState;
/// Builds the complete HTTP router. Everything except the API description (`/swagger-ui`,
-/// `/openapi.yaml`), `/health` and `/ready` is authenticated.
+/// `/openapi.yaml`), `/health`, `/ready` and logging in by key (`/auth/challenge`,
+/// `/auth/login/key`, which prove the user themselves) is authenticated.
pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> Router {
let mut api: Router<AppState> = Router::new()
.route("/store", get(store::get_store_root))
@@ -117,6 +118,9 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) ->
SwaggerUi::new("/swagger-ui").config(utoipa_swagger_ui::Config::from("../openapi.yaml")),
)
.route("/openapi.yaml", get(openapi_spec))
+ // Logging in by key: a challenge, and the token for it signed, without a password.
+ .route("/auth/challenge", axum::routing::post(login::challenge))
+ .route("/auth/login/key", axum::routing::post(login::login_key))
.route("/health", get(health::node_health))
.route("/ready", get(health::ready))
// Outermost, so authentication failures get the error body too.