Skip to main content

acme_proxy_admin/webadmin/handlers/
session.rs

1//! `POST`/`GET`/`DELETE /api/session` — sign in, whoami, sign out.
2//!
3//! Every failed sign-in answers one `invalid_credentials`, whatever the cause;
4//! the reason (`wrong_password`, `unknown_user`, `account_disabled`) is only in
5//! the `admin_login_failed` log line. An operator with a second factor gets a
6//! `pending_mfa` session first, which can do nothing but finish the login.
7
8use axum::Json;
9use axum::extract::{Query, State};
10use axum::http::{StatusCode, header};
11use axum::response::{IntoResponse, Response};
12use serde::Deserialize;
13use serde_json::json;
14use std::time::Duration;
15use tracing::{info, warn};
16
17use crate::admin::mfa::MfaOutcome;
18use crate::admin::users::{self, AuthOutcome};
19use crate::webadmin::AdminState;
20use crate::webadmin::error::AdminError;
21use crate::webadmin::handlers::Caller;
22use crate::webadmin::session::{
23    AdminClientIp, Authenticated, MfaStep, PENDING_MFA_TTL, PendingMfa, PendingMfaSubmit,
24    SelfServiceWrite, check_origin, clearing_cookie, cookie_value, hash_token, log_login,
25    mint_csrf_token, mint_token, session_cookie,
26};
27use acme_proxy_store::admin_session::AdminSession;
28use acme_proxy_store::admin_session::NewSession;
29use acme_proxy_store::admin_user::AdminUser;
30
31/// The credentials of `POST /api/session`, and of the sign-in page's form.
32#[derive(Debug, Deserialize)]
33pub struct LoginRequest {
34    pub username: String,
35    pub password: String,
36}
37
38/// The query of a sign-out, `DELETE /api/session` or its page twin.
39#[derive(Debug, Deserialize, Default)]
40pub struct LogoutQuery {
41    /// Sign out of every browser, not just this one.
42    #[serde(default)]
43    pub all: bool,
44}
45
46/// The submission that finishes a login: a TOTP code, or a recovery code. One
47/// field, because the operator types whichever they have into the same box.
48#[derive(Debug, Deserialize)]
49pub struct MfaRequest {
50    pub code: String,
51}
52
53/// A sign-in: the two rows it produced, and the cookie to set.
54pub(crate) struct SignedIn {
55    pub user: AdminUser,
56    pub session: AdminSession,
57    /// The `Set-Cookie` value, built once here so the token itself — which is
58    /// never stored — does not have to travel any further than this.
59    pub cookie: String,
60    /// `None` is a *completed* login and an `active` session — the shape every
61    /// caller had before there was a second factor.
62    pub pending: Option<MfaStep>,
63}
64
65/// Everything a sign-in is, independent of how the credentials arrived.
66///
67/// Called by [`post_session`] with a JSON body and by
68/// [`crate::webadmin::pages::session::post_login`] with a form body. The two
69/// front ends must not drift on any of this: the origin gate, the limiter
70/// running *before* the 600 000-iteration hash, the indistinguishable failures,
71/// or the session-fixation delete.
72pub(crate) async fn sign_in(
73    state: &AdminState,
74    client: Option<std::net::IpAddr>,
75    headers: &axum::http::HeaderMap,
76    credentials: &LoginRequest,
77) -> Result<SignedIn, AdminError> {
78    let body = credentials;
79    check_origin(headers, &state.config.admin.base_url)?;
80
81    let attempt = match state.logins.begin(client) {
82        Ok(attempt) => attempt,
83        Err(retry_after) => {
84            log_login(false, &body.username, client, "rate_limited");
85            return Err(AdminError::rate_limited(retry_after));
86        }
87    };
88
89    let outcome =
90        users::authenticate(&body.username, &body.password, state.database.clone()).await?;
91
92    // Every failure answers identically; only the log says which.
93    let refused = match outcome {
94        AuthOutcome::Authenticated(user) => Ok(*user),
95        AuthOutcome::UnknownUser => Err("unknown_user"),
96        AuthOutcome::WrongPassword(_) => Err("wrong_password"),
97        AuthOutcome::Disabled(_) => Err("account_disabled"),
98    };
99    let mut user = match refused {
100        Ok(user) => user,
101        Err(reason) => {
102            attempt.failed();
103            log_login(false, &body.username, client, reason);
104            return Err(AdminError::invalid_credentials());
105        }
106    };
107
108    // What still stands between this password and a usable session. An
109    // operator who *has* a factor is challenged whether `require_mfa` is set or
110    // not: the flag governs only the operator who has none.
111    let step = if user.has_totp() {
112        Some(MfaStep::Verify)
113    } else if state.config.admin.require_mfa {
114        Some(MfaStep::Enrol)
115    } else {
116        None
117    };
118
119    // Session fixation: whatever session this request already carried is gone,
120    // whether or not it was valid. A login must never keep an attacker-planted
121    // cookie alive.
122    if let Some(existing) = cookie_value(headers) {
123        AdminSession::delete(&hash_token(&existing), &state.database).await?;
124    }
125
126    let minted = mint_token();
127    let csrf_token = mint_csrf_token();
128    let created_ip = client.map(|ip| ip.to_string());
129    // Capped: an unauthenticated caller writes this into the session row and
130    // into the sign-in notification's durable payload.
131    let user_agent = crate::webadmin::user_agent_of(headers);
132
133    let Some(step) = step else {
134        let ttl = Duration::from_secs(state.config.admin.session_ttl_seconds);
135        let session = AdminSession::create(
136            NewSession {
137                user_id: user.id,
138                token_hash: &minted.token_hash,
139                csrf_token: &csrf_token,
140                created_ip,
141                user_agent: user_agent.clone(),
142            },
143            ttl,
144            &state.database,
145        )
146        .await?;
147
148        let known_before = user.known_login_ips.clone();
149        user.mark_logged_in(client_ip_str(client).as_deref(), &state.database)
150            .await?;
151        state.logins.record_success(client);
152        log_login(true, &user.username, client, "");
153        notify_sign_in_from_new_address(state, &user, &known_before, client, user_agent).await;
154
155        return Ok(SignedIn {
156            user,
157            session,
158            cookie: session_cookie(&minted.token, ttl),
159            pending: None,
160        });
161    };
162
163    // Half-authenticated. Three things deliberately do **not** happen here, and
164    // each would be a real hole:
165    //
166    // - `record_success`: it clears this address's limiter bucket, so leaving
167    //   it here would let somebody holding a correct password reset their own
168    //   budget on every attempt and then guess six-digit codes without limit.
169    // - `mark_logged_in`: `last_login_at` should mean "completed a login", not
170    //   "typed the right password".
171    // - `log_login(true, …)`: the login has not succeeded yet.
172    //
173    // No `attempt.failed()` either: the password *was* right. Dropping the
174    // attempt gives its slot back uncounted.
175    let session = AdminSession::create_pending(
176        NewSession {
177            user_id: user.id,
178            token_hash: &minted.token_hash,
179            csrf_token: &csrf_token,
180            created_ip,
181            user_agent,
182        },
183        PENDING_MFA_TTL,
184        &state.database,
185    )
186    .await?;
187
188    info!(event = "admin_login_mfa_pending",
189          outcome = "success",
190          username = %user.username,
191          client_ip = ?client,
192          step = step.as_str());
193
194    Ok(SignedIn {
195        user,
196        session,
197        // The cookie's Max-Age follows the row, not the configured session
198        // lifetime: a browser holding it for twelve hours after the row died in
199        // five minutes only produces a confusing refusal.
200        cookie: session_cookie(&minted.token, PENDING_MFA_TTL),
201        pending: Some(step),
202    })
203}
204
205/// Everything finishing a login is, independent of how the code arrived.
206///
207/// [`sign_in`]'s twin, and shared by [`post_session_mfa`] and
208/// `pages::session::post_login_mfa` for the same reason: the limiter, the
209/// indistinguishable failures and the token rotation must not drift between the
210/// two front ends.
211pub(crate) async fn finish_mfa(
212    state: &AdminState,
213    client: Option<std::net::IpAddr>,
214    pending: PendingMfa,
215    submitted: &str,
216) -> Result<SignedIn, AdminError> {
217    // The origin gate already ran, in `PendingMfaSubmit`'s extractor -- it is
218    // what stands in for the CSRF token this route cannot have.
219    let attempt = match state.logins.begin(client) {
220        Ok(attempt) => attempt,
221        Err(retry_after) => {
222            log_login(false, &pending.user.username, client, "rate_limited");
223            return Err(AdminError::rate_limited(retry_after));
224        }
225    };
226
227    let mut user = pending.user;
228    let outcome =
229        crate::admin::mfa::verify_second_factor(&mut user, submitted, state.database.clone())
230            .await?;
231
232    let MfaOutcome::Accepted { via, .. } = outcome else {
233        attempt.failed();
234
235        // The bound the address-keyed limiter cannot provide. A `pending_mfa`
236        // cookie is valid from any address on purpose, so without this an
237        // attacker holding the password gets `login_max_attempts` fresh guesses
238        // per source address -- 2^64 of them from one IPv6 /64, against a
239        // six-digit code with a three-step window. The counter lives on the
240        // session, so rotating addresses buys nothing, and it is incremented by
241        // a single `UPDATE ... RETURNING`, so concurrent submissions cannot
242        // each observe zero.
243        let spent = AdminSession::record_mfa_failure(&pending.session.token_hash, &state.database)
244            .await?
245            .is_some_and(|attempts| attempts >= i64::from(state.config.admin.login_max_attempts));
246
247        if spent {
248            // Past the cap the half-authenticated session is over, not merely
249            // refused: producing the password again is the only way back to a
250            // pending row, and `sign_in`'s limiter bounds *that*.
251            AdminSession::delete(&pending.session.token_hash, &state.database).await?;
252            warn!(event = "admin_mfa_attempts_exhausted",
253                  outcome = "failure",
254                  username = %user.username,
255                  client_ip = ?client,
256                  max_attempts = state.config.admin.login_max_attempts);
257        }
258
259        warn!(event = "admin_mfa_failed",
260              outcome = "failure",
261              username = %user.username,
262              client_ip = ?client,
263              reason = outcome.reason());
264
265        // Tell the operator: a correct password was entered against their
266        // account and only the second factor stopped it (V6.3.5). A lockout is
267        // its own, sharper signal.
268        let user_agent = pending.session.user_agent.clone();
269        notify_sign_in(
270            state,
271            &user,
272            acme_proxy_jobs::notify::AdminSignInOutcome::SecondFactorRefused,
273            client,
274            user_agent.clone(),
275        )
276        .await;
277        if spent {
278            notify_sign_in(
279                state,
280                &user,
281                acme_proxy_jobs::notify::AdminSignInOutcome::LockedOut,
282                client,
283                user_agent,
284            )
285            .await;
286        }
287
288        // The same answer a wrong password gets, and for the same reason: a
289        // wrong code, a spent recovery code and a replayed one are one refusal
290        // to the client, and only the log says which. Exhausting the attempts
291        // is deliberately not a distinguishable answer either -- the next
292        // request will simply find no session.
293        return Err(AdminError::invalid_credentials());
294    };
295
296    let (session, cookie) = promote_pending(state, &pending.session.token_hash).await?;
297
298    let known_before = user.known_login_ips.clone();
299    user.mark_logged_in(client_ip_str(client).as_deref(), &state.database)
300        .await?;
301    state.logins.record_success(client);
302    info!(event = "admin_mfa_verified",
303          outcome = "success",
304          username = %user.username,
305          method = via.as_str());
306    log_login(true, &user.username, client, "");
307    notify_sign_in_from_new_address(
308        state,
309        &user,
310        &known_before,
311        client,
312        pending.session.user_agent.clone(),
313    )
314    .await;
315
316    Ok(SignedIn {
317        user,
318        session,
319        cookie,
320        pending: None,
321    })
322}
323
324/// Rotates a `pending_mfa` session into a fresh `active` one, returning the row
325/// and the `Set-Cookie` carrying its new token.
326///
327/// Shared by [`finish_mfa`] and the enrolment-confirm handler, which is the
328/// other way a half-authenticated session becomes usable: under
329/// `admin.require_mfa`, an operator with no factor finishes their login by
330/// *setting one up*, not by proving one.
331pub(crate) async fn promote_pending(
332    state: &AdminState,
333    pending_token_hash: &str,
334) -> Result<(AdminSession, String), AdminError> {
335    let ttl = Duration::from_secs(state.config.admin.session_ttl_seconds);
336    let minted = mint_token();
337    let csrf_token = mint_csrf_token();
338
339    let Some(session) = AdminSession::promote(
340        pending_token_hash,
341        &minted.token_hash,
342        &csrf_token,
343        ttl,
344        &state.database,
345    )
346    .await?
347    else {
348        // Lost the race to a concurrent submission, or the row expired between
349        // the extractor and here. Either way there is nothing to promote.
350        return Err(AdminError::session_invalid());
351    };
352
353    Ok((session, session_cookie(&minted.token, ttl)))
354}
355
356/// [`finish_mfa`]'s twin for the other way a login completes: setting a factor
357/// up rather than proving one.
358///
359/// Under `admin.require_mfa` an operator with no factor lands on the enrolment
360/// page and their session stays `pending_mfa` until they confirm a code. That
361/// confirmation **is** a completed sign-in, and it owes the same three pieces of
362/// bookkeeping the code path owes:
363///
364/// - `mark_logged_in`, so `last_login_at` means "completed a login";
365/// - `record_success`, so the operator does not leave their own limiter bucket
366///   dirty after signing in;
367/// - `log_login(true, …)`, without which a first sign-in — the one that hands
368///   out a first factor — produces no `admin_login_succeeded` event at all.
369///
370/// It exists as a shared function for the reason [`finish_mfa`] does: the JSON
371/// and HTML front ends each call it, and before it they had drifted, the API
372/// side doing none of the three and the pages side one of them.
373pub(crate) async fn finish_enrolment(
374    state: &AdminState,
375    client: Option<std::net::IpAddr>,
376    user: &mut acme_proxy_store::admin_user::AdminUser,
377    pending_token_hash: &str,
378    user_agent: Option<String>,
379) -> Result<(AdminSession, String), AdminError> {
380    let (session, cookie) = promote_pending(state, pending_token_hash).await?;
381    let known_before = user.known_login_ips.clone();
382    user.mark_logged_in(client_ip_str(client).as_deref(), &state.database)
383        .await?;
384    state.logins.record_success(client);
385    info!(event = "admin_mfa_enrolled", outcome = "success", username = %user.username);
386    log_login(true, &user.username, client, "");
387    notify_sign_in_from_new_address(state, user, &known_before, client, user_agent).await;
388    Ok((session, cookie))
389}
390
391/// `Option<IpAddr>` -> the string form `known_login_ips` / the payloads carry.
392fn client_ip_str(client: Option<std::net::IpAddr>) -> Option<String> {
393    client.map(|ip| ip.to_string())
394}
395
396/// Queues one web-admin security notification about `outcome` for `user`.
397async fn notify_sign_in(
398    state: &AdminState,
399    user: &AdminUser,
400    outcome: acme_proxy_jobs::notify::AdminSignInOutcome,
401    client: Option<std::net::IpAddr>,
402    user_agent: Option<String>,
403) {
404    state
405        .notify_security(acme_proxy_jobs::notify::NotifyEvent::AdminSignIn(
406            acme_proxy_jobs::notify::AdminSignInData {
407                profile: acme_proxy_jobs::notify::ADMIN_DISPATCHER_KEY.to_string(),
408                username: user.username.clone(),
409                recipient: user.contact_email.clone(),
410                outcome,
411                client_ip: client_ip_str(client),
412                user_agent,
413                at: acme_proxy_store::nonce::now_secs(),
414            },
415        ))
416        .await;
417}
418
419/// [`notify_sign_in`] for a completed login, but only when `client` is an
420/// address the operator's recent sign-ins did not come from. `known_before` is
421/// `known_login_ips` as it was *before* this login folded the current address
422/// in. A first-ever login (`known_before` empty) is silent — there is no
423/// baseline to be unusual against.
424async fn notify_sign_in_from_new_address(
425    state: &AdminState,
426    user: &AdminUser,
427    known_before: &[String],
428    client: Option<std::net::IpAddr>,
429    user_agent: Option<String>,
430) {
431    let Some(ip) = client_ip_str(client) else {
432        return;
433    };
434    if known_before.is_empty() || known_before.contains(&ip) {
435        return;
436    }
437    notify_sign_in(
438        state,
439        user,
440        acme_proxy_jobs::notify::AdminSignInOutcome::SucceededFromNewAddress,
441        client,
442        user_agent,
443    )
444    .await;
445}
446
447/// `POST /api/session` — exchange a username and password for a session cookie.
448///
449/// The one route that is reachable without a session, so it carries its own
450/// protections: the origin gate (there is no CSRF token yet to check) and the
451/// rate limiter, which runs **before** the password hash so a flood does not
452/// cost 600 000 iterations per attempt. Both live in [`sign_in`], which the
453/// sign-in *page* calls too.
454pub async fn post_session(
455    State(state): State<AdminState>,
456    AdminClientIp(client): AdminClientIp,
457    headers: axum::http::HeaderMap,
458    Json(body): Json<LoginRequest>,
459) -> Result<Response, AdminError> {
460    let signed_in = sign_in(&state, client, &headers, &body).await?;
461    Ok(signed_in_response(&signed_in))
462}
463
464/// `GET /api/session/mfa` — what this half-authenticated cookie still owes.
465///
466/// Unreachable with an `active` session, and with none at all: `PendingMfa` is
467/// `Authenticated`'s exact mirror image.
468pub async fn get_session_mfa(pending: PendingMfa) -> Json<serde_json::Value> {
469    Json(json!({
470        "step": pending.step.as_str(),
471        "expiresAt": acme_proxy_core::datetime::rfc3339(pending.session.expires_at),
472    }))
473}
474
475/// `POST /api/session/mfa` — finish a login with a code.
476///
477/// Takes the submission whichever it is: the TOTP code is tried first and the
478/// recovery codes after, because one is an HMAC and the other is up to ten
479/// PBKDF2 runs.
480///
481/// Deliberately **not** CSRF-checked — see [`PendingMfaSubmit`], whose extractor
482/// runs the origin gate in its place.
483pub async fn post_session_mfa(
484    State(state): State<AdminState>,
485    AdminClientIp(client): AdminClientIp,
486    PendingMfaSubmit(pending): PendingMfaSubmit,
487    Json(body): Json<MfaRequest>,
488) -> Result<Response, AdminError> {
489    let signed_in = finish_mfa(&state, client, pending, &body.code).await?;
490    Ok(signed_in_response(&signed_in))
491}
492
493/// The `200` both login steps answer with.
494///
495/// A pending answer carries **no `user` member**: a half-authenticated session
496/// must not read operator metadata. It is not a `401` either -- the password
497/// *was* right, and a script has to be able to tell those two apart.
498fn signed_in_response(signed_in: &SignedIn) -> Response {
499    let body = match signed_in.pending {
500        None => session_body(&signed_in.user, &signed_in.session),
501        Some(step) => json!({
502            "mfaRequired": true,
503            "step": step.as_str(),
504            "csrfToken": signed_in.session.csrf_token,
505            "expiresAt": acme_proxy_core::datetime::rfc3339(signed_in.session.expires_at),
506        }),
507    };
508
509    (
510        StatusCode::OK,
511        [(header::SET_COOKIE, signed_in.cookie.clone())],
512        Json(body),
513    )
514        .into_response()
515}
516
517/// `GET /api/session` — who am I, and what is my CSRF token.
518///
519/// The CSRF token is returned here as well as at login so a page reloaded on a
520/// still-live cookie can pick it up without signing in again.
521pub async fn get_session(auth: Authenticated) -> Json<serde_json::Value> {
522    Json(session_body(&auth.user, &auth.session))
523}
524
525/// `DELETE /api/session[?all=true]` — sign out.
526pub async fn delete_session(
527    State(state): State<AdminState>,
528    Query(query): Query<LogoutQuery>,
529    SelfServiceWrite(auth): SelfServiceWrite,
530    request_context: acme_proxy_core::audit::RequestContext,
531) -> Result<Response, AdminError> {
532    apply_logout(&state, &Caller::api(&auth, &request_context), query.all).await?;
533    Ok((
534        StatusCode::NO_CONTENT,
535        [(header::SET_COOKIE, clearing_cookie())],
536    )
537        .into_response())
538}
539
540/// Signs the caller out: the session making the request, or with `all` every
541/// session they hold. Only "everywhere" writes an audit row, since it ends
542/// sessions the operator is not holding; a plain logout is not a revoke.
543///
544/// The caller's front end clears the cookie; this ends the row it named.
545pub(crate) async fn apply_logout(
546    state: &AdminState,
547    caller: &Caller<'_>,
548    all: bool,
549) -> Result<(), AdminError> {
550    let scope = if all {
551        let revoked = AdminSession::delete_for_user(caller.auth.user.id, &state.database).await?;
552        state
553            .record_admin_action(caller.request, caller.username(), |actor, ctx| {
554                acme_proxy_jobs::auditor::admin::session_revoked(
555                    actor,
556                    ctx,
557                    acme_proxy_jobs::auditor::admin::SessionScope::AllOf(
558                        caller.username().to_string(),
559                    ),
560                    revoked,
561                )
562            })
563            .await;
564        "all"
565    } else {
566        AdminSession::delete(&caller.auth.session.token_hash, &state.database).await?;
567        "one"
568    };
569
570    tracing::info!(event = "admin_logout",
571                   outcome = "success",
572                   surface = caller.surface,
573                   username = %caller.username(),
574                   scope = scope);
575    Ok(())
576}
577
578/// The body both `POST` and `GET /api/session` return.
579fn session_body(user: &AdminUser, session: &AdminSession) -> serde_json::Value {
580    json!({
581        "user": crate::admin::render_admin_user_json(user),
582        "csrfToken": session.csrf_token,
583        "expiresAt": acme_proxy_core::datetime::rfc3339(session.expires_at),
584    })
585}