Skip to main content

acme_proxy_admin/webadmin/
session.rs

1//! Session tokens, the cookie they travel in, the extractors that resolve
2//! them, the CSRF check, and the login rate limiter.
3//!
4//! ## The token
5//!
6//! 32 bytes from the system CSPRNG, base64url-unpadded (43 characters, cookie-
7//! safe unquoted). Stored as `hex(SHA-256(token))` and never in plaintext, so
8//! a read of the database — a backup, a `.dump`, an injection — yields nothing
9//! replayable. Not the password KDF: the token has 256 bits of entropy, so
10//! there is no dictionary to slow down and a slow hash would buy nothing but
11//! latency on every request. Lookup is by the hash, and the value looked up
12//! *is* the secret, so that path needs no constant-time comparison — unlike
13//! the CSRF token, which is compared against a caller-supplied string.
14//!
15//! ## CSRF, and why `SameSite=Strict` is not enough
16//!
17//! The usual answer is that SameSite covers it. It does not, here, and the
18//! reason is specific: **SameSite is scoped to the registrable domain, not the
19//! origin — different ports of the same host are same-site.** A panel on
20//! `:3001` beside anything else on `:8080` of the same box is exactly the
21//! configuration it does not protect. So there is a per-session token as well,
22//! plus an origin gate that also covers login (which by definition carries no
23//! session token yet).
24//!
25//! Enforcement is **structural**: one of [`AuthenticatedWrite`],
26//! [`AdminWrite`] or [`SelfServiceWrite`] is the only way for a mutating
27//! handler to reach a session, and constructing any of them runs the origin
28//! and CSRF checks. The three differ only in the privilege tier they demand —
29//! `operator`+, `admin`, and none (own-account routes) — so a handler that
30//! forgets the role check cannot compile. Same reasoning as hoisting the
31//! media-type/`crit`/`url`/nonce checks into `AcmeRequest` — a new endpoint
32//! cannot forget what it cannot express.
33
34use std::collections::HashMap;
35use std::net::IpAddr;
36use std::sync::Mutex;
37use std::time::Duration;
38
39use axum::extract::FromRequestParts;
40use axum::http::request::Parts;
41use axum::http::{HeaderMap, header};
42use base64::Engine as _;
43use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL_SAFE_NO_PAD;
44use ring::rand::{SecureRandom, SystemRandom};
45use subtle::ConstantTimeEq;
46use tracing::{info, warn};
47
48use crate::webadmin::AdminState;
49use crate::webadmin::error::AdminError;
50use acme_proxy_store::admin_session::AdminSession;
51use acme_proxy_store::admin_user::AdminRole;
52use acme_proxy_store::admin_user::AdminUser;
53use acme_proxy_store::nonce::fingerprint;
54use acme_proxy_store::nonce::now_secs;
55
56/// The session cookie's name.
57///
58/// The `__Host-` prefix is free, browser-enforced hardening: it *requires*
59/// `Secure`, `Path=/` and no `Domain`, which is exactly the spec chosen below.
60/// A future edit that drops `Secure` therefore breaks the cookie visibly
61/// instead of silently widening it.
62pub const COOKIE_NAME: &str = "__Host-acme_admin_session";
63
64/// The header carrying the per-session CSRF token on unsafe methods.
65pub const CSRF_HEADER: &str = "x-csrf-token";
66
67/// Session token length in bytes. 256 bits — the same size
68/// `acme_proxy_store::eab::generate_secret` chooses, and for the same reason.
69const TOKEN_LEN: usize = 32;
70
71/// How stale `last_seen_at` may get before a request bothers to advance it.
72///
73/// Without this, a page polling every few seconds would take the WAL writer
74/// lock on every request just to move a timestamp by a second.
75const SESSION_TOUCH_INTERVAL: i64 = 60;
76
77/// How long a half-authenticated session may sit unfinished.
78///
79/// Deliberately **not** `admin.session_ttl_seconds` and deliberately not a
80/// configuration key. A `pending_mfa` row is a password that has been accepted
81/// and nothing more; it should not outlive the tab that created it. Five
82/// minutes is longer than reading a code off a phone and shorter than walking
83/// away from the keyboard -- a UI timing constant, not a policy an operator
84/// tunes.
85///
86/// It is also the only per-session bound on code guessing: within it an
87/// attacker holding a correct password gets `admin.login_max_attempts` tries
88/// from one address, and then the row is gone regardless.
89pub const PENDING_MFA_TTL: Duration = Duration::from_secs(300);
90
91/// A freshly minted session token, and the hash to store for it.
92pub struct MintedToken {
93    /// Goes in the `Set-Cookie` and is never written down.
94    pub token: String,
95    /// Goes in `admin_sessions.token_hash`.
96    pub token_hash: String,
97}
98
99/// Mints a session token. RNG failure is unrecoverable, as elsewhere in this
100/// crate.
101#[must_use]
102pub fn mint_token() -> MintedToken {
103    let mut bytes = [0u8; TOKEN_LEN];
104    SystemRandom::new()
105        .fill(&mut bytes)
106        .expect("system RNG unavailable");
107    let token = BASE64_URL_SAFE_NO_PAD.encode(bytes);
108    let token_hash = hash_token(&token);
109    MintedToken { token, token_hash }
110}
111
112/// Mints a CSRF token. Same entropy as a session token — it is stored in
113/// plaintext, but it still has to be unguessable.
114#[must_use]
115pub fn mint_csrf_token() -> String {
116    let mut bytes = [0u8; TOKEN_LEN];
117    SystemRandom::new()
118        .fill(&mut bytes)
119        .expect("system RNG unavailable");
120    BASE64_URL_SAFE_NO_PAD.encode(bytes)
121}
122
123/// `hex(SHA-256(token))` — the `admin_sessions` primary key.
124#[must_use]
125pub fn hash_token(token: &str) -> String {
126    let digest = ring::digest::digest(&ring::digest::SHA256, token.as_bytes());
127    hex::encode(digest.as_ref())
128}
129
130/// The `Set-Cookie` value that establishes a session.
131#[must_use]
132pub fn session_cookie(token: &str, ttl: Duration) -> String {
133    format!(
134        "{COOKIE_NAME}={token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age={}",
135        ttl.as_secs()
136    )
137}
138
139/// The `Set-Cookie` value that clears one.
140#[must_use]
141pub fn clearing_cookie() -> String {
142    format!("{COOKIE_NAME}=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0")
143}
144
145/// Reads the session token out of a `Cookie` header set.
146///
147/// Hand-rolled rather than pulling in `axum-extra` or `cookie` for twenty
148/// lines. Takes the **first** match, across all `Cookie` headers: a crafted
149/// request carrying the name twice must not let the second one win, which is a
150/// session-fixation vector.
151#[must_use]
152pub fn cookie_value(headers: &HeaderMap) -> Option<String> {
153    for header in headers.get_all(header::COOKIE) {
154        let Ok(raw) = header.to_str() else { continue };
155        for pair in raw.split(';') {
156            let Some((name, value)) = pair.split_once('=') else {
157                continue;
158            };
159            if name.trim() == COOKIE_NAME {
160                // A cookie value may be quoted (RFC 6265 §4.1.1).
161                let value = value.trim();
162                let value = value
163                    .strip_prefix('"')
164                    .and_then(|v| v.strip_suffix('"'))
165                    .unwrap_or(value);
166                return Some(value.to_string());
167            }
168        }
169    }
170    None
171}
172
173/// The client address of an admin request, if the socket carried one.
174///
175/// Its own extractor rather than [`acme_proxy_core::client::ClientIp`], so a
176/// router built without the `[admin.filter]` layer (a test driving one handler)
177/// still gets the socket peer rather than `None`.
178///
179/// **Forwarded headers only from `admin.filter.trusted_proxies`.** That layer
180/// resolves the address and records a `ClientIp`, which this prefers; its
181/// absence falls back to the peer. Honouring `X-Forwarded-For` from anyone else
182/// would let a caller spoof the key the login limiter counts on — and with no
183/// trusted proxy listed, the limiter behind a reverse proxy counts the proxy.
184#[derive(Debug, Clone, Copy)]
185pub struct AdminClientIp(pub Option<IpAddr>);
186
187impl<S: Sync> FromRequestParts<S> for AdminClientIp {
188    type Rejection = std::convert::Infallible;
189
190    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
191        if let Some(acme_proxy_core::client::ClientIp(resolved)) = parts
192            .extensions
193            .get::<acme_proxy_core::client::ClientIp>()
194            .copied()
195        {
196            return Ok(AdminClientIp(resolved));
197        }
198        let address = parts
199            .extensions
200            .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
201            // Canonicalized for the same reason `ProxyPolicy::resolve` does it:
202            // a dual-stack listener sees an IPv4 client as `::ffff:…`, and two
203            // spellings of one address would be two buckets.
204            .map(|info| info.0.ip().to_canonical());
205        Ok(AdminClientIp(address))
206    }
207}
208
209/// What still stands between a `pending_mfa` cookie and a usable session.
210///
211/// Not derivable from the session row alone, which is why it is carried rather
212/// than recomputed: both states are one `pending_mfa` row, and the difference
213/// is `user.has_totp()`. Making each caller re-derive it is exactly the drift
214/// this codebase avoids.
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum MfaStep {
217    /// A confirmed factor exists: prove a code, or spend a recovery code.
218    Verify,
219    /// `admin.require_mfa` is on and this operator has none: enrol first.
220    Enrol,
221}
222
223impl MfaStep {
224    /// The wire and template spelling.
225    #[must_use]
226    pub fn as_str(self) -> &'static str {
227        match self {
228            MfaStep::Verify => "verify",
229            MfaStep::Enrol => "enrol",
230        }
231    }
232}
233
234/// A live session and the operator it belongs to. Read-only handlers take this.
235#[derive(Debug)]
236pub struct Authenticated {
237    pub session: AdminSession,
238    pub user: AdminUser,
239}
240
241/// [`Authenticated`], the origin gate and the CSRF check, **plus a privilege
242/// tier of at least [`AdminRole::Operator`]**.
243///
244/// Every mutating handler that touches shared or CA state takes this. None of
245/// the three checks can be forgotten, because this type is the only way such a
246/// handler can reach a session at all. A `viewer` is refused here; the
247/// own-account routes a `viewer` may still use take [`SelfServiceWrite`]
248/// instead, and the colleague-management routes take [`AdminWrite`].
249#[derive(Debug)]
250pub struct AuthenticatedWrite(pub Authenticated);
251
252/// [`AuthenticatedWrite`], but requiring the full [`AdminRole::Admin`] tier.
253///
254/// The four `/operators/*` routes take this: disabling or re-enabling a
255/// colleague, resetting their second factor, revoking one of their sessions.
256#[derive(Debug)]
257pub struct AdminWrite(pub Authenticated);
258
259/// The origin gate, a live `active` session and the CSRF check -- **no
260/// privilege check**, so any role including `viewer` passes.
261///
262/// The own-account routes take this: change your own password, revoke your own
263/// sessions, manage your own second factor, log out. A `viewer` has to be able
264/// to do these or `admin.require_mfa` enrolment would be unreachable for them.
265#[derive(Debug)]
266pub struct SelfServiceWrite(pub Authenticated);
267
268/// [`Authenticated`] **plus a privilege tier of [`AdminRole::Admin`]** -- the
269/// read-side sibling of [`AdminWrite`], with no CSRF or origin gate, since a
270/// `GET` changes nothing.
271///
272/// The `/operators` read routes take this. What they render is every
273/// colleague's role and contact address, the last addresses each signed in
274/// from, and every live session's fingerprint, address and last-seen time --
275/// "who else is an admin here, and from where do they work" is exactly the
276/// reconnaissance a `viewer` tier exists to withhold, so the reads are gated to
277/// the same tier as the writes rather than to a live session alone.
278///
279/// Nothing else on this listener needs it: every other read is either the
280/// caller's own (`/account/*`) or CA state a `viewer` is meant to see.
281#[derive(Debug)]
282pub struct AdminRead(pub Authenticated);
283
284impl FromRequestParts<AdminState> for Authenticated {
285    type Rejection = AdminError;
286
287    async fn from_request_parts(
288        parts: &mut Parts,
289        state: &AdminState,
290    ) -> Result<Self, Self::Rejection> {
291        resolve_session(parts, state).await
292    }
293}
294
295/// The origin gate + live `active` session + CSRF check the three write
296/// extractors share. Each then adds (or omits) its own privilege check.
297async fn resolve_write(parts: &Parts, state: &AdminState) -> Result<Authenticated, AdminError> {
298    // The origin gate first: it is free, and it refuses a cross-origin
299    // request before a database lookup happens on its behalf.
300    check_origin(&parts.headers, &state.config.admin.base_url)?;
301    let authenticated = resolve_session(parts, state).await?;
302    check_csrf(&parts.headers, &authenticated.session.csrf_token)?;
303    Ok(authenticated)
304}
305
306/// The privilege gate. `role()` resolves a `NULL` column to
307/// [`AdminRole::Admin`], so an operator that predates the `role` column is
308/// unaffected.
309fn require_role(user: &AdminUser, minimum: AdminRole) -> Result<(), AdminError> {
310    if user.role() >= minimum {
311        Ok(())
312    } else {
313        Err(AdminError::insufficient_role())
314    }
315}
316
317impl FromRequestParts<AdminState> for AdminRead {
318    type Rejection = AdminError;
319
320    async fn from_request_parts(
321        parts: &mut Parts,
322        state: &AdminState,
323    ) -> Result<Self, Self::Rejection> {
324        // No `resolve_write`: there is no body to forge and no state change to
325        // ride, so the origin and CSRF gates buy nothing on a read. The tier is
326        // the whole of what this adds over `Authenticated`.
327        let authenticated = resolve_session(parts, state).await?;
328        require_role(&authenticated.user, AdminRole::Admin)?;
329        Ok(AdminRead(authenticated))
330    }
331}
332
333impl FromRequestParts<AdminState> for AuthenticatedWrite {
334    type Rejection = AdminError;
335
336    async fn from_request_parts(
337        parts: &mut Parts,
338        state: &AdminState,
339    ) -> Result<Self, Self::Rejection> {
340        let authenticated = resolve_write(parts, state).await?;
341        require_role(&authenticated.user, AdminRole::Operator)?;
342        Ok(AuthenticatedWrite(authenticated))
343    }
344}
345
346impl FromRequestParts<AdminState> for AdminWrite {
347    type Rejection = AdminError;
348
349    async fn from_request_parts(
350        parts: &mut Parts,
351        state: &AdminState,
352    ) -> Result<Self, Self::Rejection> {
353        let authenticated = resolve_write(parts, state).await?;
354        require_role(&authenticated.user, AdminRole::Admin)?;
355        Ok(AdminWrite(authenticated))
356    }
357}
358
359impl FromRequestParts<AdminState> for SelfServiceWrite {
360    type Rejection = AdminError;
361
362    async fn from_request_parts(
363        parts: &mut Parts,
364        state: &AdminState,
365    ) -> Result<Self, Self::Rejection> {
366        Ok(SelfServiceWrite(resolve_write(parts, state).await?))
367    }
368}
369
370/// A session with a verified password and nothing more.
371///
372/// Its own extractor with its own resolver, because [`resolve_session`] hard-
373/// refuses a non-`active` session -- deliberately, so no ordinary route can
374/// accept a half-authenticated one. This is its exact mirror image: it refuses
375/// an `active` one.
376#[derive(Debug)]
377pub struct PendingMfa {
378    pub session: AdminSession,
379    pub user: AdminUser,
380    pub step: MfaStep,
381}
382
383/// [`PendingMfa`] with the **origin gate only, and no CSRF check.**
384///
385/// The omission is deliberate, and is the same one `POST /ui/login` already
386/// makes one step earlier: the challenge page is a plain form -- sign-in must
387/// work with JavaScript off, which `tests/admin_pages.rs` pins for `login.html`
388/// -- and [`check_csrf`] reads a header a form cannot set. Teaching it to read a
389/// form field instead would be a second CSRF path, which is what
390/// `pages::auth`'s whole shape exists to prevent.
391///
392/// What covers the route is [`check_origin`], and the residual risk is nil: a
393/// cross-site forger would need a valid code for a session they cannot read,
394/// and success would only complete the victim's own login.
395#[derive(Debug)]
396pub struct PendingMfaSubmit(pub PendingMfa);
397
398/// A session allowed to *set up* a factor.
399///
400/// Either an `active` session -- voluntary enrolment, or moving to a new phone
401/// -- or a `pending_mfa` one that exists precisely because the operator has none
402/// and `admin.require_mfa` is on.
403///
404/// **The `pending_mfa` case requires `!user.has_totp()`, and that condition is
405/// the whole security of this type.** A session that owes a *code* must never
406/// reach an enrolment route, or the second factor is bypassable by enrolling a
407/// new one over it.
408///
409/// Runs [`check_origin`] and [`check_csrf`] exactly as [`AuthenticatedWrite`]
410/// does: unlike the challenge form, these routes are htmx calls from a page
411/// that was handed a token.
412#[derive(Debug)]
413pub struct EnrolWrite {
414    pub session: AdminSession,
415    pub user: AdminUser,
416    /// `true` when this session is still `pending_mfa`, so the handler knows to
417    /// promote it once the enrolment confirms.
418    pub pending: bool,
419}
420
421impl FromRequestParts<AdminState> for PendingMfa {
422    type Rejection = AdminError;
423
424    async fn from_request_parts(
425        parts: &mut Parts,
426        state: &AdminState,
427    ) -> Result<Self, Self::Rejection> {
428        resolve_pending(parts, state).await
429    }
430}
431
432impl FromRequestParts<AdminState> for PendingMfaSubmit {
433    type Rejection = AdminError;
434
435    async fn from_request_parts(
436        parts: &mut Parts,
437        state: &AdminState,
438    ) -> Result<Self, Self::Rejection> {
439        check_origin(&parts.headers, &state.config.admin.base_url)?;
440        Ok(PendingMfaSubmit(resolve_pending(parts, state).await?))
441    }
442}
443
444impl FromRequestParts<AdminState> for EnrolWrite {
445    type Rejection = AdminError;
446
447    async fn from_request_parts(
448        parts: &mut Parts,
449        state: &AdminState,
450    ) -> Result<Self, Self::Rejection> {
451        check_origin(&parts.headers, &state.config.admin.base_url)?;
452        let (_, session, user) = resolve_live(parts, state).await?;
453
454        // The refusal this type exists for. A `pending_mfa` session whose owner
455        // already *has* a factor owes a code, and letting it enrol a second one
456        // would make the first optional.
457        if !session.is_active() && user.has_totp() {
458            return Err(AdminError::session_invalid());
459        }
460
461        check_csrf(&parts.headers, &session.csrf_token)?;
462        let pending = !session.is_active();
463        Ok(EnrolWrite {
464            session,
465            user,
466            pending,
467        })
468    }
469}
470
471/// Cookie → live row → live user.
472///
473/// Judges *liveness* only -- expiry, idleness, the orphan case, a disabled owner
474/// -- and never `state`. That is what lets two extractors sit over one lookup
475/// without either being a widening of the other: each adds its own opposite
476/// state check on top.
477async fn resolve_live(
478    parts: &Parts,
479    state: &AdminState,
480) -> Result<(String, AdminSession, AdminUser), AdminError> {
481    let token = cookie_value(&parts.headers).ok_or_else(AdminError::session_invalid)?;
482    let token_hash = hash_token(&token);
483
484    let Some(session) = AdminSession::find_by_token_hash(&token_hash, &state.database).await?
485    else {
486        return Err(AdminError::session_invalid());
487    };
488
489    let now = now_secs();
490    if session.is_expired(now) {
491        AdminSession::delete(&token_hash, &state.database).await?;
492        return Err(AdminError::session_expired());
493    }
494    let idle_timeout = Duration::from_secs(state.config.admin.session_idle_timeout_seconds);
495    if session.is_idle(now, idle_timeout) {
496        AdminSession::delete(&token_hash, &state.database).await?;
497        return Err(AdminError::session_idle());
498    }
499
500    let Some(user) = AdminUser::find_by_id(session.user_id, &state.database).await? else {
501        // The FK cascade should make this impossible; if it happens, the
502        // session is orphaned and must not authenticate anybody.
503        warn!(event = "admin_session_orphaned", outcome = "failure", session_fp = %fingerprint(&token_hash));
504        AdminSession::delete(&token_hash, &state.database).await?;
505        return Err(AdminError::session_invalid());
506    };
507    if !user.is_active() {
508        return Err(AdminError::session_invalid());
509    }
510
511    Ok((token_hash, session, user))
512}
513
514/// Resolves the cookie to a live, **fully authenticated** session, refusing each
515/// way it can be dead with its own code — the client is told to sign in again
516/// either way, but the operator reading a log can tell an expiry from a
517/// revocation.
518async fn resolve_session(parts: &Parts, state: &AdminState) -> Result<Authenticated, AdminError> {
519    let (_, mut session, user) = resolve_live(parts, state).await?;
520
521    // `pending_mfa`: a password was accepted and nothing more. Only the routes
522    // that finish the login may see such a session, and they go through
523    // `resolve_pending` instead.
524    if !session.is_active() {
525        return Err(AdminError::session_invalid());
526    }
527
528    if now_secs() - session.last_seen_at >= SESSION_TOUCH_INTERVAL {
529        session.touch(&state.database).await?;
530    }
531
532    Ok(Authenticated { session, user })
533}
534
535/// [`resolve_session`]'s mirror image: a live session that is **not** yet
536/// active.
537///
538/// Does not touch `last_seen_at`. A pending row has a five-minute absolute
539/// deadline, so advancing an idle deadline it will never reach would be pure
540/// cost on the login path.
541async fn resolve_pending(parts: &Parts, state: &AdminState) -> Result<PendingMfa, AdminError> {
542    let (_, session, user) = resolve_live(parts, state).await?;
543
544    if session.is_active() {
545        return Err(AdminError::session_invalid());
546    }
547
548    let step = if user.has_totp() {
549        MfaStep::Verify
550    } else {
551        MfaStep::Enrol
552    };
553    Ok(PendingMfa {
554        session,
555        user,
556        step,
557    })
558}
559
560/// Compares the request's `X-CSRF-Token` against the session's, in constant
561/// time.
562pub fn check_csrf(headers: &HeaderMap, expected: &str) -> Result<(), AdminError> {
563    let Some(supplied) = headers.get(CSRF_HEADER).and_then(|v| v.to_str().ok()) else {
564        return Err(AdminError::csrf_failed(format!(
565            "this request needs an {CSRF_HEADER} header carrying the session's csrfToken"
566        )));
567    };
568
569    // `ct_eq` is only constant-time across equal lengths; comparing the
570    // lengths first leaks nothing a token's own encoding does not already fix.
571    let matches = supplied.len() == expected.len()
572        && bool::from(supplied.as_bytes().ct_eq(expected.as_bytes()));
573    if !matches {
574        return Err(AdminError::csrf_failed(
575            "the CSRF token does not match this session",
576        ));
577    }
578    Ok(())
579}
580
581/// Refuses a request whose `Origin` or `Sec-Fetch-Site` says it came from
582/// somewhere else.
583///
584/// Both headers are checked only when *present*: a non-browser client (curl,
585/// a script) sends neither and is not the threat this addresses. Its value is
586/// covering the login request, which carries no session and therefore no CSRF
587/// token to check.
588pub fn check_origin(headers: &HeaderMap, base_url: &str) -> Result<(), AdminError> {
589    if let Some(site) = headers.get("sec-fetch-site").and_then(|v| v.to_str().ok())
590        && site != "same-origin"
591        && site != "none"
592    {
593        return Err(AdminError::csrf_failed(format!(
594            "cross-origin request refused (Sec-Fetch-Site: {site})"
595        )));
596    }
597
598    if let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) {
599        let expected = url::Url::parse(base_url)
600            .map(|u| u.origin().ascii_serialization())
601            .unwrap_or_default();
602        if origin != expected {
603            return Err(AdminError::csrf_failed(format!(
604                "cross-origin request refused (Origin: {origin}, expected {expected})"
605            )));
606        }
607    }
608    Ok(())
609}
610
611/// Fixed-window failed-login counter, keyed by client address.
612///
613/// In-process on purpose: it protects a listener that defaults to loopback and
614/// holds a handful of accounts, and a database-backed counter would add a
615/// write to the very path being flooded.
616///
617/// **An attempt is counted when it starts, not when it fails.** [`begin`]
618/// reserves a slot under the lock and refuses once failures *plus attempts
619/// still in flight* reach the limit. Counting only finished failures was a
620/// check-then-act race: a burst of parallel requests from one address all
621/// passed the check before the first of them had paid its 600 000 iterations
622/// and recorded anything, so the burst bought as many guesses — and as much
623/// KDF time — as it had requests. The slot is a [`LoginAttempt`] guard, so
624/// every early return (a database error included) gives it back.
625///
626/// [`begin`]: LoginLimiter::begin
627#[derive(Debug)]
628pub struct LoginLimiter {
629    max_attempts: u32,
630    window: Duration,
631    buckets: Mutex<HashMap<IpAddr, Bucket>>,
632}
633
634#[derive(Debug, Clone, Copy)]
635struct Bucket {
636    failures: u32,
637    /// Attempts begun and not yet settled. Never carried across a reload: see
638    /// [`LoginLimiter::rebuilt`].
639    in_flight: u32,
640    window_started: i64,
641}
642
643/// The bucket an address counts against.
644///
645/// An IPv6 client is keyed by its /64. A single subscriber routinely holds a
646/// whole /64 and can rotate through it at will, so keying the full /128 gave
647/// one attacker 2^64 fresh budgets. An IPv4-mapped address is its IPv4 self.
648fn bucket_key(client: IpAddr) -> IpAddr {
649    match client.to_canonical() {
650        IpAddr::V4(v4) => IpAddr::V4(v4),
651        IpAddr::V6(v6) => {
652            let prefix = u128::from(v6) & (u128::MAX << 64);
653            IpAddr::V6(std::net::Ipv6Addr::from(prefix))
654        }
655    }
656}
657
658impl LoginLimiter {
659    /// At most `max_attempts` failures per client address (an IPv6 address by
660    /// its /64) within `window_seconds`.
661    #[must_use]
662    pub fn new(max_attempts: u32, window_seconds: u64) -> Self {
663        Self {
664            max_attempts,
665            window: Duration::from_secs(window_seconds),
666            buckets: Mutex::new(HashMap::new()),
667        }
668    }
669
670    /// The same counters under new limits.
671    ///
672    /// A configuration reload rebuilds the admin router, and with it every value
673    /// `AdminState` derives from `[admin]` — which for this type would mean
674    /// starting from an empty map. That is a security regression, not a cosmetic
675    /// one: a reload in the middle of a brute-force attempt would clear the
676    /// attacker's backoff, and `admin.login_*` is exactly the sort of key an
677    /// operator edits *because* they are being flooded.
678    ///
679    /// Carrying the whole limiter across instead would be the other error,
680    /// leaving `login_max_attempts` and `login_window_seconds` silently stale.
681    /// So the counters move and the limits do not.
682    ///
683    /// The in-flight counts do **not** move: their guards hold the old limiter
684    /// and settle against it, so a count copied here would never be released.
685    /// The cost is that an attempt straddling a reload is not counted, once.
686    #[must_use]
687    pub fn rebuilt(&self, max_attempts: u32, window_seconds: u64) -> Self {
688        let mut buckets =
689            std::mem::take(&mut *self.buckets.lock().unwrap_or_else(|e| e.into_inner()));
690        for bucket in buckets.values_mut() {
691            bucket.in_flight = 0;
692        }
693        Self {
694            max_attempts,
695            window: Duration::from_secs(window_seconds),
696            buckets: Mutex::new(buckets),
697        }
698    }
699
700    /// Starts a login attempt from this address, or refuses it. `Err` carries
701    /// the seconds left in the window.
702    ///
703    /// Called **before** the password hash runs: 600 000 iterations is a
704    /// denial-of-service lever, so a limited caller must not pay it — nor make
705    /// the server pay it. The returned guard holds the slot until it is
706    /// [`failed`](LoginAttempt::failed) or dropped.
707    pub fn begin(&self, client: Option<IpAddr>) -> Result<LoginAttempt<'_>, u64> {
708        // No address means no key. This is not a bypass: the limiter is a
709        // convenience over the bind address and the session, and an admin
710        // listener always has a peer address in practice (`TapIo` carries it
711        // through TLS). Failing closed here would lock out every request
712        // rather than every attacker.
713        let Some(key) = client.map(bucket_key) else {
714            return Ok(LoginAttempt {
715                limiter: self,
716                key: None,
717            });
718        };
719        let now = now_secs();
720        let window = self.window.as_secs() as i64;
721
722        let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
723        // Pruned under the same lock, so the map cannot grow without bound. A
724        // bucket with an attempt in flight is kept, its failures forgotten.
725        buckets.retain(|_, bucket| now - bucket.window_started < window || bucket.in_flight > 0);
726
727        let bucket = buckets.entry(key).or_insert(Bucket {
728            failures: 0,
729            in_flight: 0,
730            window_started: now,
731        });
732        if now - bucket.window_started >= window {
733            bucket.failures = 0;
734            bucket.window_started = now;
735        }
736        if bucket.failures.saturating_add(bucket.in_flight) >= self.max_attempts {
737            return Err((window - (now - bucket.window_started)).max(1) as u64);
738        }
739        bucket.in_flight += 1;
740        Ok(LoginAttempt {
741            limiter: self,
742            key: Some(key),
743        })
744    }
745
746    /// Clears an address's counter after a completed login, so one operator
747    /// fumbling their password does not spend the window for the next.
748    pub fn record_success(&self, client: Option<IpAddr>) {
749        let Some(key) = client.map(bucket_key) else {
750            return;
751        };
752        let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
753        if let Some(bucket) = buckets.get_mut(&key) {
754            // Another attempt may still be in flight; its guard needs the
755            // bucket to settle against.
756            bucket.failures = 0;
757            if bucket.in_flight == 0 {
758                buckets.remove(&key);
759            }
760        }
761    }
762
763    /// Gives back an in-flight slot, and turns it into a failure if `failed`.
764    fn settle(&self, key: IpAddr, failed: bool) {
765        let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
766        // Absent when `rebuilt` emptied this limiter mid-attempt.
767        let Some(bucket) = buckets.get_mut(&key) else {
768            return;
769        };
770        bucket.in_flight = bucket.in_flight.saturating_sub(1);
771        if failed {
772            bucket.failures += 1;
773        } else if bucket.failures == 0 && bucket.in_flight == 0 {
774            buckets.remove(&key);
775        }
776    }
777}
778
779/// One login attempt's slot in the [`LoginLimiter`].
780///
781/// Dropping it releases the slot without counting anything: a correct password
782/// waiting on its second factor, a refused origin, a database error. Only
783/// [`failed`](Self::failed) spends the budget.
784#[derive(Debug)]
785#[must_use = "dropping the attempt releases its slot at once"]
786pub struct LoginAttempt<'a> {
787    limiter: &'a LoginLimiter,
788    key: Option<IpAddr>,
789}
790
791impl LoginAttempt<'_> {
792    /// Counts this attempt as a failure against its address.
793    pub fn failed(mut self) {
794        if let Some(key) = self.key.take() {
795            self.limiter.settle(key, true);
796        }
797    }
798}
799
800impl Drop for LoginAttempt<'_> {
801    fn drop(&mut self) {
802        if let Some(key) = self.key.take() {
803            self.limiter.settle(key, false);
804        }
805    }
806}
807
808/// Logs a completed login attempt. One place, so the events cannot drift.
809pub fn log_login(succeeded: bool, username: &str, client: Option<IpAddr>, reason: &'static str) {
810    if succeeded {
811        info!(event = "admin_login_succeeded",
812              outcome = "success",
813              username = %username,
814              client_ip = ?client);
815    } else {
816        warn!(event = "admin_login_failed",
817              outcome = "failure",
818              username = %username,
819              client_ip = ?client,
820              reason = reason);
821    }
822}
823
824#[cfg(test)]
825mod tests {
826    use super::*;
827    use axum::http::HeaderValue;
828
829    fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
830        let mut map = HeaderMap::new();
831        for (name, value) in pairs {
832            map.append(
833                header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
834                HeaderValue::from_str(value).unwrap(),
835            );
836        }
837        map
838    }
839
840    async fn client_ip_of(request: axum::http::Request<()>) -> Option<IpAddr> {
841        let (mut parts, ()) = request.into_parts();
842        let AdminClientIp(ip) = AdminClientIp::from_request_parts(&mut parts, &())
843            .await
844            .unwrap();
845        ip
846    }
847
848    /// Behind a trusted proxy the `[admin.filter]` layer has resolved the real
849    /// client, and that — not the proxy — is what the login limiter must count.
850    #[tokio::test]
851    async fn the_resolved_client_is_preferred_to_the_peer() {
852        let mut request = axum::http::Request::new(());
853        request
854            .extensions_mut()
855            .insert(axum::extract::ConnectInfo(std::net::SocketAddr::from((
856                [172, 18, 0, 2],
857                4711,
858            ))));
859        request
860            .extensions_mut()
861            .insert(acme_proxy_core::client::ClientIp(Some(
862                "198.51.100.9".parse().unwrap(),
863            )));
864        assert_eq!(
865            client_ip_of(request).await,
866            Some("198.51.100.9".parse().unwrap())
867        );
868    }
869
870    #[tokio::test]
871    async fn without_the_filter_layer_the_peer_is_the_client() {
872        let mut request = axum::http::Request::new(());
873        request.extensions_mut().insert(axum::extract::ConnectInfo(
874            "[::ffff:192.0.2.7]:4711"
875                .parse::<std::net::SocketAddr>()
876                .unwrap(),
877        ));
878        assert_eq!(
879            client_ip_of(request).await,
880            Some("192.0.2.7".parse().unwrap())
881        );
882    }
883
884    #[test]
885    fn a_minted_token_is_43_url_safe_characters_and_hashes_stably() {
886        let minted = mint_token();
887        assert_eq!(minted.token.len(), 43, "32 bytes, base64url unpadded");
888        assert!(!minted.token.contains('='));
889        assert!(!minted.token.contains('+'));
890        assert!(!minted.token.contains('/'));
891        assert_eq!(minted.token_hash, hash_token(&minted.token));
892        assert_eq!(minted.token_hash.len(), 64, "SHA-256 as hex");
893
894        // Distinct per call, and the hash never contains the token.
895        assert_ne!(mint_token().token, minted.token);
896        assert!(!minted.token_hash.contains(&minted.token));
897    }
898
899    #[test]
900    fn hash_token_matches_a_known_vector() {
901        // sha256("") — pins the encoding (hex, lowercase) as well as the digest.
902        assert_eq!(
903            hash_token(""),
904            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
905        );
906    }
907
908    #[test]
909    fn csrf_tokens_are_unguessable_and_distinct() {
910        let first = mint_csrf_token();
911        assert_eq!(first.len(), 43);
912        assert_ne!(first, mint_csrf_token());
913    }
914
915    #[test]
916    fn the_session_cookie_carries_every_required_attribute() {
917        let cookie = session_cookie("the-token", Duration::from_secs(43_200));
918        assert!(cookie.starts_with("__Host-acme_admin_session=the-token;"));
919        assert!(cookie.contains("HttpOnly"));
920        assert!(cookie.contains("Secure"));
921        assert!(cookie.contains("SameSite=Strict"));
922        assert!(cookie.contains("Path=/"));
923        assert!(cookie.contains("Max-Age=43200"));
924        // `__Host-` forbids it, and setting one would widen the cookie to
925        // every subdomain.
926        assert!(!cookie.contains("Domain"));
927    }
928
929    #[test]
930    fn the_clearing_cookie_expires_immediately_and_keeps_the_same_attributes() {
931        let cookie = clearing_cookie();
932        assert!(cookie.starts_with("__Host-acme_admin_session=;"));
933        assert!(cookie.contains("Max-Age=0"));
934        // A browser matches on name+path+domain, so a clear that dropped these
935        // would leave the original cookie in place.
936        assert!(cookie.contains("Path=/"));
937        assert!(cookie.contains("Secure"));
938        assert!(cookie.contains("HttpOnly"));
939    }
940
941    /// A parser case: a label, the `Cookie` headers to send, and the value the
942    /// parser must pick out.
943    type CookieCase = (
944        &'static str,
945        Vec<(&'static str, String)>,
946        Option<&'static str>,
947    );
948
949    #[test]
950    fn cookie_parsing_is_table_driven() {
951        let name = COOKIE_NAME;
952        let cases: Vec<CookieCase> = vec![
953            ("absent entirely", vec![], None),
954            (
955                "the only cookie",
956                vec![("cookie", format!("{name}=abc"))],
957                Some("abc"),
958            ),
959            (
960                "among others",
961                vec![("cookie", format!("theme=dark; {name}=abc; lang=en"))],
962                Some("abc"),
963            ),
964            (
965                "leading whitespace",
966                vec![("cookie", format!("theme=dark;   {name}=abc"))],
967                Some("abc"),
968            ),
969            (
970                "a quoted value",
971                vec![("cookie", format!("{name}=\"abc\""))],
972                Some("abc"),
973            ),
974            (
975                "a segment with no equals sign",
976                vec![("cookie", format!("broken; {name}=abc"))],
977                Some("abc"),
978            ),
979            (
980                "present but empty",
981                vec![("cookie", format!("{name}="))],
982                Some(""),
983            ),
984            (
985                "a different cookie only",
986                vec![("cookie", "other=abc".to_string())],
987                None,
988            ),
989            (
990                // The fixation vector: a crafted header repeating the name
991                // must not let the *second* value win.
992                "duplicated in one header",
993                vec![("cookie", format!("{name}=first; {name}=second"))],
994                Some("first"),
995            ),
996            (
997                "duplicated across two headers",
998                vec![
999                    ("cookie", format!("{name}=first")),
1000                    ("cookie", format!("{name}=second")),
1001                ],
1002                Some("first"),
1003            ),
1004            (
1005                "a name that merely contains ours",
1006                vec![("cookie", format!("x{name}=nope"))],
1007                None,
1008            ),
1009        ];
1010
1011        for (label, pairs, expected) in cases {
1012            let owned: Vec<(&str, &str)> = pairs.iter().map(|(n, v)| (*n, v.as_str())).collect();
1013            assert_eq!(
1014                cookie_value(&headers(&owned)).as_deref(),
1015                expected,
1016                "case `{label}`"
1017            );
1018        }
1019    }
1020
1021    #[test]
1022    fn the_csrf_check_accepts_only_an_exact_match() {
1023        let expected = "the-expected-token";
1024        assert!(check_csrf(&headers(&[(CSRF_HEADER, expected)]), expected).is_ok());
1025
1026        // Missing.
1027        let error = check_csrf(&HeaderMap::new(), expected).unwrap_err();
1028        assert_eq!(error.code, "csrf_failed");
1029        assert!(error.message.contains(CSRF_HEADER));
1030
1031        // Wrong, and wrong-length (the branch that short-circuits `ct_eq`).
1032        for supplied in [
1033            "",
1034            "wrong",
1035            "the-expected-token-but-longer",
1036            "the-expected-toke",
1037        ] {
1038            let error = check_csrf(&headers(&[(CSRF_HEADER, supplied)]), expected).unwrap_err();
1039            assert_eq!(error.code, "csrf_failed", "for `{supplied}`");
1040        }
1041    }
1042
1043    #[test]
1044    fn the_origin_gate_covers_the_cases_a_browser_produces() {
1045        let base = "http://localhost:3001";
1046
1047        // Neither header: a script or curl. Not the threat this addresses.
1048        assert!(check_origin(&HeaderMap::new(), base).is_ok());
1049
1050        // Same-origin, both spellings a browser uses.
1051        assert!(check_origin(&headers(&[("sec-fetch-site", "same-origin")]), base).is_ok());
1052        assert!(check_origin(&headers(&[("sec-fetch-site", "none")]), base).is_ok());
1053        assert!(check_origin(&headers(&[("origin", base)]), base).is_ok());
1054
1055        for site in ["cross-site", "same-site"] {
1056            let error = check_origin(&headers(&[("sec-fetch-site", site)]), base).unwrap_err();
1057            assert_eq!(error.code, "csrf_failed", "for {site}");
1058            // `same-site` is the one SameSite=Strict would have let through:
1059            // a different port of the same host.
1060            assert!(error.message.contains(site));
1061        }
1062
1063        let error = check_origin(&headers(&[("origin", "http://evil.example")]), base).unwrap_err();
1064        assert!(error.message.contains("evil.example"));
1065
1066        // A different port of the same host is a different origin.
1067        let error =
1068            check_origin(&headers(&[("origin", "http://localhost:8080")]), base).unwrap_err();
1069        assert_eq!(error.code, "csrf_failed");
1070    }
1071
1072    fn ip(last: u8) -> Option<IpAddr> {
1073        Some(IpAddr::from([192, 0, 2, last]))
1074    }
1075
1076    /// One failed attempt, start to finish.
1077    fn fail(limiter: &LoginLimiter, client: Option<IpAddr>) {
1078        limiter.begin(client).unwrap().failed();
1079    }
1080
1081    #[test]
1082    fn the_limiter_permits_up_to_the_limit_then_refuses() {
1083        let limiter = LoginLimiter::new(3, 300);
1084
1085        for attempt in 0..3 {
1086            let slot = limiter.begin(ip(1));
1087            assert!(slot.is_ok(), "attempt {attempt} must pass");
1088            slot.unwrap().failed();
1089        }
1090
1091        let retry_after = limiter.begin(ip(1)).unwrap_err();
1092        assert!(retry_after > 0 && retry_after <= 300, "got {retry_after}");
1093
1094        // Scoped to the address that failed.
1095        assert!(limiter.begin(ip(2)).is_ok());
1096    }
1097
1098    /// The race this type exists to close: a burst whose attempts are all in
1099    /// flight at once must not each find the budget untouched.
1100    #[test]
1101    fn attempts_in_flight_count_against_the_limit() {
1102        let limiter = LoginLimiter::new(3, 300);
1103        let held: Vec<_> = (0..3).map(|_| limiter.begin(ip(1)).unwrap()).collect();
1104        assert!(
1105            limiter.begin(ip(1)).is_err(),
1106            "a fourth concurrent attempt must be refused before any has failed"
1107        );
1108
1109        // Settled without failing — a right password awaiting its second
1110        // factor — the slots come back.
1111        drop(held);
1112        assert!(limiter.begin(ip(1)).is_ok());
1113        assert!(
1114            limiter.buckets.lock().unwrap().is_empty(),
1115            "a bucket with nothing to remember must not linger"
1116        );
1117    }
1118
1119    #[test]
1120    fn a_success_clears_the_counter() {
1121        let limiter = LoginLimiter::new(2, 300);
1122        fail(&limiter, ip(1));
1123        limiter.record_success(ip(1));
1124        fail(&limiter, ip(1));
1125        assert!(
1126            limiter.begin(ip(1)).is_ok(),
1127            "the pre-success failure must not still count"
1128        );
1129    }
1130
1131    /// A success while another attempt is in flight keeps the bucket that
1132    /// attempt will settle against, so its slot is still released.
1133    #[test]
1134    fn a_success_beside_an_attempt_in_flight_keeps_its_slot() {
1135        let limiter = LoginLimiter::new(1, 300);
1136        let other = limiter.begin(ip(1)).unwrap();
1137        limiter.record_success(ip(1));
1138        assert!(limiter.begin(ip(1)).is_err(), "the slot is still taken");
1139        drop(other);
1140        assert!(limiter.begin(ip(1)).is_ok());
1141    }
1142
1143    #[test]
1144    fn the_window_rolls_over_and_prunes() {
1145        // A one-second window, so the rollover is observable without sleeping
1146        // on a wall clock the test does not control.
1147        let limiter = LoginLimiter::new(1, 1);
1148        fail(&limiter, ip(1));
1149        assert!(limiter.begin(ip(1)).is_err());
1150
1151        // Backdate the bucket past the window.
1152        {
1153            let mut buckets = limiter.buckets.lock().unwrap();
1154            buckets.get_mut(&ip(1).unwrap()).unwrap().window_started -= 5;
1155        }
1156        assert!(limiter.begin(ip(1)).is_ok(), "the window must roll over");
1157        assert!(
1158            limiter.buckets.lock().unwrap().is_empty(),
1159            "a stale bucket must be pruned, or the map grows without bound"
1160        );
1161    }
1162
1163    #[test]
1164    fn a_missing_client_address_is_not_limited() {
1165        let limiter = LoginLimiter::new(1, 300);
1166        fail(&limiter, None);
1167        limiter.record_success(None);
1168        assert!(
1169            limiter.begin(None).is_ok(),
1170            "failing closed here would lock out every request, not every attacker"
1171        );
1172    }
1173
1174    /// Rotating through one /64 is one budget; a different /64 is another, and
1175    /// an IPv4-mapped address is its IPv4 self.
1176    #[test]
1177    fn an_ipv6_client_is_limited_by_its_slash_64() {
1178        let limiter = LoginLimiter::new(1, 300);
1179        let v6 = |s: &str| Some(s.parse::<IpAddr>().unwrap());
1180
1181        fail(&limiter, v6("2001:db8:1:2::1"));
1182        assert!(limiter.begin(v6("2001:db8:1:2:ffff::9")).is_err());
1183        assert!(limiter.begin(v6("2001:db8:1:3::1")).is_ok());
1184
1185        fail(&limiter, ip(7));
1186        assert!(limiter.begin(v6("::ffff:192.0.2.7")).is_err());
1187    }
1188
1189    /// The failures cross a reload; the in-flight counts must not, or a slot
1190    /// held across it would never come back.
1191    #[test]
1192    fn a_rebuilt_limiter_keeps_failures_and_drops_slots_in_flight() {
1193        let old = LoginLimiter::new(2, 300);
1194        fail(&old, ip(1));
1195        let straddling = old.begin(ip(1)).unwrap();
1196
1197        let new = old.rebuilt(2, 300);
1198        drop(straddling);
1199        assert!(new.begin(ip(1)).is_ok(), "one failure of two is spent");
1200        fail(&new, ip(1));
1201        assert!(new.begin(ip(1)).is_err());
1202    }
1203
1204    #[test]
1205    fn log_login_renders_both_outcomes() {
1206        // The `tracing` field expressions only execute when something is
1207        // subscribed; `tests/common` installs a sink, and here the call is
1208        // simply exercised for its branches.
1209        log_login(true, "alice", ip(1), "");
1210        log_login(false, "alice", ip(1), "wrong_password");
1211        log_login(false, "alice", None, "unknown_user");
1212    }
1213}