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}