Skip to main content

acme_proxy/webadmin/handlers/
mfa.rs

1//! `/api/mfa` — enrol, confirm, disable, and reissue recovery codes.
2//!
3//! The counterpart to [`super::session`], which owns the two *login* steps.
4//! Everything here acts on a session that already exists, in one of two states:
5//! `active` (an operator managing their own factor) or `pending_mfa` with no
6//! factor yet (`admin.require_mfa` forcing enrolment before the session becomes
7//! usable). [`crate::webadmin::session::EnrolWrite`] is what tells those two
8//! apart, and what refuses the third case -- a `pending_mfa` session that owes a
9//! *code*, which must never reach an enrolment route.
10
11use axum::Json;
12use axum::extract::State;
13use axum::http::{StatusCode, header};
14use axum::response::{IntoResponse, Response};
15use serde::Deserialize;
16use serde_json::json;
17use tracing::warn;
18
19use crate::admin::{mfa, totp};
20use crate::webadmin::AdminState;
21use crate::webadmin::error::AdminError;
22use crate::webadmin::handlers::session::finish_enrolment;
23use crate::webadmin::session::{AdminClientIp, Authenticated, AuthenticatedWrite, EnrolWrite};
24
25#[derive(Debug, Deserialize)]
26pub struct ConfirmRequest {
27    pub code: String,
28}
29
30/// The body of every route that *changes* a second factor rather than proving
31/// one. `password` is required only when a factor already exists — see
32/// [`check_step_up`].
33#[derive(Debug, Default, Deserialize)]
34pub struct StepUpRequest {
35    #[serde(default)]
36    pub password: String,
37}
38
39/// Re-authenticates an operator who is about to replace or remove an existing
40/// second factor.
41///
42/// A live session is not sufficient authority for this, and the reason is the
43/// blast radius rather than the change itself: `confirm_totp_enrolment` and
44/// `disable_totp` both call `revoke_other_sessions` and supersede the recovery
45/// codes. So somebody holding a stolen cookie can enrol *their* authenticator
46/// over the operator's, end every one of the operator's other sessions, and
47/// void the codes that would let them back in — converting a stolen session
48/// into a lockout that only `acme-proxy admin user totp reset` on the host can
49/// undo.
50///
51/// Only when a factor already exists. A first enrolment protects nothing, and
52/// demanding a password there would put one in the way of the `require_mfa`
53/// bootstrap, whose whole design is that enrolling must stay reachable.
54///
55/// The refusal is `invalid_credentials`, the same answer sign-in gives, so this
56/// is not a second oracle for whether a password is right.
57pub(crate) fn check_step_up(
58    user: &crate::sqlite::admin_user::AdminUser,
59    password: &str,
60    client: Option<std::net::IpAddr>,
61    logins: &crate::webadmin::session::LoginLimiter,
62) -> Result<(), AdminError> {
63    if !user.has_totp() {
64        return Ok(());
65    }
66
67    // Checked **before** the KDF, which is `sign_in`'s reasoning verbatim: 600 000
68    // PBKDF2 iterations is a denial-of-service lever, and until this ran here an
69    // authenticated caller could pull it as fast as it could send requests.
70    // Guessing was unbounded too, which mattered more — this is the one check
71    // standing between a stolen cookie and the factor takeover the doc comment
72    // above describes.
73    //
74    // **The sign-in bucket, deliberately, not one of its own.** It is literally
75    // the same secret, and the panel already shares one bucket between the
76    // password step and the code step; a second budget here would hand an
77    // attacker `2 × login_max_attempts` guesses per window against one password.
78    // The cost is that a lockout earned on the account card also refuses sign-in
79    // from that address until `login_window_seconds` rolls over — already true
80    // of the code step, and the same remedy.
81    //
82    // What this does *not* bound: an `active` cookie is valid from any address
83    // on purpose (`created_ip` is forensics, never compared), so somebody
84    // rotating source addresses still gets `login_max_attempts` guesses each.
85    // Closing that needs a per-session counter, i.e. a column on
86    // `admin_sessions` — deliberately not done, because unlike a six-digit code
87    // a password behind 85 ms of PBKDF2 per guess is not reachable that way, and
88    // the address bucket already removes the DoS lever.
89    if let Err(retry_after) = logins.check(client) {
90        warn!(
91            event = "admin_mfa_step_up_refused",
92            outcome = "failure",
93            username = %user.username,
94            reason = "rate_limited"
95        );
96        return Err(AdminError::rate_limited(retry_after));
97    }
98
99    match crate::admin::password::verify_password(&user.password_hash, password) {
100        Ok(true) => {
101            // No `record_success`. `sign_in` moved its own to the *promotion*
102            // past the second factor for exactly this reason: clearing the
103            // bucket on a correct password would let whoever holds one reset it
104            // at will and brute-force the six digits behind it. A step-up caller
105            // is in that position by definition.
106            Ok(())
107        }
108        Ok(false) => {
109            logins.record_failure(client);
110            warn!(event = "admin_mfa_step_up_refused", outcome = "failure", username = %user.username, reason = "wrong_password");
111            Err(AdminError::invalid_credentials())
112        }
113        Err(error) => {
114            // A stored hash this process cannot parse is a corrupt row, not a
115            // wrong password. Refuse rather than let the change through.
116            //
117            // No `record_failure`: `decode` failed before the KDF ran, so
118            // nothing was guessed and no work was spent. Counting it would let
119            // one corrupt row lock its own owner out of sign-in as well — the
120            // one account that most needs to reach an operator.
121            //
122            // `warn`, matching `admin::users::authenticate`'s report of the
123            // same condition: one name emits at one level.
124            warn!(event = "admin_password_hash_unreadable",
125                  outcome = "failure",
126                  username = %user.username,
127                  error = %error);
128            Err(AdminError::invalid_credentials())
129        }
130    }
131}
132
133/// `GET /api/mfa` — this operator's second-factor state.
134///
135/// Never the secret, and never a recovery code: only whether one exists and how
136/// many are left.
137pub async fn get_mfa(
138    State(state): State<AdminState>,
139    auth: Authenticated,
140) -> Result<Json<serde_json::Value>, AdminError> {
141    let remaining = mfa::recovery_codes_remaining(&auth.user.id, state.database).await?;
142    Ok(Json(json!({
143        "totpEnabled": auth.user.has_totp(),
144        "enrolmentPending": auth.user.has_pending_totp(),
145        "recoveryCodesRemaining": remaining,
146    })))
147}
148
149/// `POST /api/mfa/totp` — begin an enrolment.
150///
151/// **The response is the only time the secret is readable.** It is stored as
152/// `totp_pending_secret` and never rendered again -- `GET /api/mfa` reports
153/// `enrolmentPending`, not the bytes. Starting a second enrolment overwrites the
154/// pending one and leaves any *confirmed* factor untouched, which is what makes
155/// "move to a new phone" safe to begin.
156///
157/// Requires the account password when a factor already exists: see
158/// [`check_step_up`]. `confirm` deliberately does not, since it can only
159/// confirm a secret this route already gated.
160pub async fn begin_totp(
161    State(state): State<AdminState>,
162    AdminClientIp(client): AdminClientIp,
163    enrol: EnrolWrite,
164    body: Option<Json<StepUpRequest>>,
165) -> Result<Response, AdminError> {
166    let mut user = enrol.user;
167    check_step_up(
168        &user,
169        &body.unwrap_or_default().password,
170        client,
171        &state.logins,
172    )?;
173    let enrolment = mfa::begin_totp_enrolment(
174        &mut user,
175        &state.config.admin.base_url,
176        state.database.clone(),
177    )
178    .await?;
179
180    Ok((
181        StatusCode::CREATED,
182        Json(json!({
183            "secret": enrolment.secret_base32,
184            "uri": enrolment.uri,
185            "algorithm": "SHA1",
186            "digits": totp::DIGITS,
187            "period": totp::PERIOD_SECONDS,
188        })),
189    )
190        .into_response())
191}
192
193/// `POST /api/mfa/totp/confirm` — prove a code against the pending enrolment.
194///
195/// On success the pending secret becomes the real one and a fresh recovery set
196/// is returned **once**. When the session was still `pending_mfa` -- the
197/// `require_mfa` bootstrap -- this also completes the login, so the answer
198/// carries a rotated cookie: setting a factor up *is* the second step for an
199/// operator who had none.
200pub async fn confirm_totp(
201    State(state): State<AdminState>,
202    AdminClientIp(client): AdminClientIp,
203    enrol: EnrolWrite,
204    Json(body): Json<ConfirmRequest>,
205) -> Result<Response, AdminError> {
206    let mut user = enrol.user;
207    let keep = enrol.session.token_hash.clone();
208
209    let Some(codes) =
210        mfa::confirm_totp_enrolment(&mut user, &body.code, Some(&keep), state.database.clone())
211            .await?
212    else {
213        return Err(AdminError::bad_request(
214            "that code does not match the pending enrolment",
215        ));
216    };
217
218    let body = json!({ "recoveryCodes": codes });
219
220    if !enrol.pending {
221        return Ok((StatusCode::OK, Json(body)).into_response());
222    }
223
224    // The `require_mfa` bootstrap: this confirmation completed the login, so it
225    // owes everything the code path owes. `finish_enrolment` is the one place
226    // that knows what, and the pages side calls the same function.
227    let (_, cookie) =
228        finish_enrolment(&state, client, &mut user, &enrol.session.token_hash).await?;
229    Ok((StatusCode::OK, [(header::SET_COOKIE, cookie)], Json(body)).into_response())
230}
231
232/// `DELETE /api/mfa/totp` — remove the factor and every recovery code.
233///
234/// Refused while `admin.require_mfa` is on: the operator would be made to enrol
235/// again on their very next sign-in, so the only thing removing it achieves is
236/// a locked panel between the two.
237///
238/// Requires the account password ([`check_step_up`]) — removing a factor is the
239/// most consequential thing a stolen cookie could do here.
240pub async fn disable_totp(
241    State(state): State<AdminState>,
242    AdminClientIp(client): AdminClientIp,
243    AuthenticatedWrite(auth): AuthenticatedWrite,
244    body: Option<Json<StepUpRequest>>,
245) -> Result<Response, AdminError> {
246    if state.config.admin.require_mfa {
247        return Err(AdminError::conflict(
248            "mfa_required",
249            "admin.require_mfa is on: this server requires a second factor of every operator",
250        ));
251    }
252
253    let mut user = auth.user;
254    check_step_up(
255        &user,
256        &body.unwrap_or_default().password,
257        client,
258        &state.logins,
259    )?;
260    mfa::disable_totp(
261        &mut user,
262        Some(&auth.session.token_hash),
263        state.database.clone(),
264    )
265    .await?;
266
267    Ok(StatusCode::NO_CONTENT.into_response())
268}
269
270/// `POST /api/mfa/recovery-codes` — mint a fresh set, **shown once**.
271///
272/// The previous set stops working the moment this answers, which is why it
273/// takes the account password ([`check_step_up`]): superseding the set a stolen
274/// session's owner would use to recover is the same lockout as replacing the
275/// factor.
276pub async fn regenerate_recovery_codes(
277    State(state): State<AdminState>,
278    AdminClientIp(client): AdminClientIp,
279    AuthenticatedWrite(auth): AuthenticatedWrite,
280    body: Option<Json<StepUpRequest>>,
281) -> Result<Json<serde_json::Value>, AdminError> {
282    if !auth.user.has_totp() {
283        return Err(AdminError::conflict(
284            "mfa_not_enabled",
285            "there is no second factor for these codes to recover access to",
286        ));
287    }
288    check_step_up(
289        &auth.user,
290        &body.unwrap_or_default().password,
291        client,
292        &state.logins,
293    )?;
294
295    let codes = mfa::regenerate_recovery_codes(&auth.user, state.database).await?;
296    Ok(Json(json!({ "recoveryCodes": codes })))
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use crate::sqlite::admin_user::AdminUser;
303
304    fn user_with(password_hash: &str, totp: Option<&[u8]>) -> AdminUser {
305        AdminUser {
306            id: "11111111-2222-3333-4444-555555555555".to_string(),
307            username: "alice".to_string(),
308            password_hash: password_hash.to_string(),
309            status: "active".to_string(),
310            totp_secret: totp.map(<[u8]>::to_vec),
311            totp_pending_secret: None,
312            totp_last_step: None,
313            created_at: 1_700_000_000,
314            updated_at: 1_700_000_000,
315            last_login_at: None,
316        }
317    }
318
319    use crate::webadmin::session::LoginLimiter;
320    use std::net::IpAddr;
321
322    const MAX_ATTEMPTS: u32 = 5;
323
324    fn limiter() -> LoginLimiter {
325        LoginLimiter::new(MAX_ATTEMPTS, 300)
326    }
327
328    fn client() -> Option<IpAddr> {
329        Some("198.51.100.7".parse().expect("a literal address"))
330    }
331
332    /// The encoded form is self-describing, so a cheaper cost still exercises
333    /// every branch here at a fraction of the wall clock — which matters,
334    /// because these tests deliberately run the KDF several times over.
335    fn cheap_hash(password: &str) -> String {
336        crate::admin::password::hash_generated_secret(password)
337    }
338
339    /// The gate is scoped to operators who *have* something to protect.
340    #[test]
341    fn a_factorless_operator_passes_without_a_password() {
342        let user = user_with("not-even-a-valid-hash", None);
343        let logins = limiter();
344        assert!(check_step_up(&user, "", client(), &logins).is_ok());
345        assert!(check_step_up(&user, "anything", client(), &logins).is_ok());
346    }
347
348    #[test]
349    fn a_live_factor_needs_the_right_password() {
350        let hash = cheap_hash("correct horse battery staple");
351        let user = user_with(&hash, Some(b"secret"));
352        let logins = limiter();
353
354        assert!(check_step_up(&user, "correct horse battery staple", client(), &logins).is_ok());
355        for wrong in ["", "Correct horse battery staple", "wrong"] {
356            let Err(error) = check_step_up(&user, wrong, client(), &logins) else {
357                panic!("{wrong:?} must be refused");
358            };
359            // Byte-identical to a wrong password at sign-in, so this is not a
360            // second oracle for whether one is right.
361            assert_eq!(error.code, AdminError::invalid_credentials().code);
362            assert_eq!(error.status, AdminError::invalid_credentials().status);
363        }
364    }
365
366    /// A stored hash this process cannot parse is a corrupt row, not a correct
367    /// password. It must refuse rather than let the factor change through.
368    #[test]
369    fn an_unreadable_stored_hash_refuses_rather_than_admits() {
370        let user = user_with("pbkdf2-sha256$not-a-number$salt$digest", Some(b"secret"));
371        let logins = limiter();
372        let error = check_step_up(&user, "anything", client(), &logins)
373            .expect_err("a corrupt hash must refuse");
374        assert_eq!(error.code, AdminError::invalid_credentials().code);
375    }
376
377    /// Guessing the password here is bounded by the same budget sign-in uses.
378    ///
379    /// The assertion that matters is the *last* one: once the address is locked
380    /// out the **correct** password is refused too, which is the only thing that
381    /// can prove the limiter runs before the KDF rather than after it. Checking
382    /// it afterwards would bound nothing — the expensive work would already be
383    /// done, and that expense is the denial-of-service lever `sign_in` runs its
384    /// own check ahead of.
385    #[test]
386    fn a_wrong_password_counts_against_the_login_limiter() {
387        let hash = cheap_hash("correct horse battery staple");
388        let user = user_with(&hash, Some(b"secret"));
389        let logins = limiter();
390
391        for _ in 0..MAX_ATTEMPTS {
392            let error = check_step_up(&user, "wrong", client(), &logins)
393                .expect_err("a wrong password must refuse");
394            assert_eq!(error.code, AdminError::invalid_credentials().code);
395        }
396
397        let error = check_step_up(&user, "correct horse battery staple", client(), &logins)
398            .expect_err("the budget is spent, so even a correct password waits");
399        assert_eq!(error.status, StatusCode::TOO_MANY_REQUESTS);
400    }
401
402    /// A correct password does **not** clear the bucket.
403    ///
404    /// `sign_in` moved its own `record_success` past the second factor for
405    /// exactly this reason: somebody holding a correct password must not be able
406    /// to reset the budget at will and brute-force the six digits behind it. A
407    /// step-up caller holds a session, so they are in that position by
408    /// definition.
409    #[test]
410    fn a_correct_password_does_not_clear_the_bucket() {
411        let hash = cheap_hash("correct horse battery staple");
412        let user = user_with(&hash, Some(b"secret"));
413        let logins = limiter();
414
415        for _ in 0..MAX_ATTEMPTS - 1 {
416            assert!(check_step_up(&user, "wrong", client(), &logins).is_err());
417        }
418        assert!(check_step_up(&user, "correct horse battery staple", client(), &logins).is_ok());
419
420        // One guess left, not a fresh five.
421        assert!(check_step_up(&user, "wrong", client(), &logins).is_err());
422        let error = check_step_up(&user, "wrong", client(), &logins)
423            .expect_err("the budget survives a correct password");
424        assert_eq!(error.status, StatusCode::TOO_MANY_REQUESTS);
425    }
426
427    /// An operator with no factor never spends the budget, since the gate
428    /// returns before any KDF runs — there is nothing to bound, and charging
429    /// them would let a factorless account lock its own address out of sign-in.
430    #[test]
431    fn a_factorless_operator_never_touches_the_limiter() {
432        let user = user_with("not-even-a-valid-hash", None);
433        let logins = limiter();
434
435        for _ in 0..MAX_ATTEMPTS + 1 {
436            assert!(check_step_up(&user, "anything", client(), &logins).is_ok());
437        }
438        assert!(logins.check(client()).is_ok());
439    }
440
441    /// A corrupt row refuses, but must not lock its own owner out of sign-in:
442    /// `decode` failed before the KDF ran, so nothing was guessed and no work
443    /// was spent.
444    #[test]
445    fn an_unreadable_stored_hash_does_not_lock_the_address_out() {
446        let user = user_with("pbkdf2-sha256$not-a-number$salt$digest", Some(b"secret"));
447        let logins = limiter();
448
449        for _ in 0..MAX_ATTEMPTS + 1 {
450            assert!(check_step_up(&user, "anything", client(), &logins).is_err());
451        }
452        assert!(logins.check(client()).is_ok());
453    }
454}