Add authentication
Proving who somebody is and starting a session for them are separate operations. login_with_caldav does the first by asking the CalDAV server whether the credentials work; begin_session does the second and knows nothing about how the question was answered. OIDC arrives as a second way to reach begin_session, not as a second scheme threaded through everything -- which is what v1 had, with a JWT for most of the app and a separate SQLite session_token used only by the preferences API. The token lives in an HttpOnly cookie and nowhere else. v1 kept a JWT and the CalDAV password in localStorage, readable by any script on the origin, and re-sent the password in a header on every request. Here the password never leaves the server: it is encrypted with XChaCha20-Poly1305 and caldav_for is the only path back, handing out a client rather than a credential. Failed decryption is an error, not a subtly wrong password -- the AEAD tag is checked, so a tampered row surfaces here instead of as a mysterious CalDAV rejection later. A wrong password and an unreachable server stay distinct, because telling somebody their password is wrong when the server is down sends them to reset one that was fine. Errors carry a stable code alongside their message, so a client can branch on them. Internal ones say nothing about the inside of the server; the detail goes to the log. Tests go through router(), the same function main calls -- v1's suite rebuilt the route table and tested a copy until it stopped compiling. Skipping is now loud: a skipped test reports "ok", so run.sh sets RUNWAY_REQUIRE_CALDAV=1 and not running becomes a failure.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
//! Signing in and out.
|
||||
|
||||
use crate::auth::{CurrentUser, clear_session_cookie, session_cookie};
|
||||
use crate::db::User;
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::http::header::USER_AGENT;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
/// The DAV root, e.g. `https://example.com/dav.php/`.
|
||||
pub server_url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// What a signed-in client is told about itself.
|
||||
///
|
||||
/// The [`User`] row is returned directly rather than copied into a parallel
|
||||
/// response struct. It holds nothing secret — the password lives encrypted in
|
||||
/// its own table and never travels — and v1's four near-identical wire structs
|
||||
/// are a warning about what maintaining a second shape costs.
|
||||
#[derive(Serialize)]
|
||||
pub struct SessionResponse {
|
||||
pub user: User,
|
||||
}
|
||||
|
||||
/// `POST /api/auth/login`
|
||||
///
|
||||
/// Verification is a real request to the CalDAV server: if it will list your
|
||||
/// calendars, you are who you say you are. There is no Runway password to
|
||||
/// forget, and revoking an account on the CalDAV server revokes it here.
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<LoginRequest>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if request.username.is_empty() || request.password.is_empty() {
|
||||
return Err(ApiError::BadRequest(
|
||||
"a username and password are both required".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let user_agent = headers
|
||||
.get(USER_AGENT)
|
||||
.and_then(|value| value.to_str().ok());
|
||||
|
||||
let (user, token) = state
|
||||
.auth
|
||||
.login_with_caldav(
|
||||
request.server_url.trim(),
|
||||
request.username.trim(),
|
||||
&request.password,
|
||||
user_agent,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// The token goes in an HttpOnly cookie and nowhere else. It is deliberately
|
||||
// absent from the body: anything in the body is readable by script, which
|
||||
// is the property the cookie exists to avoid.
|
||||
let jar = jar.add(session_cookie(
|
||||
token,
|
||||
state.auth.session_lifetime(),
|
||||
state.config.secure_cookies,
|
||||
));
|
||||
|
||||
Ok((jar, Json(SessionResponse { user })).into_response())
|
||||
}
|
||||
|
||||
/// `POST /api/auth/logout`
|
||||
///
|
||||
/// Ends the session server-side as well as clearing the cookie. Clearing only
|
||||
/// the cookie would leave a token that still works for anyone holding a copy.
|
||||
pub async fn logout(State(state): State<AppState>, jar: CookieJar) -> Result<Response, ApiError> {
|
||||
if let Some(cookie) = jar.get(crate::auth::SESSION_COOKIE) {
|
||||
state.auth.logout(cookie.value()).await?;
|
||||
}
|
||||
|
||||
let jar = jar.add(clear_session_cookie(state.config.secure_cookies));
|
||||
Ok((jar, Json(serde_json::json!({ "status": "signed out" }))).into_response())
|
||||
}
|
||||
|
||||
/// `GET /api/auth/session`
|
||||
///
|
||||
/// Who the caller is. Used on startup to decide whether to show the login
|
||||
/// screen, which replaces v1's separate token-verification endpoint.
|
||||
pub async fn current_session(
|
||||
CurrentUser { user, .. }: CurrentUser,
|
||||
) -> Result<Json<SessionResponse>, ApiError> {
|
||||
Ok(Json(SessionResponse { user }))
|
||||
}
|
||||
Reference in New Issue
Block a user