1use 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, EnrolWrite, SelfServiceWrite};
24
25#[derive(Debug, Deserialize)]
28pub struct ConfirmRequest {
29 pub code: String,
30}
31
32#[derive(Debug, Default, Deserialize)]
36pub struct StepUpRequest {
37 #[serde(default)]
38 pub password: String,
39}
40
41pub(crate) async fn check_step_up(
60 user: &acme_proxy_store::admin_user::AdminUser,
61 password: &str,
62 client: Option<std::net::IpAddr>,
63 logins: &crate::webadmin::session::LoginLimiter,
64) -> Result<(), AdminError> {
65 if !user.has_totp() {
66 return Ok(());
67 }
68 verify_current_password(user, password, client, logins).await
69}
70
71pub(crate) async fn verify_current_password(
81 user: &acme_proxy_store::admin_user::AdminUser,
82 password: &str,
83 client: Option<std::net::IpAddr>,
84 logins: &crate::webadmin::session::LoginLimiter,
85) -> Result<(), AdminError> {
86 let attempt = match logins.begin(client) {
109 Ok(attempt) => attempt,
110 Err(retry_after) => {
111 warn!(
112 event = "admin_mfa_step_up_refused",
113 outcome = "failure",
114 username = %user.username,
115 reason = "rate_limited"
116 );
117 return Err(AdminError::rate_limited(retry_after));
118 }
119 };
120
121 match crate::admin::password::verify_password_off_runtime(&user.password_hash, password).await {
122 Ok(true) => {
123 Ok(())
129 }
130 Ok(false) => {
131 attempt.failed();
132 warn!(event = "admin_mfa_step_up_refused", outcome = "failure", username = %user.username, reason = "wrong_password");
133 Err(AdminError::invalid_credentials())
134 }
135 Err(error) => {
136 warn!(event = "admin_password_hash_unreadable",
147 outcome = "failure",
148 username = %user.username,
149 error = %error);
150 Err(AdminError::invalid_credentials())
151 }
152 }
153}
154
155mod actions {
165 use super::{AdminError, AdminState, check_step_up, mfa};
166 use crate::webadmin::CredentialChange;
167 use acme_proxy_core::audit::RequestContext;
168 use acme_proxy_store::admin_user::AdminUser;
169 use std::net::IpAddr;
170
171 #[derive(Clone, Copy)]
176 pub(super) struct Origin {
177 pub(super) client: Option<IpAddr>,
178 }
179
180 pub(super) async fn begin_totp(
191 state: &AdminState,
192 user: &mut AdminUser,
193 password: &str,
194 origin: Origin,
195 ) -> Result<crate::admin::totp::Enrolment, AdminError> {
196 check_step_up(user, password, origin.client, &state.logins).await?;
197 Ok(mfa::resume_or_begin_totp_enrolment(
198 user,
199 &state.config.admin.base_url,
200 state.database.clone(),
201 )
202 .await?)
203 }
204
205 pub(super) async fn confirm_totp(
208 state: &AdminState,
209 request: &RequestContext,
210 user: &mut AdminUser,
211 code: &str,
212 keep: &str,
213 origin: Origin,
214 ) -> Result<Option<Vec<String>>, AdminError> {
215 let Some(codes) =
216 mfa::confirm_totp_enrolment(user, code, Some(keep), state.database.clone()).await?
217 else {
218 return Ok(None);
219 };
220 record(
221 state,
222 request,
223 user,
224 CredentialChange::SecondFactorEnabled,
225 origin,
226 )
227 .await;
228 Ok(Some(codes))
229 }
230
231 pub(super) async fn disable_totp(
233 state: &AdminState,
234 request: &RequestContext,
235 user: &mut AdminUser,
236 password: &str,
237 keep: &str,
238 origin: Origin,
239 ) -> Result<(), AdminError> {
240 if state.config.admin.require_mfa {
241 return Err(AdminError::conflict(
242 "mfa_required",
243 "admin.require_mfa is on: this server requires a second factor of every operator",
244 ));
245 }
246 check_step_up(user, password, origin.client, &state.logins).await?;
247 mfa::disable_totp(user, Some(keep), state.database.clone()).await?;
248 record(
249 state,
250 request,
251 user,
252 CredentialChange::SecondFactorDisabled,
253 origin,
254 )
255 .await;
256 Ok(())
257 }
258
259 pub(super) async fn regenerate_recovery_codes(
261 state: &AdminState,
262 request: &RequestContext,
263 user: &AdminUser,
264 password: &str,
265 origin: Origin,
266 ) -> Result<Vec<String>, AdminError> {
267 if !user.has_totp() {
268 return Err(AdminError::conflict(
269 "mfa_not_enabled",
270 "there is no second factor for these codes to recover access to",
271 ));
272 }
273 check_step_up(user, password, origin.client, &state.logins).await?;
274 let codes = mfa::regenerate_recovery_codes(user, state.database.clone()).await?;
275 record(
276 state,
277 request,
278 user,
279 CredentialChange::RecoveryCodesRegenerated,
280 origin,
281 )
282 .await;
283 Ok(codes)
284 }
285
286 async fn record(
289 state: &AdminState,
290 request: &RequestContext,
291 user: &AdminUser,
292 change: CredentialChange,
293 origin: Origin,
294 ) {
295 state
296 .record_credential_change(request, &user.username, user, change, true, origin.client)
297 .await;
298 }
299}
300
301pub(crate) async fn begin_totp_for(
305 state: &AdminState,
306 user: &mut acme_proxy_store::admin_user::AdminUser,
307 password: &str,
308 client: Option<std::net::IpAddr>,
309) -> Result<crate::admin::totp::Enrolment, AdminError> {
310 actions::begin_totp(state, user, password, actions::Origin { client }).await
311}
312
313pub(crate) async fn confirm_totp_for(
315 state: &AdminState,
316 request: &acme_proxy_core::audit::RequestContext,
317 user: &mut acme_proxy_store::admin_user::AdminUser,
318 code: &str,
319 keep: &str,
320 client: Option<std::net::IpAddr>,
321) -> Result<Option<Vec<String>>, AdminError> {
322 actions::confirm_totp(state, request, user, code, keep, actions::Origin { client }).await
323}
324
325pub(crate) async fn disable_totp_for(
327 state: &AdminState,
328 request: &acme_proxy_core::audit::RequestContext,
329 user: &mut acme_proxy_store::admin_user::AdminUser,
330 password: &str,
331 keep: &str,
332 client: Option<std::net::IpAddr>,
333) -> Result<(), AdminError> {
334 actions::disable_totp(
335 state,
336 request,
337 user,
338 password,
339 keep,
340 actions::Origin { client },
341 )
342 .await
343}
344
345pub(crate) async fn regenerate_recovery_codes_for(
347 state: &AdminState,
348 request: &acme_proxy_core::audit::RequestContext,
349 user: &acme_proxy_store::admin_user::AdminUser,
350 password: &str,
351 client: Option<std::net::IpAddr>,
352) -> Result<Vec<String>, AdminError> {
353 actions::regenerate_recovery_codes(state, request, user, password, actions::Origin { client })
354 .await
355}
356
357pub async fn get_mfa(
362 State(state): State<AdminState>,
363 auth: Authenticated,
364) -> Result<Json<serde_json::Value>, AdminError> {
365 let remaining = mfa::recovery_codes_remaining(auth.user.id, state.database).await?;
366 Ok(Json(json!({
367 "totpEnabled": auth.user.has_totp(),
368 "enrolmentPending": auth.user.has_pending_totp(),
369 "recoveryCodesRemaining": remaining,
370 })))
371}
372
373pub async fn begin_totp(
385 State(state): State<AdminState>,
386 AdminClientIp(client): AdminClientIp,
387 enrol: EnrolWrite,
388 body: Option<Json<StepUpRequest>>,
389) -> Result<Response, AdminError> {
390 let mut user = enrol.user;
391 let enrolment = actions::begin_totp(
392 &state,
393 &mut user,
394 &body.unwrap_or_default().password,
395 actions::Origin { client },
396 )
397 .await?;
398
399 Ok((
400 StatusCode::CREATED,
401 Json(json!({
402 "secret": enrolment.secret_base32,
403 "uri": enrolment.uri,
404 "algorithm": "SHA1",
405 "digits": totp::DIGITS,
406 "period": totp::PERIOD_SECONDS,
407 })),
408 )
409 .into_response())
410}
411
412pub async fn confirm_totp(
420 State(state): State<AdminState>,
421 AdminClientIp(client): AdminClientIp,
422 enrol: EnrolWrite,
423 request_context: acme_proxy_core::audit::RequestContext,
424 Json(body): Json<ConfirmRequest>,
425) -> Result<Response, AdminError> {
426 let mut user = enrol.user;
427 let keep = enrol.session.token_hash.clone();
428
429 let Some(codes) = actions::confirm_totp(
430 &state,
431 &request_context,
432 &mut user,
433 &body.code,
434 &keep,
435 actions::Origin { client },
436 )
437 .await?
438 else {
439 return Err(AdminError::bad_request(
440 "that code does not match the pending enrolment",
441 ));
442 };
443
444 let body = json!({ "recoveryCodes": codes });
445
446 if !enrol.pending {
447 return Ok((StatusCode::OK, Json(body)).into_response());
448 }
449
450 let (_, cookie) = finish_enrolment(
454 &state,
455 client,
456 &mut user,
457 &enrol.session.token_hash,
458 enrol.session.user_agent.clone(),
459 )
460 .await?;
461 Ok((StatusCode::OK, [(header::SET_COOKIE, cookie)], Json(body)).into_response())
462}
463
464pub async fn disable_totp(
473 State(state): State<AdminState>,
474 AdminClientIp(client): AdminClientIp,
475 SelfServiceWrite(auth): SelfServiceWrite,
476 request_context: acme_proxy_core::audit::RequestContext,
477 body: Option<Json<StepUpRequest>>,
478) -> Result<Response, AdminError> {
479 let mut user = auth.user;
480 actions::disable_totp(
481 &state,
482 &request_context,
483 &mut user,
484 &body.unwrap_or_default().password,
485 &auth.session.token_hash,
486 actions::Origin { client },
487 )
488 .await?;
489
490 Ok(StatusCode::NO_CONTENT.into_response())
491}
492
493pub async fn regenerate_recovery_codes(
500 State(state): State<AdminState>,
501 AdminClientIp(client): AdminClientIp,
502 SelfServiceWrite(auth): SelfServiceWrite,
503 request_context: acme_proxy_core::audit::RequestContext,
504 body: Option<Json<StepUpRequest>>,
505) -> Result<Json<serde_json::Value>, AdminError> {
506 let codes = actions::regenerate_recovery_codes(
507 &state,
508 &request_context,
509 &auth.user,
510 &body.unwrap_or_default().password,
511 actions::Origin { client },
512 )
513 .await?;
514
515 Ok(Json(json!({ "recoveryCodes": codes })))
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use acme_proxy_store::admin_user::AdminUser;
522
523 fn user_with(password_hash: &str, totp: Option<&[u8]>) -> AdminUser {
524 AdminUser {
525 id: acme_proxy_store::testutil::ADMIN_FIXTURE_ID,
526 username: "alice".to_string(),
527 password_hash: password_hash.to_string(),
528 status: "active".to_string(),
529 role: None,
530 totp_secret: totp.map(<[u8]>::to_vec),
531 totp_pending_secret: None,
532 totp_last_step: None,
533 created_at: 1_700_000_000,
534 updated_at: 1_700_000_000,
535 last_login_at: None,
536 contact_email: None,
537 known_login_ips: Vec::new(),
538 }
539 }
540
541 use crate::webadmin::session::LoginLimiter;
542 use std::net::IpAddr;
543
544 const MAX_ATTEMPTS: u32 = 5;
545
546 fn limiter() -> LoginLimiter {
547 LoginLimiter::new(MAX_ATTEMPTS, 300)
548 }
549
550 fn client() -> Option<IpAddr> {
551 Some("198.51.100.7".parse().expect("a literal address"))
552 }
553
554 fn cheap_hash(password: &str) -> String {
558 crate::admin::password::hash_generated_secret(password)
559 }
560
561 #[tokio::test]
563 async fn a_factorless_operator_passes_without_a_password() {
564 let user = user_with("not-even-a-valid-hash", None);
565 let logins = limiter();
566 assert!(check_step_up(&user, "", client(), &logins).await.is_ok());
567 assert!(
568 check_step_up(&user, "anything", client(), &logins)
569 .await
570 .is_ok()
571 );
572 }
573
574 #[tokio::test]
578 async fn verify_current_password_runs_even_for_a_factorless_operator() {
579 let hash = cheap_hash("correct horse battery staple");
580 let user = user_with(&hash, None);
581 let logins = limiter();
582
583 assert!(
584 verify_current_password(&user, "correct horse battery staple", client(), &logins)
585 .await
586 .is_ok()
587 );
588 let error = verify_current_password(&user, "wrong", client(), &logins)
589 .await
590 .expect_err("a wrong password must refuse even with no factor enrolled");
591 assert_eq!(error.code, AdminError::invalid_credentials().code);
592 }
593
594 #[tokio::test]
595 async fn a_live_factor_needs_the_right_password() {
596 let hash = cheap_hash("correct horse battery staple");
597 let user = user_with(&hash, Some(b"secret"));
598 let logins = limiter();
599
600 assert!(
601 check_step_up(&user, "correct horse battery staple", client(), &logins)
602 .await
603 .is_ok()
604 );
605 for wrong in ["", "Correct horse battery staple", "wrong"] {
606 let Err(error) = check_step_up(&user, wrong, client(), &logins).await else {
607 panic!("{wrong:?} must be refused");
608 };
609 assert_eq!(error.code, AdminError::invalid_credentials().code);
612 assert_eq!(error.status, AdminError::invalid_credentials().status);
613 }
614 }
615
616 #[tokio::test]
619 async fn an_unreadable_stored_hash_refuses_rather_than_admits() {
620 let user = user_with("pbkdf2-sha256$not-a-number$salt$digest", Some(b"secret"));
621 let logins = limiter();
622 let error = check_step_up(&user, "anything", client(), &logins)
623 .await
624 .expect_err("a corrupt hash must refuse");
625 assert_eq!(error.code, AdminError::invalid_credentials().code);
626 }
627
628 #[tokio::test]
637 async fn a_wrong_password_counts_against_the_login_limiter() {
638 let hash = cheap_hash("correct horse battery staple");
639 let user = user_with(&hash, Some(b"secret"));
640 let logins = limiter();
641
642 for _ in 0..MAX_ATTEMPTS {
643 let error = check_step_up(&user, "wrong", client(), &logins)
644 .await
645 .expect_err("a wrong password must refuse");
646 assert_eq!(error.code, AdminError::invalid_credentials().code);
647 }
648
649 let error = check_step_up(&user, "correct horse battery staple", client(), &logins)
650 .await
651 .expect_err("the budget is spent, so even a correct password waits");
652 assert_eq!(error.status, StatusCode::TOO_MANY_REQUESTS);
653 }
654
655 #[tokio::test]
663 async fn a_correct_password_does_not_clear_the_bucket() {
664 let hash = cheap_hash("correct horse battery staple");
665 let user = user_with(&hash, Some(b"secret"));
666 let logins = limiter();
667
668 for _ in 0..MAX_ATTEMPTS - 1 {
669 assert!(
670 check_step_up(&user, "wrong", client(), &logins)
671 .await
672 .is_err()
673 );
674 }
675 assert!(
676 check_step_up(&user, "correct horse battery staple", client(), &logins)
677 .await
678 .is_ok()
679 );
680
681 assert!(
683 check_step_up(&user, "wrong", client(), &logins)
684 .await
685 .is_err()
686 );
687 let error = check_step_up(&user, "wrong", client(), &logins)
688 .await
689 .expect_err("the budget survives a correct password");
690 assert_eq!(error.status, StatusCode::TOO_MANY_REQUESTS);
691 }
692
693 #[tokio::test]
697 async fn a_factorless_operator_never_touches_the_limiter() {
698 let user = user_with("not-even-a-valid-hash", None);
699 let logins = limiter();
700
701 for _ in 0..MAX_ATTEMPTS + 1 {
702 assert!(
703 check_step_up(&user, "anything", client(), &logins)
704 .await
705 .is_ok()
706 );
707 }
708 assert!(logins.begin(client()).is_ok());
709 }
710
711 #[tokio::test]
715 async fn an_unreadable_stored_hash_does_not_lock_the_address_out() {
716 let user = user_with("pbkdf2-sha256$not-a-number$salt$digest", Some(b"secret"));
717 let logins = limiter();
718
719 for _ in 0..MAX_ATTEMPTS + 1 {
720 assert!(
721 check_step_up(&user, "anything", client(), &logins)
722 .await
723 .is_err()
724 );
725 }
726 assert!(logins.begin(client()).is_ok());
727 }
728}