aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/login.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/login.rs')
-rw-r--r--src/api/login.rs111
1 files changed, 97 insertions, 14 deletions
diff --git a/src/api/login.rs b/src/api/login.rs
index 75d6982..de6e390 100644
--- a/src/api/login.rs
+++ b/src/api/login.rs
@@ -14,8 +14,13 @@
//! (`_continuum/logouts/<name>`, `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`).
+//! * `POST /auth/session`, with a token: `refresh`, and the new token kept in the
+//! browser as a cookie (`SESSION_COOKIE`), so its pages (the dashboard) are let in by
+//! it; `DELETE /auth/session` forgets it. The login page (`GET /login`, without
+//! authentication, with the dashboard) gets one by password or from a token pasted.
+//!
+//! Every request may bring a token instead of a password: `Authorization: Bearer ct1…`,
+//! or, with no `Authorization`, the session cookie (`bearer`).
use crate::document::signatures::Sign;
use std::sync::Arc;
@@ -45,22 +50,46 @@ pub(crate) struct Token {
pub(crate) claims: Arc<Claims>,
}
+/// The cookie a browser keeps its session's token in (`POST /auth/session`):
+/// `HttpOnly`, `SameSite=Strict`, so no script and no other site's page sends it.
+pub(crate) const SESSION_COOKIE: &str = "continuum-token";
+
+/// The session cookie's token, if the request brings one.
+fn session_cookie(request: &Request<Body>) -> Option<String> {
+ request
+ .headers()
+ .get_all(axum::http::header::COOKIE)
+ .iter()
+ .filter_map(|value| value.to_str().ok())
+ .flat_map(|value| value.split(';'))
+ .filter_map(|pair| pair.trim().split_once('='))
+ .find(|(name, _)| *name == SESSION_COOKIE)
+ .map(|(_, token)| token.to_owned())
+}
+
/// 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`.
+/// node takes it (`token::verify`); `401`, saying why, if not. One without
+/// `Authorization` but with the session cookie: the cookie's token's, if taken (if not,
+/// as if it brought none). 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 authorization = request.headers().get(AUTHORIZATION).cloned();
+ let (text, cookie) = match authorization {
+ Some(value) => match value
+ .to_str()
+ .ok()
+ .and_then(|value| value.strip_prefix("Bearer "))
+ {
+ Some(text) => (text.to_owned(), false),
+ None => return next.run(request).await,
+ },
+ None => match session_cookie(&request) {
+ Some(text) => (text, true),
+ None => return next.run(request).await,
+ },
};
let Some(swarm) = state.swarm_id().await else {
return error_response(
@@ -85,6 +114,11 @@ pub(crate) async fn bearer(
});
next.run(request).await
}
+ // A stale session: as if there were none (the login page, a password).
+ Err(refused) if cookie => {
+ debug!(reason = %refused, "Ignored the session cookie's token");
+ next.run(request).await
+ }
Err(refused) => {
debug!(reason = %refused, "Refused a token");
error_response(
@@ -288,13 +322,62 @@ pub(crate) async fn refresh(
token: Option<Extension<Token>>,
body: Option<Json<LimitRequest>>,
) -> Result<Response, ApiError> {
+ let claims = refreshed(&state, token, body).await?;
+ answer(&state, &claims)
+}
+
+/// `POST /auth/session`: `refresh`, and the new token kept as the session cookie, for as
+/// long as it is valid.
+pub(crate) async fn session(
+ State(state): State<AppState>,
+ token: Option<Extension<Token>>,
+ body: Option<Json<LimitRequest>>,
+) -> Result<Response, ApiError> {
+ let claims = refreshed(&state, token, body).await?;
+ let mut response = answer(&state, &claims)?;
+ let key = state
+ .node_key
+ .as_ref()
+ .ok_or(ApiError(StatusCode::SERVICE_UNAVAILABLE, None))?;
+ let max_age = claims.exp.saturating_sub(claims.iat) / 1000;
+ let cookie = format!(
+ "{SESSION_COOKIE}={}; Path=/; HttpOnly; SameSite=Strict; Max-Age={max_age}",
+ token::issue(key, &claims)
+ );
+ if let Ok(value) = axum::http::HeaderValue::from_str(&cookie) {
+ response
+ .headers_mut()
+ .append(axum::http::header::SET_COOKIE, value);
+ }
+ Ok(response)
+}
+
+/// `DELETE /auth/session`: the session cookie forgotten (the token itself stays valid
+/// until it expires; `POST /auth/logout` ends every one). A stale one is ignored anyway.
+pub(crate) async fn end_session() -> Response {
+ (
+ StatusCode::NO_CONTENT,
+ [(
+ axum::http::header::SET_COOKIE,
+ format!("{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"),
+ )],
+ )
+ .into_response()
+}
+
+/// The claims a refresh of `token` gives (`limit` within its own).
+async fn refreshed(
+ state: &AppState,
+ token: Option<Extension<Token>>,
+ body: Option<Json<LimitRequest>>,
+) -> Result<Claims, 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 (swarm, now) = issuing(state).await?;
let earlier = &token.claims;
if earlier.session_exp <= now {
return Err(ApiError(
@@ -329,7 +412,7 @@ pub(crate) async fn refresh(
limit,
..(**earlier).clone()
};
- answer(&state, &claims)
+ Ok(claims)
}
/// `POST /auth/logout`.