use axum::Form;
use axum::extract::{Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde_json::{Map, Value};
use crate::admin::mfa;
use crate::webadmin::AdminState;
use crate::webadmin::handlers::Caller;
use crate::webadmin::handlers::session::apply_logout;
use crate::webadmin::handlers::session::{
LoginRequest, LogoutQuery, MfaRequest, finish_enrolment, finish_mfa, sign_in,
};
use crate::webadmin::pages::auth::{PageMfaPending, PageMfaSubmit, PageSelfServiceWrite};
use crate::webadmin::pages::error::{LOGIN_PATH, PageError, redirect};
use crate::webadmin::pages::templates;
use crate::webadmin::session::{AdminClientIp, MfaStep, PendingMfa, clearing_cookie};
const PANEL_PATH: &str = "/ui/";
pub(crate) const MFA_PATH: &str = "/ui/login/mfa";
pub async fn get_login(State(state): State<AdminState>) -> Result<Response, PageError> {
Ok(page(&state, None, None)?.into_response())
}
pub async fn post_login(
State(state): State<AdminState>,
AdminClientIp(client): AdminClientIp,
headers: HeaderMap,
Form(credentials): Form<LoginRequest>,
) -> Result<Response, PageError> {
match sign_in(&state, client, &headers, &credentials).await {
Ok(signed_in) => Ok((
StatusCode::SEE_OTHER,
[
(
header::LOCATION,
if signed_in.pending.is_some() {
MFA_PATH.to_string()
} else {
PANEL_PATH.to_string()
},
),
(header::SET_COOKIE, signed_in.cookie),
],
)
.into_response()),
Err(error) => {
let status = error.status;
let flash = super::flash_error(error.code, error.message);
Ok((
status,
page(&state, Some(flash), Some(&credentials.username))?,
)
.into_response())
}
}
}
pub async fn get_login_mfa(
State(state): State<AdminState>,
session: PageMfaPending,
) -> Result<Response, PageError> {
Ok(challenge(&state, session.pending, None)
.await?
.into_response())
}
pub async fn post_login_mfa(
State(state): State<AdminState>,
AdminClientIp(client): AdminClientIp,
request_context: acme_proxy_core::audit::RequestContext,
session: PageMfaSubmit,
Form(body): Form<MfaRequest>,
) -> Result<Response, PageError> {
if session.pending.step == MfaStep::Enrol {
return confirm_enrolment(
&state,
client,
&request_context,
session.pending,
&body.code,
)
.await;
}
let step = session.pending.step;
let expires_at = session.pending.session.expires_at;
match finish_mfa(&state, client, session.pending, &body.code).await {
Ok(signed_in) => Ok((
StatusCode::SEE_OTHER,
[
(header::LOCATION, PANEL_PATH.to_string()),
(header::SET_COOKIE, signed_in.cookie),
],
)
.into_response()),
Err(error) => {
let status = error.status;
let flash = super::flash_error(error.code, error.message);
let mut context = Map::new();
context.insert("step".to_string(), Value::String(step.as_str().to_string()));
context.insert(
"expiresAt".to_string(),
Value::String(acme_proxy_core::datetime::rfc3339(expires_at)),
);
context.insert("flash".to_string(), flash);
Ok((status, render_challenge(&state, context)?).into_response())
}
}
}
async fn confirm_enrolment(
state: &AdminState,
client: Option<std::net::IpAddr>,
request_context: &acme_proxy_core::audit::RequestContext,
pending: PendingMfa,
code: &str,
) -> Result<Response, PageError> {
let mut user = pending.user;
let keep = pending.session.token_hash.clone();
let Some(codes) =
mfa::confirm_totp_enrolment(&mut user, code, Some(&keep), state.database.clone()).await?
else {
let mut context = Map::new();
context.insert("step".to_string(), Value::String("enrol".to_string()));
context.insert(
"flash".to_string(),
super::flash_error("bad_request", "That code did not match. Try the next one."),
);
context.insert(
"enrolment".to_string(),
enrolment_context(state, &mut user).await?,
);
return Ok((StatusCode::UNAUTHORIZED, render_challenge(state, context)?).into_response());
};
state
.record_credential_change(
request_context,
&user.username,
&user,
crate::webadmin::CredentialChange::SecondFactorEnabled,
true,
client,
)
.await;
let (_, cookie) = finish_enrolment(
state,
client,
&mut user,
&pending.session.token_hash,
pending.session.user_agent.clone(),
)
.await?;
let mut context = Map::new();
context.insert("recovery_codes".to_string(), serde_json::json!(codes));
let body = templates::render(
&state.templates,
"mfa/enrolled.html",
minijinja::Value::from_serialize(Value::Object(context)),
)?;
let mut response = (StatusCode::OK, body).into_response();
if let Ok(value) = header::HeaderValue::from_str(&cookie) {
response.headers_mut().insert(header::SET_COOKIE, value);
}
Ok(response)
}
pub async fn post_logout(
State(state): State<AdminState>,
Query(query): Query<LogoutQuery>,
session: PageSelfServiceWrite,
request_context: acme_proxy_core::audit::RequestContext,
) -> Result<Response, PageError> {
apply_logout(
&state,
&Caller::ui(&session.auth, &request_context),
query.all,
)
.await?;
let mut response = redirect(LOGIN_PATH, session.hx);
if let Ok(value) = header::HeaderValue::from_str(&clearing_cookie()) {
response.headers_mut().insert(header::SET_COOKIE, value);
}
Ok(response)
}
fn page(
state: &AdminState,
flash: Option<Value>,
username: Option<&str>,
) -> Result<axum::response::Html<String>, PageError> {
let mut context = Map::new();
if let Some(flash) = flash {
context.insert("flash".to_string(), flash);
}
if let Some(username) = username {
context.insert("username".to_string(), Value::String(username.to_string()));
}
templates::render(
&state.templates,
"login.html",
minijinja::Value::from_serialize(Value::Object(context)),
)
}
async fn challenge(
state: &AdminState,
pending: PendingMfa,
flash: Option<Value>,
) -> Result<axum::response::Html<String>, PageError> {
let mut context = Map::new();
context.insert(
"step".to_string(),
Value::String(pending.step.as_str().to_string()),
);
context.insert(
"expiresAt".to_string(),
Value::String(acme_proxy_core::datetime::rfc3339(
pending.session.expires_at,
)),
);
if let Some(flash) = flash {
context.insert("flash".to_string(), flash);
}
if pending.step == MfaStep::Enrol {
let mut user = pending.user;
context.insert(
"enrolment".to_string(),
enrolment_context(state, &mut user).await?,
);
}
render_challenge(state, context)
}
async fn enrolment_context(
state: &AdminState,
user: &mut acme_proxy_store::admin_user::AdminUser,
) -> Result<Value, PageError> {
let enrolment = mfa::resume_or_begin_totp_enrolment(
user,
&state.config.admin.base_url,
state.database.clone(),
)
.await?;
Ok(serde_json::json!({
"secret": enrolment.secret_base32,
"uri": enrolment.uri,
"algorithm": "SHA1",
"digits": crate::admin::totp::DIGITS,
"period": crate::admin::totp::PERIOD_SECONDS,
}))
}
fn render_challenge(
state: &AdminState,
context: Map<String, Value>,
) -> Result<axum::response::Html<String>, PageError> {
templates::render(
&state.templates,
"mfa/challenge.html",
minijinja::Value::from_serialize(Value::Object(context)),
)
}