use axum::Json;
use axum::extract::State;
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use serde_json::json;
use tracing::warn;
use crate::admin::{mfa, totp};
use crate::webadmin::AdminState;
use crate::webadmin::error::AdminError;
use crate::webadmin::handlers::session::finish_enrolment;
use crate::webadmin::session::{AdminClientIp, Authenticated, EnrolWrite, SelfServiceWrite};
#[derive(Debug, Deserialize)]
pub struct ConfirmRequest {
pub code: String,
}
#[derive(Debug, Default, Deserialize)]
pub struct StepUpRequest {
#[serde(default)]
pub password: String,
}
pub(crate) async fn check_step_up(
user: &acme_proxy_store::admin_user::AdminUser,
password: &str,
client: Option<std::net::IpAddr>,
logins: &crate::webadmin::session::LoginLimiter,
) -> Result<(), AdminError> {
if !user.has_totp() {
return Ok(());
}
verify_current_password(user, password, client, logins).await
}
pub(crate) async fn verify_current_password(
user: &acme_proxy_store::admin_user::AdminUser,
password: &str,
client: Option<std::net::IpAddr>,
logins: &crate::webadmin::session::LoginLimiter,
) -> Result<(), AdminError> {
let attempt = match logins.begin(client) {
Ok(attempt) => attempt,
Err(retry_after) => {
warn!(
event = "admin_mfa_step_up_refused",
outcome = "failure",
username = %user.username,
reason = "rate_limited"
);
return Err(AdminError::rate_limited(retry_after));
}
};
match crate::admin::password::verify_password_off_runtime(&user.password_hash, password).await {
Ok(true) => {
Ok(())
}
Ok(false) => {
attempt.failed();
warn!(event = "admin_mfa_step_up_refused", outcome = "failure", username = %user.username, reason = "wrong_password");
Err(AdminError::invalid_credentials())
}
Err(error) => {
warn!(event = "admin_password_hash_unreadable",
outcome = "failure",
username = %user.username,
error = %error);
Err(AdminError::invalid_credentials())
}
}
}
mod actions {
use super::{AdminError, AdminState, check_step_up, mfa};
use crate::webadmin::CredentialChange;
use acme_proxy_core::audit::RequestContext;
use acme_proxy_store::admin_user::AdminUser;
use std::net::IpAddr;
#[derive(Clone, Copy)]
pub(super) struct Origin {
pub(super) client: Option<IpAddr>,
}
pub(super) async fn begin_totp(
state: &AdminState,
user: &mut AdminUser,
password: &str,
origin: Origin,
) -> Result<crate::admin::totp::Enrolment, AdminError> {
check_step_up(user, password, origin.client, &state.logins).await?;
Ok(mfa::resume_or_begin_totp_enrolment(
user,
&state.config.admin.base_url,
state.database.clone(),
)
.await?)
}
pub(super) async fn confirm_totp(
state: &AdminState,
request: &RequestContext,
user: &mut AdminUser,
code: &str,
keep: &str,
origin: Origin,
) -> Result<Option<Vec<String>>, AdminError> {
let Some(codes) =
mfa::confirm_totp_enrolment(user, code, Some(keep), state.database.clone()).await?
else {
return Ok(None);
};
record(
state,
request,
user,
CredentialChange::SecondFactorEnabled,
origin,
)
.await;
Ok(Some(codes))
}
pub(super) async fn disable_totp(
state: &AdminState,
request: &RequestContext,
user: &mut AdminUser,
password: &str,
keep: &str,
origin: Origin,
) -> Result<(), AdminError> {
if state.config.admin.require_mfa {
return Err(AdminError::conflict(
"mfa_required",
"admin.require_mfa is on: this server requires a second factor of every operator",
));
}
check_step_up(user, password, origin.client, &state.logins).await?;
mfa::disable_totp(user, Some(keep), state.database.clone()).await?;
record(
state,
request,
user,
CredentialChange::SecondFactorDisabled,
origin,
)
.await;
Ok(())
}
pub(super) async fn regenerate_recovery_codes(
state: &AdminState,
request: &RequestContext,
user: &AdminUser,
password: &str,
origin: Origin,
) -> Result<Vec<String>, AdminError> {
if !user.has_totp() {
return Err(AdminError::conflict(
"mfa_not_enabled",
"there is no second factor for these codes to recover access to",
));
}
check_step_up(user, password, origin.client, &state.logins).await?;
let codes = mfa::regenerate_recovery_codes(user, state.database.clone()).await?;
record(
state,
request,
user,
CredentialChange::RecoveryCodesRegenerated,
origin,
)
.await;
Ok(codes)
}
async fn record(
state: &AdminState,
request: &RequestContext,
user: &AdminUser,
change: CredentialChange,
origin: Origin,
) {
state
.record_credential_change(request, &user.username, user, change, true, origin.client)
.await;
}
}
pub(crate) async fn begin_totp_for(
state: &AdminState,
user: &mut acme_proxy_store::admin_user::AdminUser,
password: &str,
client: Option<std::net::IpAddr>,
) -> Result<crate::admin::totp::Enrolment, AdminError> {
actions::begin_totp(state, user, password, actions::Origin { client }).await
}
pub(crate) async fn confirm_totp_for(
state: &AdminState,
request: &acme_proxy_core::audit::RequestContext,
user: &mut acme_proxy_store::admin_user::AdminUser,
code: &str,
keep: &str,
client: Option<std::net::IpAddr>,
) -> Result<Option<Vec<String>>, AdminError> {
actions::confirm_totp(state, request, user, code, keep, actions::Origin { client }).await
}
pub(crate) async fn disable_totp_for(
state: &AdminState,
request: &acme_proxy_core::audit::RequestContext,
user: &mut acme_proxy_store::admin_user::AdminUser,
password: &str,
keep: &str,
client: Option<std::net::IpAddr>,
) -> Result<(), AdminError> {
actions::disable_totp(
state,
request,
user,
password,
keep,
actions::Origin { client },
)
.await
}
pub(crate) async fn regenerate_recovery_codes_for(
state: &AdminState,
request: &acme_proxy_core::audit::RequestContext,
user: &acme_proxy_store::admin_user::AdminUser,
password: &str,
client: Option<std::net::IpAddr>,
) -> Result<Vec<String>, AdminError> {
actions::regenerate_recovery_codes(state, request, user, password, actions::Origin { client })
.await
}
pub async fn get_mfa(
State(state): State<AdminState>,
auth: Authenticated,
) -> Result<Json<serde_json::Value>, AdminError> {
let remaining = mfa::recovery_codes_remaining(auth.user.id, state.database).await?;
Ok(Json(json!({
"totpEnabled": auth.user.has_totp(),
"enrolmentPending": auth.user.has_pending_totp(),
"recoveryCodesRemaining": remaining,
})))
}
pub async fn begin_totp(
State(state): State<AdminState>,
AdminClientIp(client): AdminClientIp,
enrol: EnrolWrite,
body: Option<Json<StepUpRequest>>,
) -> Result<Response, AdminError> {
let mut user = enrol.user;
let enrolment = actions::begin_totp(
&state,
&mut user,
&body.unwrap_or_default().password,
actions::Origin { client },
)
.await?;
Ok((
StatusCode::CREATED,
Json(json!({
"secret": enrolment.secret_base32,
"uri": enrolment.uri,
"algorithm": "SHA1",
"digits": totp::DIGITS,
"period": totp::PERIOD_SECONDS,
})),
)
.into_response())
}
pub async fn confirm_totp(
State(state): State<AdminState>,
AdminClientIp(client): AdminClientIp,
enrol: EnrolWrite,
request_context: acme_proxy_core::audit::RequestContext,
Json(body): Json<ConfirmRequest>,
) -> Result<Response, AdminError> {
let mut user = enrol.user;
let keep = enrol.session.token_hash.clone();
let Some(codes) = actions::confirm_totp(
&state,
&request_context,
&mut user,
&body.code,
&keep,
actions::Origin { client },
)
.await?
else {
return Err(AdminError::bad_request(
"that code does not match the pending enrolment",
));
};
let body = json!({ "recoveryCodes": codes });
if !enrol.pending {
return Ok((StatusCode::OK, Json(body)).into_response());
}
let (_, cookie) = finish_enrolment(
&state,
client,
&mut user,
&enrol.session.token_hash,
enrol.session.user_agent.clone(),
)
.await?;
Ok((StatusCode::OK, [(header::SET_COOKIE, cookie)], Json(body)).into_response())
}
pub async fn disable_totp(
State(state): State<AdminState>,
AdminClientIp(client): AdminClientIp,
SelfServiceWrite(auth): SelfServiceWrite,
request_context: acme_proxy_core::audit::RequestContext,
body: Option<Json<StepUpRequest>>,
) -> Result<Response, AdminError> {
let mut user = auth.user;
actions::disable_totp(
&state,
&request_context,
&mut user,
&body.unwrap_or_default().password,
&auth.session.token_hash,
actions::Origin { client },
)
.await?;
Ok(StatusCode::NO_CONTENT.into_response())
}
pub async fn regenerate_recovery_codes(
State(state): State<AdminState>,
AdminClientIp(client): AdminClientIp,
SelfServiceWrite(auth): SelfServiceWrite,
request_context: acme_proxy_core::audit::RequestContext,
body: Option<Json<StepUpRequest>>,
) -> Result<Json<serde_json::Value>, AdminError> {
let codes = actions::regenerate_recovery_codes(
&state,
&request_context,
&auth.user,
&body.unwrap_or_default().password,
actions::Origin { client },
)
.await?;
Ok(Json(json!({ "recoveryCodes": codes })))
}
#[cfg(test)]
mod tests {
use super::*;
use acme_proxy_store::admin_user::AdminUser;
fn user_with(password_hash: &str, totp: Option<&[u8]>) -> AdminUser {
AdminUser {
id: acme_proxy_store::testutil::ADMIN_FIXTURE_ID,
username: "alice".to_string(),
password_hash: password_hash.to_string(),
status: "active".to_string(),
role: None,
totp_secret: totp.map(<[u8]>::to_vec),
totp_pending_secret: None,
totp_last_step: None,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
last_login_at: None,
contact_email: None,
known_login_ips: Vec::new(),
}
}
use crate::webadmin::session::LoginLimiter;
use std::net::IpAddr;
const MAX_ATTEMPTS: u32 = 5;
fn limiter() -> LoginLimiter {
LoginLimiter::new(MAX_ATTEMPTS, 300)
}
fn client() -> Option<IpAddr> {
Some("198.51.100.7".parse().expect("a literal address"))
}
fn cheap_hash(password: &str) -> String {
crate::admin::password::hash_generated_secret(password)
}
#[tokio::test]
async fn a_factorless_operator_passes_without_a_password() {
let user = user_with("not-even-a-valid-hash", None);
let logins = limiter();
assert!(check_step_up(&user, "", client(), &logins).await.is_ok());
assert!(
check_step_up(&user, "anything", client(), &logins)
.await
.is_ok()
);
}
#[tokio::test]
async fn verify_current_password_runs_even_for_a_factorless_operator() {
let hash = cheap_hash("correct horse battery staple");
let user = user_with(&hash, None);
let logins = limiter();
assert!(
verify_current_password(&user, "correct horse battery staple", client(), &logins)
.await
.is_ok()
);
let error = verify_current_password(&user, "wrong", client(), &logins)
.await
.expect_err("a wrong password must refuse even with no factor enrolled");
assert_eq!(error.code, AdminError::invalid_credentials().code);
}
#[tokio::test]
async fn a_live_factor_needs_the_right_password() {
let hash = cheap_hash("correct horse battery staple");
let user = user_with(&hash, Some(b"secret"));
let logins = limiter();
assert!(
check_step_up(&user, "correct horse battery staple", client(), &logins)
.await
.is_ok()
);
for wrong in ["", "Correct horse battery staple", "wrong"] {
let Err(error) = check_step_up(&user, wrong, client(), &logins).await else {
panic!("{wrong:?} must be refused");
};
assert_eq!(error.code, AdminError::invalid_credentials().code);
assert_eq!(error.status, AdminError::invalid_credentials().status);
}
}
#[tokio::test]
async fn an_unreadable_stored_hash_refuses_rather_than_admits() {
let user = user_with("pbkdf2-sha256$not-a-number$salt$digest", Some(b"secret"));
let logins = limiter();
let error = check_step_up(&user, "anything", client(), &logins)
.await
.expect_err("a corrupt hash must refuse");
assert_eq!(error.code, AdminError::invalid_credentials().code);
}
#[tokio::test]
async fn a_wrong_password_counts_against_the_login_limiter() {
let hash = cheap_hash("correct horse battery staple");
let user = user_with(&hash, Some(b"secret"));
let logins = limiter();
for _ in 0..MAX_ATTEMPTS {
let error = check_step_up(&user, "wrong", client(), &logins)
.await
.expect_err("a wrong password must refuse");
assert_eq!(error.code, AdminError::invalid_credentials().code);
}
let error = check_step_up(&user, "correct horse battery staple", client(), &logins)
.await
.expect_err("the budget is spent, so even a correct password waits");
assert_eq!(error.status, StatusCode::TOO_MANY_REQUESTS);
}
#[tokio::test]
async fn a_correct_password_does_not_clear_the_bucket() {
let hash = cheap_hash("correct horse battery staple");
let user = user_with(&hash, Some(b"secret"));
let logins = limiter();
for _ in 0..MAX_ATTEMPTS - 1 {
assert!(
check_step_up(&user, "wrong", client(), &logins)
.await
.is_err()
);
}
assert!(
check_step_up(&user, "correct horse battery staple", client(), &logins)
.await
.is_ok()
);
assert!(
check_step_up(&user, "wrong", client(), &logins)
.await
.is_err()
);
let error = check_step_up(&user, "wrong", client(), &logins)
.await
.expect_err("the budget survives a correct password");
assert_eq!(error.status, StatusCode::TOO_MANY_REQUESTS);
}
#[tokio::test]
async fn a_factorless_operator_never_touches_the_limiter() {
let user = user_with("not-even-a-valid-hash", None);
let logins = limiter();
for _ in 0..MAX_ATTEMPTS + 1 {
assert!(
check_step_up(&user, "anything", client(), &logins)
.await
.is_ok()
);
}
assert!(logins.begin(client()).is_ok());
}
#[tokio::test]
async fn an_unreadable_stored_hash_does_not_lock_the_address_out() {
let user = user_with("pbkdf2-sha256$not-a-number$salt$digest", Some(b"secret"));
let logins = limiter();
for _ in 0..MAX_ATTEMPTS + 1 {
assert!(
check_step_up(&user, "anything", client(), &logins)
.await
.is_err()
);
}
assert!(logins.begin(client()).is_ok());
}
}