use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use serde_json::{Map, Value, json};
use crate::admin;
use crate::admin::password::PasswordContext;
use crate::admin::users::{self, UserError};
use crate::admin::{mfa, totp};
use crate::sqlite::admin_session::AdminSession;
use crate::sqlite::admin_user::AdminUser;
use crate::webadmin::AdminState;
use crate::webadmin::handlers::mfa::{check_step_up, verify_current_password};
use crate::webadmin::handlers::paging::PageParams;
use crate::webadmin::pages::auth::{PageEnrolWrite, PageSelfServiceWrite, PageSession};
use crate::webadmin::pages::error::PageError;
use crate::webadmin::pages::{chrome, respond, respond_fragment};
use crate::webadmin::session::{AdminClientIp, clearing_cookie};
#[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 context = card_context(state, user, csrf_token).await?;
Ok(Some(super::refuse_with_card(
state,
"account/_mfa.html",
context,
&error,
)?))
}
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));
context.insert(
"min_password_length".to_string(),
json!(crate::admin::password::MIN_PASSWORD_LEN),
);
insert_own_sessions(&mut context, &state, &session.auth).await?;
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>,
AdminClientIp(client): AdminClientIp,
headers: HeaderMap,
session: PageEnrolWrite,
request_context: crate::audit::RequestContext,
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());
};
state
.record_credential_change(
&request_context,
&user.username,
&user,
crate::notify::AdminCredentialChange::SecondFactorEnabled,
true,
client,
crate::webadmin::user_agent_of(&headers),
)
.await;
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,
headers: HeaderMap,
session: PageSelfServiceWrite,
request_context: crate::audit::RequestContext,
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?;
state
.record_credential_change(
&request_context,
&user.username,
&user,
crate::notify::AdminCredentialChange::SecondFactorDisabled,
true,
client,
crate::webadmin::user_agent_of(&headers),
)
.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,
headers: HeaderMap,
session: PageSelfServiceWrite,
request_context: crate::audit::RequestContext,
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?;
state
.record_credential_change(
&request_context,
&session.auth.user.username,
&session.auth.user,
crate::notify::AdminCredentialChange::RecoveryCodesRegenerated,
true,
client,
crate::webadmin::user_agent_of(&headers),
)
.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())
}
#[derive(Debug, Deserialize)]
pub struct ChangePasswordForm {
pub current_password: String,
pub new_password: String,
}
fn password_card_context(csrf_token: &str) -> Map<String, Value> {
let mut context = Map::new();
context.insert(
"csrf_token".to_string(),
Value::String(csrf_token.to_string()),
);
context.insert(
"min_password_length".to_string(),
json!(crate::admin::password::MIN_PASSWORD_LEN),
);
context
}
async fn refuse_without_current_password(
state: &AdminState,
csrf_token: &str,
user: &AdminUser,
password: &str,
client: Option<std::net::IpAddr>,
) -> Result<Option<Response>, PageError> {
let Err(error) = verify_current_password(user, password, client, &state.logins) else {
return Ok(None);
};
Ok(Some(super::refuse_with_card(
state,
"account/_password.html",
password_card_context(csrf_token),
&error,
)?))
}
pub async fn change_password(
State(state): State<AdminState>,
AdminClientIp(client): AdminClientIp,
headers: HeaderMap,
session: PageSelfServiceWrite,
request_context: crate::audit::RequestContext,
axum::Form(body): axum::Form<ChangePasswordForm>,
) -> Result<Response, PageError> {
let mut user = session.auth.user;
let csrf_token = session.auth.session.csrf_token.clone();
if let Some(refusal) =
refuse_without_current_password(&state, &csrf_token, &user, &body.current_password, client)
.await?
{
return Ok(refusal);
}
let context = PasswordContext::from_config(&state.config, &user.username);
let mut fragment_context = password_card_context(&csrf_token);
match users::change_own_password(
&mut user,
&body.new_password,
&context,
&session.auth.session.token_hash,
state.database.clone(),
)
.await
{
Ok(()) => {
state
.record_credential_change(
&request_context,
&user.username,
&user,
crate::notify::AdminCredentialChange::Password,
true,
client,
crate::webadmin::user_agent_of(&headers),
)
.await;
fragment_context.insert(
"flash".to_string(),
super::flash(
"ok",
"Your password was changed. Every other session of yours was signed out.",
),
);
Ok(
respond_fragment(&state, "account/_password.html", fragment_context)?
.into_response(),
)
}
Err(UserError::Policy(message)) => {
fragment_context.insert(
"flash".to_string(),
super::flash_error("bad_request", message),
);
Ok((
StatusCode::BAD_REQUEST,
respond_fragment(&state, "account/_password.html", fragment_context)?,
)
.into_response())
}
Err(UserError::Database(error)) => Err(error.into()),
Err(UserError::DuplicateUsername(_) | UserError::InvalidContact(_)) => {
Err(PageError::internal())
}
}
}
pub async fn revoke_own_session(
State(state): State<AdminState>,
Path(id): Path<String>,
session: PageSelfServiceWrite,
request_context: crate::audit::RequestContext,
) -> Result<Response, PageError> {
let target =
AdminSession::find_by_user_and_fingerprint(session.auth.user.id, &id, &state.database)
.await?
.ok_or_else(|| session_not_found(&id))?;
let was_current = target.token_hash == session.auth.session.token_hash;
AdminSession::delete(&target.token_hash, &state.database).await?;
let scope = if was_current {
crate::audit::admin::SessionScope::OwnCurrent
} else {
crate::audit::admin::SessionScope::OwnOther
};
state
.record_admin_action(
&request_context,
&session.auth.user.username,
|actor, ctx| crate::audit::admin::session_revoked(actor, ctx, scope, 1),
)
.await;
tracing::info!(event = "admin_session_revoked",
outcome = "success",
surface = "ui",
scope = "self",
username = %session.auth.user.username,
session_fp = %id);
if was_current {
let mut response = crate::webadmin::pages::error::redirect(
crate::webadmin::pages::error::LOGIN_PATH,
session.hx,
);
if let Ok(value) = axum::http::HeaderValue::from_str(&clearing_cookie()) {
response
.headers_mut()
.insert(axum::http::header::SET_COOKIE, value);
}
return Ok(response);
}
let mut context = Map::new();
context.insert(
"csrf_token".to_string(),
Value::String(session.auth.session.csrf_token.clone()),
);
insert_own_sessions(&mut context, &state, &session.auth).await?;
context.insert("flash".to_string(), super::flash("ok", "Session revoked."));
Ok(respond_fragment(&state, "account/_sessions.html", context)?.into_response())
}
fn session_not_found(id: &str) -> PageError {
PageError::not_found(format!("no such session: {id}"))
}
async fn insert_own_sessions(
context: &mut Map<String, Value>,
state: &AdminState,
auth: &crate::webadmin::session::Authenticated,
) -> Result<(), PageError> {
let page = PageParams::default().resolve(&state.config);
let (sessions, _total) =
AdminSession::search(Some(auth.user.id), page.limit, page.offset, &state.database).await?;
let rows: Vec<Value> = sessions
.iter()
.map(|s| admin::render_admin_session_detail_json(s, &auth.session.token_hash))
.collect();
context.insert("sessions".to_string(), Value::Array(rows));
context.insert(
"sessions_revoke_prefix".to_string(),
Value::String("/ui/account/sessions".to_string()),
);
context.insert(
"sessions_target".to_string(),
Value::String("#account-sessions".to_string()),
);
Ok(())
}
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)
}