Skip to main content

acme_proxy/webadmin/handlers/
session.rs

1//! `POST`/`GET`/`DELETE /api/session` — sign in, whoami, sign out.
2
3use axum::Json;
4use axum::extract::{Query, State};
5use axum::http::{StatusCode, header};
6use axum::response::{IntoResponse, Response};
7use serde::Deserialize;
8use serde_json::json;
9use std::time::Duration;
10use tracing::{info, warn};
11
12use crate::admin::mfa::MfaOutcome;
13use crate::admin::users::{self, AuthOutcome};
14use crate::sqlite::admin_session::{AdminSession, NewSession};
15use crate::sqlite::admin_user::AdminUser;
16use crate::webadmin::AdminState;
17use crate::webadmin::error::AdminError;
18use crate::webadmin::session::{
19    AdminClientIp, Authenticated, AuthenticatedWrite, MfaStep, PENDING_MFA_TTL, PendingMfa,
20    PendingMfaSubmit, check_origin, clearing_cookie, cookie_value, hash_token, log_login,
21    mint_csrf_token, mint_token, session_cookie,
22};
23
24#[derive(Debug, Deserialize)]
25pub struct LoginRequest {
26    pub username: String,
27    pub password: String,
28}
29
30#[derive(Debug, Deserialize, Default)]
31pub struct LogoutQuery {
32    /// Sign out of every browser, not just this one.
33    #[serde(default)]
34    pub all: bool,
35}
36
37/// The submission that finishes a login: a TOTP code, or a recovery code. One
38/// field, because the operator types whichever they have into the same box.
39#[derive(Debug, Deserialize)]
40pub struct MfaRequest {
41    pub code: String,
42}
43
44/// A sign-in: the two rows it produced, and the cookie to set.
45pub(crate) struct SignedIn {
46    pub user: AdminUser,
47    pub session: AdminSession,
48    /// The `Set-Cookie` value, built once here so the token itself — which is
49    /// never stored — does not have to travel any further than this.
50    pub cookie: String,
51    /// `None` is a *completed* login and an `active` session — the shape every
52    /// caller had before there was a second factor.
53    pub pending: Option<MfaStep>,
54}
55
56/// Everything a sign-in is, independent of how the credentials arrived.
57///
58/// Called by [`post_session`] with a JSON body and by
59/// [`crate::webadmin::pages::session::post_login`] with a form body. The two
60/// front ends must not drift on any of this: the origin gate, the limiter
61/// running *before* the 600 000-iteration hash, the indistinguishable failures,
62/// or the session-fixation delete.
63pub(crate) async fn sign_in(
64    state: &AdminState,
65    client: Option<std::net::IpAddr>,
66    headers: &axum::http::HeaderMap,
67    credentials: &LoginRequest,
68) -> Result<SignedIn, AdminError> {
69    let body = credentials;
70    check_origin(headers, &state.config.admin.base_url)?;
71
72    if let Err(retry_after) = state.logins.check(client) {
73        log_login(false, &body.username, client, "rate_limited");
74        return Err(AdminError::rate_limited(retry_after));
75    }
76
77    let outcome =
78        users::authenticate(&body.username, &body.password, state.database.clone()).await?;
79
80    // Every failure answers identically; only the log says which.
81    let mut user = match outcome {
82        AuthOutcome::Authenticated(user) => *user,
83        other => {
84            let reason = match other {
85                AuthOutcome::UnknownUser => "unknown_user",
86                AuthOutcome::WrongPassword(_) => "wrong_password",
87                AuthOutcome::Disabled(_) => "account_disabled",
88                AuthOutcome::Authenticated(_) => unreachable!("handled above"),
89            };
90            state.logins.record_failure(client);
91            log_login(false, &body.username, client, reason);
92            return Err(AdminError::invalid_credentials());
93        }
94    };
95
96    // What still stands between this password and a usable session. An
97    // operator who *has* a factor is challenged whether `require_mfa` is set or
98    // not: the flag governs only the operator who has none.
99    let step = if user.has_totp() {
100        Some(MfaStep::Verify)
101    } else if state.config.admin.require_mfa {
102        Some(MfaStep::Enrol)
103    } else {
104        None
105    };
106
107    // Session fixation: whatever session this request already carried is gone,
108    // whether or not it was valid. A login must never keep an attacker-planted
109    // cookie alive.
110    if let Some(existing) = cookie_value(headers) {
111        AdminSession::delete(&hash_token(&existing), &state.database).await?;
112    }
113
114    let minted = mint_token();
115    let csrf_token = mint_csrf_token();
116    let created_ip = client.map(|ip| ip.to_string());
117    let user_agent = headers
118        .get(header::USER_AGENT)
119        .and_then(|v| v.to_str().ok())
120        .map(str::to_string);
121
122    let Some(step) = step else {
123        let ttl = Duration::from_secs(state.config.admin.session_ttl_seconds);
124        let session = AdminSession::create(
125            NewSession {
126                user_id: user.id,
127                token_hash: &minted.token_hash,
128                csrf_token: &csrf_token,
129                created_ip,
130                user_agent,
131            },
132            ttl,
133            &state.database,
134        )
135        .await?;
136
137        user.mark_logged_in(&state.database).await?;
138        state.logins.record_success(client);
139        log_login(true, &user.username, client, "");
140
141        return Ok(SignedIn {
142            user,
143            session,
144            cookie: session_cookie(&minted.token, ttl),
145            pending: None,
146        });
147    };
148
149    // Half-authenticated. Three things deliberately do **not** happen here, and
150    // each would be a real hole:
151    //
152    // - `record_success`: it clears this address's limiter bucket, so leaving
153    //   it here would let somebody holding a correct password reset their own
154    //   budget on every attempt and then guess six-digit codes without limit.
155    // - `mark_logged_in`: `last_login_at` should mean "completed a login", not
156    //   "typed the right password".
157    // - `log_login(true, …)`: the login has not succeeded yet.
158    //
159    // No `record_failure` either: the password *was* right.
160    let session = AdminSession::create_pending(
161        NewSession {
162            user_id: user.id,
163            token_hash: &minted.token_hash,
164            csrf_token: &csrf_token,
165            created_ip,
166            user_agent,
167        },
168        PENDING_MFA_TTL,
169        &state.database,
170    )
171    .await?;
172
173    info!(event = "admin_login_mfa_pending",
174          outcome = "success",
175          username = %user.username,
176          client_ip = ?client,
177          step = step.as_str());
178
179    Ok(SignedIn {
180        user,
181        session,
182        // The cookie's Max-Age follows the row, not the configured session
183        // lifetime: a browser holding it for twelve hours after the row died in
184        // five minutes only produces a confusing refusal.
185        cookie: session_cookie(&minted.token, PENDING_MFA_TTL),
186        pending: Some(step),
187    })
188}
189
190/// Everything finishing a login is, independent of how the code arrived.
191///
192/// [`sign_in`]'s twin, and shared by [`post_session_mfa`] and
193/// `pages::session::post_login_mfa` for the same reason: the limiter, the
194/// indistinguishable failures and the token rotation must not drift between the
195/// two front ends.
196pub(crate) async fn finish_mfa(
197    state: &AdminState,
198    client: Option<std::net::IpAddr>,
199    pending: PendingMfa,
200    submitted: &str,
201) -> Result<SignedIn, AdminError> {
202    // The origin gate already ran, in `PendingMfaSubmit`'s extractor -- it is
203    // what stands in for the CSRF token this route cannot have.
204    if let Err(retry_after) = state.logins.check(client) {
205        log_login(false, &pending.user.username, client, "rate_limited");
206        return Err(AdminError::rate_limited(retry_after));
207    }
208
209    let mut user = pending.user;
210    let outcome =
211        crate::admin::mfa::verify_second_factor(&mut user, submitted, state.database.clone())
212            .await?;
213
214    let MfaOutcome::Accepted { via, .. } = outcome else {
215        state.logins.record_failure(client);
216
217        // The bound the address-keyed limiter cannot provide. A `pending_mfa`
218        // cookie is valid from any address on purpose, so without this an
219        // attacker holding the password gets `login_max_attempts` fresh guesses
220        // per source address -- 2^64 of them from one IPv6 /64, against a
221        // six-digit code with a three-step window. The counter lives on the
222        // session, so rotating addresses buys nothing, and it is incremented by
223        // a single `UPDATE ... RETURNING`, so concurrent submissions cannot
224        // each observe zero.
225        let spent = AdminSession::record_mfa_failure(&pending.session.token_hash, &state.database)
226            .await?
227            .is_some_and(|attempts| attempts >= i64::from(state.config.admin.login_max_attempts));
228
229        if spent {
230            // Past the cap the half-authenticated session is over, not merely
231            // refused: producing the password again is the only way back to a
232            // pending row, and `sign_in`'s limiter bounds *that*.
233            AdminSession::delete(&pending.session.token_hash, &state.database).await?;
234            warn!(event = "admin_mfa_attempts_exhausted",
235                  outcome = "failure",
236                  username = %user.username,
237                  client_ip = ?client,
238                  max_attempts = state.config.admin.login_max_attempts);
239        }
240
241        warn!(event = "admin_mfa_failed",
242              outcome = "failure",
243              username = %user.username,
244              client_ip = ?client,
245              reason = outcome.reason());
246        // The same answer a wrong password gets, and for the same reason: a
247        // wrong code, a spent recovery code and a replayed one are one refusal
248        // to the client, and only the log says which. Exhausting the attempts
249        // is deliberately not a distinguishable answer either -- the next
250        // request will simply find no session.
251        return Err(AdminError::invalid_credentials());
252    };
253
254    let (session, cookie) = promote_pending(state, &pending.session.token_hash).await?;
255
256    user.mark_logged_in(&state.database).await?;
257    state.logins.record_success(client);
258    info!(event = "admin_mfa_verified",
259          outcome = "success",
260          username = %user.username,
261          method = via.as_str());
262    log_login(true, &user.username, client, "");
263
264    Ok(SignedIn {
265        user,
266        session,
267        cookie,
268        pending: None,
269    })
270}
271
272/// Rotates a `pending_mfa` session into a fresh `active` one, returning the row
273/// and the `Set-Cookie` carrying its new token.
274///
275/// Shared by [`finish_mfa`] and the enrolment-confirm handler, which is the
276/// other way a half-authenticated session becomes usable: under
277/// `admin.require_mfa`, an operator with no factor finishes their login by
278/// *setting one up*, not by proving one.
279pub(crate) async fn promote_pending(
280    state: &AdminState,
281    pending_token_hash: &str,
282) -> Result<(AdminSession, String), AdminError> {
283    let ttl = Duration::from_secs(state.config.admin.session_ttl_seconds);
284    let minted = mint_token();
285    let csrf_token = mint_csrf_token();
286
287    let Some(session) = AdminSession::promote(
288        pending_token_hash,
289        &minted.token_hash,
290        &csrf_token,
291        ttl,
292        &state.database,
293    )
294    .await?
295    else {
296        // Lost the race to a concurrent submission, or the row expired between
297        // the extractor and here. Either way there is nothing to promote.
298        return Err(AdminError::session_invalid());
299    };
300
301    Ok((session, session_cookie(&minted.token, ttl)))
302}
303
304/// [`finish_mfa`]'s twin for the other way a login completes: setting a factor
305/// up rather than proving one.
306///
307/// Under `admin.require_mfa` an operator with no factor lands on the enrolment
308/// page and their session stays `pending_mfa` until they confirm a code. That
309/// confirmation **is** a completed sign-in, and it owes the same three pieces of
310/// bookkeeping the code path owes:
311///
312/// - `mark_logged_in`, so `last_login_at` means "completed a login";
313/// - `record_success`, so the operator does not leave their own limiter bucket
314///   dirty after signing in;
315/// - `log_login(true, …)`, without which a first sign-in — the one that hands
316///   out a first factor — produces no `admin_login_succeeded` event at all.
317///
318/// It exists as a shared function for the reason [`finish_mfa`] does: the JSON
319/// and HTML front ends each call it, and before it they had drifted, the API
320/// side doing none of the three and the pages side one of them.
321pub(crate) async fn finish_enrolment(
322    state: &AdminState,
323    client: Option<std::net::IpAddr>,
324    user: &mut crate::sqlite::admin_user::AdminUser,
325    pending_token_hash: &str,
326) -> Result<(AdminSession, String), AdminError> {
327    let (session, cookie) = promote_pending(state, pending_token_hash).await?;
328    user.mark_logged_in(&state.database).await?;
329    state.logins.record_success(client);
330    info!(event = "admin_mfa_enrolled", outcome = "success", username = %user.username);
331    log_login(true, &user.username, client, "");
332    Ok((session, cookie))
333}
334
335/// `POST /api/session` — exchange a username and password for a session cookie.
336///
337/// The one route that is reachable without a session, so it carries its own
338/// protections: the origin gate (there is no CSRF token yet to check) and the
339/// rate limiter, which runs **before** the password hash so a flood does not
340/// cost 600 000 iterations per attempt. Both live in [`sign_in`], which the
341/// sign-in *page* calls too.
342pub async fn post_session(
343    State(state): State<AdminState>,
344    AdminClientIp(client): AdminClientIp,
345    headers: axum::http::HeaderMap,
346    Json(body): Json<LoginRequest>,
347) -> Result<Response, AdminError> {
348    let signed_in = sign_in(&state, client, &headers, &body).await?;
349    Ok(signed_in_response(&signed_in))
350}
351
352/// `GET /api/session/mfa` — what this half-authenticated cookie still owes.
353///
354/// Unreachable with an `active` session, and with none at all: `PendingMfa` is
355/// `Authenticated`'s exact mirror image.
356pub async fn get_session_mfa(pending: PendingMfa) -> Json<serde_json::Value> {
357    Json(json!({
358        "step": pending.step.as_str(),
359        "expiresAt": crate::sqlite::order::rfc3339(pending.session.expires_at),
360    }))
361}
362
363/// `POST /api/session/mfa` — finish a login with a code.
364///
365/// Takes the submission whichever it is: the TOTP code is tried first and the
366/// recovery codes after, because one is an HMAC and the other is up to ten
367/// PBKDF2 runs.
368///
369/// Deliberately **not** CSRF-checked — see [`PendingMfaSubmit`], whose extractor
370/// runs the origin gate in its place.
371pub async fn post_session_mfa(
372    State(state): State<AdminState>,
373    AdminClientIp(client): AdminClientIp,
374    PendingMfaSubmit(pending): PendingMfaSubmit,
375    Json(body): Json<MfaRequest>,
376) -> Result<Response, AdminError> {
377    let signed_in = finish_mfa(&state, client, pending, &body.code).await?;
378    Ok(signed_in_response(&signed_in))
379}
380
381/// The `200` both login steps answer with.
382///
383/// A pending answer carries **no `user` member**: a half-authenticated session
384/// must not read operator metadata. It is not a `401` either -- the password
385/// *was* right, and a script has to be able to tell those two apart.
386fn signed_in_response(signed_in: &SignedIn) -> Response {
387    let body = match signed_in.pending {
388        None => session_body(&signed_in.user, &signed_in.session),
389        Some(step) => json!({
390            "mfaRequired": true,
391            "step": step.as_str(),
392            "csrfToken": signed_in.session.csrf_token,
393            "expiresAt": crate::sqlite::order::rfc3339(signed_in.session.expires_at),
394        }),
395    };
396
397    (
398        StatusCode::OK,
399        [(header::SET_COOKIE, signed_in.cookie.clone())],
400        Json(body),
401    )
402        .into_response()
403}
404
405/// `GET /api/session` — who am I, and what is my CSRF token.
406///
407/// The CSRF token is returned here as well as at login so a page reloaded on a
408/// still-live cookie can pick it up without signing in again.
409pub async fn get_session(auth: Authenticated) -> Json<serde_json::Value> {
410    Json(session_body(&auth.user, &auth.session))
411}
412
413/// `DELETE /api/session[?all=true]` — sign out.
414pub async fn delete_session(
415    State(state): State<AdminState>,
416    Query(query): Query<LogoutQuery>,
417    AuthenticatedWrite(auth): AuthenticatedWrite,
418) -> Result<Response, AdminError> {
419    let scope = if query.all {
420        AdminSession::delete_for_user(auth.user.id, &state.database).await?;
421        "all"
422    } else {
423        AdminSession::delete(&auth.session.token_hash, &state.database).await?;
424        "one"
425    };
426
427    tracing::info!(event = "admin_logout", outcome = "success", surface = "api", username = %auth.user.username, scope = scope);
428    Ok((
429        StatusCode::NO_CONTENT,
430        [(header::SET_COOKIE, clearing_cookie())],
431    )
432        .into_response())
433}
434
435/// The body both `POST` and `GET /api/session` return.
436fn session_body(user: &AdminUser, session: &AdminSession) -> serde_json::Value {
437    json!({
438        "user": crate::admin::render_admin_user_json(user),
439        "csrfToken": session.csrf_token,
440        "expiresAt": crate::sqlite::order::rfc3339(session.expires_at),
441    })
442}