use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use serde_json::{Map, Value, json};
use crate::admin::{mfa, totp};
use crate::sqlite::admin_user::AdminUser;
use crate::webadmin::AdminState;
use crate::webadmin::handlers::mfa::check_step_up;
use crate::webadmin::pages::auth::{PageEnrolWrite, PageSession, PageSessionWrite};
use crate::webadmin::pages::error::PageError;
use crate::webadmin::pages::{chrome, respond, respond_fragment};
use crate::webadmin::session::AdminClientIp;
#[derive(Debug, Deserialize)]
pub struct ConfirmForm {
pub code: String,
}
#[derive(Debug, Default, Deserialize)]
pub struct StepUpForm {
#[serde(default)]
pub password: String,
}
async fn refuse_without_password(
state: &AdminState,
user: &AdminUser,
csrf_token: &str,
password: &str,
client: Option<std::net::IpAddr>,
) -> Result<Option<Response>, PageError> {
let Err(error) = check_step_up(user, password, client, &state.logins) else {
return Ok(None);
};
let message = if error.status == StatusCode::UNAUTHORIZED {
"That password is not correct.".to_string()
} else {
error.message.clone()
};
let mut context = card_context(state, user, csrf_token).await?;
context.insert("flash".to_string(), super::flash_error(error.code, message));
Ok(Some(
(
error.status,
respond_fragment(state, "account/_mfa.html", context)?,
)
.into_response(),
))
}
pub async fn get_account(
State(state): State<AdminState>,
session: PageSession,
) -> Result<Response, PageError> {
let mut context = chrome(&session, "account", "Your account");
context.insert("mfa".to_string(), status(&state, &session.auth.user).await?);
context.insert(
"require_mfa".to_string(),
Value::Bool(state.config.admin.require_mfa),
);
context.insert("period".to_string(), json!(totp::PERIOD_SECONDS));
Ok(respond(
&state,
session.hx,
"account/index.html",
"account/_mfa.html",
context,
)?
.into_response())
}
pub async fn begin_totp(
State(state): State<AdminState>,
AdminClientIp(client): AdminClientIp,
session: PageEnrolWrite,
axum::Form(body): axum::Form<StepUpForm>,
) -> Result<Response, PageError> {
let mut user = session.enrol.user;
if let Some(refusal) = refuse_without_password(
&state,
&user,
&session.enrol.session.csrf_token,
&body.password,
client,
)
.await?
{
return Ok(refusal);
}
let enrolment = mfa::resume_or_begin_totp_enrolment(
&mut user,
&state.config.admin.base_url,
state.database.clone(),
)
.await?;
let mut context = Map::new();
context.insert(
"csrf_token".to_string(),
Value::String(session.enrol.session.csrf_token.clone()),
);
context.insert(
"enrolment".to_string(),
json!({
"secret": enrolment.secret_base32,
"uri": enrolment.uri,
"algorithm": "SHA1",
"digits": totp::DIGITS,
"period": totp::PERIOD_SECONDS,
}),
);
Ok(respond_fragment(&state, "account/_enrol.html", context)?.into_response())
}
pub async fn confirm_totp(
State(state): State<AdminState>,
session: PageEnrolWrite,
axum::Form(body): axum::Form<ConfirmForm>,
) -> Result<Response, PageError> {
let mut user = session.enrol.user;
let keep = session.enrol.session.token_hash.clone();
let Some(codes) =
mfa::confirm_totp_enrolment(&mut user, &body.code, Some(&keep), state.database.clone())
.await?
else {
let enrolment = mfa::resume_or_begin_totp_enrolment(
&mut user,
&state.config.admin.base_url,
state.database.clone(),
)
.await?;
let mut context = Map::new();
context.insert(
"csrf_token".to_string(),
Value::String(session.enrol.session.csrf_token.clone()),
);
context.insert(
"enrolment".to_string(),
json!({
"secret": enrolment.secret_base32,
"uri": enrolment.uri,
"algorithm": "SHA1",
"digits": totp::DIGITS,
"period": totp::PERIOD_SECONDS,
}),
);
context.insert(
"flash".to_string(),
super::flash_error("bad_request", "That code did not match. Try the next one."),
);
return Ok((
StatusCode::BAD_REQUEST,
respond_fragment(&state, "account/_enrol.html", context)?,
)
.into_response());
};
let mut context = card_context(&state, &user, &session.enrol.session.csrf_token).await?;
context.insert("recovery_codes".to_string(), json!(codes));
Ok(respond_fragment(&state, "account/_codes.html", context)?.into_response())
}
pub async fn disable_totp(
State(state): State<AdminState>,
AdminClientIp(client): AdminClientIp,
session: PageSessionWrite,
axum::Form(body): axum::Form<StepUpForm>,
) -> Result<Response, PageError> {
if state.config.admin.require_mfa {
let mut context =
card_context(&state, &session.auth.user, &session.auth.session.csrf_token).await?;
context.insert(
"flash".to_string(),
super::flash_error(
"mfa_required",
"This server requires a second factor of every operator.",
),
);
return Ok((
StatusCode::CONFLICT,
respond_fragment(&state, "account/_mfa.html", context)?,
)
.into_response());
}
let mut user = session.auth.user;
if let Some(refusal) = refuse_without_password(
&state,
&user,
&session.auth.session.csrf_token,
&body.password,
client,
)
.await?
{
return Ok(refusal);
}
mfa::disable_totp(
&mut user,
Some(&session.auth.session.token_hash),
state.database.clone(),
)
.await?;
let mut context = card_context(&state, &user, &session.auth.session.csrf_token).await?;
context.insert(
"flash".to_string(),
super::flash(
"warn",
"Two-factor authentication is off. Your recovery codes were destroyed \
and every other session of yours was signed out.",
),
);
Ok(respond_fragment(&state, "account/_mfa.html", context)?.into_response())
}
pub async fn regenerate_recovery_codes(
State(state): State<AdminState>,
AdminClientIp(client): AdminClientIp,
session: PageSessionWrite,
axum::Form(body): axum::Form<StepUpForm>,
) -> Result<Response, PageError> {
if !session.auth.user.has_totp() {
let mut context =
card_context(&state, &session.auth.user, &session.auth.session.csrf_token).await?;
context.insert(
"flash".to_string(),
super::flash_error(
"mfa_not_enabled",
"There is no second factor for these codes to recover access to.",
),
);
return Ok((
StatusCode::CONFLICT,
respond_fragment(&state, "account/_mfa.html", context)?,
)
.into_response());
}
if let Some(refusal) = refuse_without_password(
&state,
&session.auth.user,
&session.auth.session.csrf_token,
&body.password,
client,
)
.await?
{
return Ok(refusal);
}
let codes = mfa::regenerate_recovery_codes(&session.auth.user, state.database.clone()).await?;
let mut context =
card_context(&state, &session.auth.user, &session.auth.session.csrf_token).await?;
context.insert("recovery_codes".to_string(), json!(codes));
Ok(respond_fragment(&state, "account/_codes.html", context)?.into_response())
}
async fn status(state: &AdminState, user: &AdminUser) -> Result<Value, PageError> {
let remaining = mfa::recovery_codes_remaining(&user.id, state.database.clone()).await?;
Ok(json!({
"totpEnabled": user.has_totp(),
"enrolmentPending": user.has_pending_totp(),
"recoveryCodesRemaining": remaining,
}))
}
async fn card_context(
state: &AdminState,
user: &AdminUser,
csrf_token: &str,
) -> Result<Map<String, Value>, PageError> {
let mut context = Map::new();
context.insert(
"csrf_token".to_string(),
Value::String(csrf_token.to_string()),
);
context.insert(
"user".to_string(),
crate::admin::render_admin_user_json(user),
);
context.insert("mfa".to_string(), status(state, user).await?);
context.insert(
"require_mfa".to_string(),
Value::Bool(state.config.admin.require_mfa),
);
context.insert("period".to_string(), json!(totp::PERIOD_SECONDS));
Ok(context)
}