use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use serde_json::{Map, Value, json};
use crate::admin;
use crate::admin::{mfa, totp};
use crate::webadmin::AdminState;
use crate::webadmin::error::AdminError;
use crate::webadmin::handlers::Caller;
use crate::webadmin::handlers::account::apply_revoke_own_session;
use crate::webadmin::handlers::mfa::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};
use acme_proxy_store::admin_session::AdminSession;
use acme_proxy_store::admin_user::AdminUser;
#[derive(Debug, Deserialize)]
pub struct ConfirmForm {
pub code: String,
}
#[derive(Debug, Default, Deserialize)]
pub struct StepUpForm {
#[serde(default)]
pub password: String,
}
async fn refuse_on_the_card(
state: &AdminState,
user: &AdminUser,
csrf_token: &str,
error: &AdminError,
) -> Result<Response, PageError> {
let context = card_context(state, user, csrf_token).await?;
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;
let enrolment = match crate::webadmin::handlers::mfa::begin_totp_for(
&state,
&mut user,
&body.password,
client,
)
.await
{
Ok(enrolment) => enrolment,
Err(error) => {
return refuse_on_the_card(&state, &user, &session.enrol.session.csrf_token, &error)
.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,
session: PageEnrolWrite,
request_context: acme_proxy_core::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) = crate::webadmin::handlers::mfa::confirm_totp_for(
&state,
&request_context,
&mut user,
&body.code,
&keep,
client,
)
.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: PageSelfServiceWrite,
request_context: acme_proxy_core::audit::RequestContext,
axum::Form(body): axum::Form<StepUpForm>,
) -> Result<Response, PageError> {
let mut user = session.auth.user;
if let Err(error) = crate::webadmin::handlers::mfa::disable_totp_for(
&state,
&request_context,
&mut user,
&body.password,
&session.auth.session.token_hash,
client,
)
.await
{
return refuse_on_the_card(&state, &user, &session.auth.session.csrf_token, &error).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: PageSelfServiceWrite,
request_context: acme_proxy_core::audit::RequestContext,
axum::Form(body): axum::Form<StepUpForm>,
) -> Result<Response, PageError> {
let codes = match crate::webadmin::handlers::mfa::regenerate_recovery_codes_for(
&state,
&request_context,
&session.auth.user,
&body.password,
client,
)
.await
{
Ok(codes) => codes,
Err(error) => {
return refuse_on_the_card(
&state,
&session.auth.user,
&session.auth.session.csrf_token,
&error,
)
.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,
}
#[derive(Debug, Default, Deserialize)]
pub struct ContactForm {
#[serde(default)]
pub current_password: String,
#[serde(default)]
pub contact: String,
}
fn contact_card_context(
csrf_token: &str,
user: &AdminUser,
typed: Option<&str>,
) -> Map<String, Value> {
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),
);
if let Some(typed) = typed {
context.insert(
"contact_input".to_string(),
Value::String(typed.to_string()),
);
}
context
}
pub async fn change_contact(
State(state): State<AdminState>,
AdminClientIp(client): AdminClientIp,
session: PageSelfServiceWrite,
request_context: acme_proxy_core::audit::RequestContext,
axum::Form(form): axum::Form<ContactForm>,
) -> Result<Response, PageError> {
let caller = session.auth.user.clone();
let csrf_token = session.auth.session.csrf_token.clone();
if let Err(error) =
verify_current_password(&caller, &form.current_password, client, &state.logins).await
{
return super::refuse_with_card(
&state,
"account/_contact.html",
contact_card_context(&csrf_token, &caller, Some(&form.contact)),
&error,
);
}
let mut target = caller.clone();
if let Err(error) = crate::webadmin::handlers::operators::apply_contact_change(
&state,
&caller,
&mut target,
Some(form.contact.as_str()),
client,
&request_context,
"ui",
)
.await
{
if error.status.is_server_error() {
return Err(error.into());
}
return super::refuse_with_card(
&state,
"account/_contact.html",
contact_card_context(&csrf_token, &caller, Some(&form.contact)),
&error,
);
}
let mut context = contact_card_context(&csrf_token, &target, None);
let banner = match &target.contact_email {
Some(address) => super::flash("ok", format!("Security notifications now go to {address}.")),
None => super::flash(
"warn",
"No address is on file: security notifications cannot reach you.",
),
};
context.insert("flash".to_string(), banner);
Ok(respond_fragment(&state, "account/_contact.html", context)?.into_response())
}
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
}
pub async fn change_password(
State(state): State<AdminState>,
AdminClientIp(client): AdminClientIp,
session: PageSelfServiceWrite,
request_context: acme_proxy_core::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();
let mut fragment_context = password_card_context(&csrf_token);
if let Err(error) = crate::webadmin::handlers::account::change_own_password_for(
&state,
&request_context,
&mut user,
&body.current_password,
&body.new_password,
&session.auth.session.token_hash,
client,
)
.await
{
return super::refuse_with_card(&state, "account/_password.html", fragment_context, &error);
}
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())
}
pub async fn revoke_own_session(
State(state): State<AdminState>,
Path(id): Path<String>,
session: PageSelfServiceWrite,
request_context: acme_proxy_core::audit::RequestContext,
) -> Result<Response, PageError> {
let was_current =
apply_revoke_own_session(&state, &Caller::ui(&session.auth, &request_context), &id).await?;
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())
}
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)
}