use axum::Json;
use axum::extract::{Query, State};
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use serde_json::json;
use std::time::Duration;
use tracing::{info, warn};
use crate::admin::mfa::MfaOutcome;
use crate::admin::users::{self, AuthOutcome};
use crate::webadmin::AdminState;
use crate::webadmin::error::AdminError;
use crate::webadmin::handlers::Caller;
use crate::webadmin::session::{
AdminClientIp, Authenticated, MfaStep, PENDING_MFA_TTL, PendingMfa, PendingMfaSubmit,
SelfServiceWrite, check_origin, clearing_cookie, cookie_value, hash_token, log_login,
mint_csrf_token, mint_token, session_cookie,
};
use acme_proxy_store::admin_session::AdminSession;
use acme_proxy_store::admin_session::NewSession;
use acme_proxy_store::admin_user::AdminUser;
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Debug, Deserialize, Default)]
pub struct LogoutQuery {
#[serde(default)]
pub all: bool,
}
#[derive(Debug, Deserialize)]
pub struct MfaRequest {
pub code: String,
}
pub(crate) struct SignedIn {
pub user: AdminUser,
pub session: AdminSession,
pub cookie: String,
pub pending: Option<MfaStep>,
}
pub(crate) async fn sign_in(
state: &AdminState,
client: Option<std::net::IpAddr>,
headers: &axum::http::HeaderMap,
credentials: &LoginRequest,
) -> Result<SignedIn, AdminError> {
let body = credentials;
check_origin(headers, &state.config.admin.base_url)?;
let attempt = match state.logins.begin(client) {
Ok(attempt) => attempt,
Err(retry_after) => {
log_login(false, &body.username, client, "rate_limited");
return Err(AdminError::rate_limited(retry_after));
}
};
let outcome =
users::authenticate(&body.username, &body.password, state.database.clone()).await?;
let refused = match outcome {
AuthOutcome::Authenticated(user) => Ok(*user),
AuthOutcome::UnknownUser => Err("unknown_user"),
AuthOutcome::WrongPassword(_) => Err("wrong_password"),
AuthOutcome::Disabled(_) => Err("account_disabled"),
};
let mut user = match refused {
Ok(user) => user,
Err(reason) => {
attempt.failed();
log_login(false, &body.username, client, reason);
return Err(AdminError::invalid_credentials());
}
};
let step = if user.has_totp() {
Some(MfaStep::Verify)
} else if state.config.admin.require_mfa {
Some(MfaStep::Enrol)
} else {
None
};
if let Some(existing) = cookie_value(headers) {
AdminSession::delete(&hash_token(&existing), &state.database).await?;
}
let minted = mint_token();
let csrf_token = mint_csrf_token();
let created_ip = client.map(|ip| ip.to_string());
let user_agent = crate::webadmin::user_agent_of(headers);
let Some(step) = step else {
let ttl = Duration::from_secs(state.config.admin.session_ttl_seconds);
let session = AdminSession::create(
NewSession {
user_id: user.id,
token_hash: &minted.token_hash,
csrf_token: &csrf_token,
created_ip,
user_agent: user_agent.clone(),
},
ttl,
&state.database,
)
.await?;
let known_before = user.known_login_ips.clone();
user.mark_logged_in(client_ip_str(client).as_deref(), &state.database)
.await?;
state.logins.record_success(client);
log_login(true, &user.username, client, "");
notify_sign_in_from_new_address(state, &user, &known_before, client, user_agent).await;
return Ok(SignedIn {
user,
session,
cookie: session_cookie(&minted.token, ttl),
pending: None,
});
};
let session = AdminSession::create_pending(
NewSession {
user_id: user.id,
token_hash: &minted.token_hash,
csrf_token: &csrf_token,
created_ip,
user_agent,
},
PENDING_MFA_TTL,
&state.database,
)
.await?;
info!(event = "admin_login_mfa_pending",
outcome = "success",
username = %user.username,
client_ip = ?client,
step = step.as_str());
Ok(SignedIn {
user,
session,
cookie: session_cookie(&minted.token, PENDING_MFA_TTL),
pending: Some(step),
})
}
pub(crate) async fn finish_mfa(
state: &AdminState,
client: Option<std::net::IpAddr>,
pending: PendingMfa,
submitted: &str,
) -> Result<SignedIn, AdminError> {
let attempt = match state.logins.begin(client) {
Ok(attempt) => attempt,
Err(retry_after) => {
log_login(false, &pending.user.username, client, "rate_limited");
return Err(AdminError::rate_limited(retry_after));
}
};
let mut user = pending.user;
let outcome =
crate::admin::mfa::verify_second_factor(&mut user, submitted, state.database.clone())
.await?;
let MfaOutcome::Accepted { via, .. } = outcome else {
attempt.failed();
let spent = AdminSession::record_mfa_failure(&pending.session.token_hash, &state.database)
.await?
.is_some_and(|attempts| attempts >= i64::from(state.config.admin.login_max_attempts));
if spent {
AdminSession::delete(&pending.session.token_hash, &state.database).await?;
warn!(event = "admin_mfa_attempts_exhausted",
outcome = "failure",
username = %user.username,
client_ip = ?client,
max_attempts = state.config.admin.login_max_attempts);
}
warn!(event = "admin_mfa_failed",
outcome = "failure",
username = %user.username,
client_ip = ?client,
reason = outcome.reason());
let user_agent = pending.session.user_agent.clone();
notify_sign_in(
state,
&user,
acme_proxy_jobs::notify::AdminSignInOutcome::SecondFactorRefused,
client,
user_agent.clone(),
)
.await;
if spent {
notify_sign_in(
state,
&user,
acme_proxy_jobs::notify::AdminSignInOutcome::LockedOut,
client,
user_agent,
)
.await;
}
return Err(AdminError::invalid_credentials());
};
let (session, cookie) = promote_pending(state, &pending.session.token_hash).await?;
let known_before = user.known_login_ips.clone();
user.mark_logged_in(client_ip_str(client).as_deref(), &state.database)
.await?;
state.logins.record_success(client);
info!(event = "admin_mfa_verified",
outcome = "success",
username = %user.username,
method = via.as_str());
log_login(true, &user.username, client, "");
notify_sign_in_from_new_address(
state,
&user,
&known_before,
client,
pending.session.user_agent.clone(),
)
.await;
Ok(SignedIn {
user,
session,
cookie,
pending: None,
})
}
pub(crate) async fn promote_pending(
state: &AdminState,
pending_token_hash: &str,
) -> Result<(AdminSession, String), AdminError> {
let ttl = Duration::from_secs(state.config.admin.session_ttl_seconds);
let minted = mint_token();
let csrf_token = mint_csrf_token();
let Some(session) = AdminSession::promote(
pending_token_hash,
&minted.token_hash,
&csrf_token,
ttl,
&state.database,
)
.await?
else {
return Err(AdminError::session_invalid());
};
Ok((session, session_cookie(&minted.token, ttl)))
}
pub(crate) async fn finish_enrolment(
state: &AdminState,
client: Option<std::net::IpAddr>,
user: &mut acme_proxy_store::admin_user::AdminUser,
pending_token_hash: &str,
user_agent: Option<String>,
) -> Result<(AdminSession, String), AdminError> {
let (session, cookie) = promote_pending(state, pending_token_hash).await?;
let known_before = user.known_login_ips.clone();
user.mark_logged_in(client_ip_str(client).as_deref(), &state.database)
.await?;
state.logins.record_success(client);
info!(event = "admin_mfa_enrolled", outcome = "success", username = %user.username);
log_login(true, &user.username, client, "");
notify_sign_in_from_new_address(state, user, &known_before, client, user_agent).await;
Ok((session, cookie))
}
fn client_ip_str(client: Option<std::net::IpAddr>) -> Option<String> {
client.map(|ip| ip.to_string())
}
async fn notify_sign_in(
state: &AdminState,
user: &AdminUser,
outcome: acme_proxy_jobs::notify::AdminSignInOutcome,
client: Option<std::net::IpAddr>,
user_agent: Option<String>,
) {
state
.notify_security(acme_proxy_jobs::notify::NotifyEvent::AdminSignIn(
acme_proxy_jobs::notify::AdminSignInData {
profile: acme_proxy_jobs::notify::ADMIN_DISPATCHER_KEY.to_string(),
username: user.username.clone(),
recipient: user.contact_email.clone(),
outcome,
client_ip: client_ip_str(client),
user_agent,
at: acme_proxy_store::nonce::now_secs(),
},
))
.await;
}
async fn notify_sign_in_from_new_address(
state: &AdminState,
user: &AdminUser,
known_before: &[String],
client: Option<std::net::IpAddr>,
user_agent: Option<String>,
) {
let Some(ip) = client_ip_str(client) else {
return;
};
if known_before.is_empty() || known_before.contains(&ip) {
return;
}
notify_sign_in(
state,
user,
acme_proxy_jobs::notify::AdminSignInOutcome::SucceededFromNewAddress,
client,
user_agent,
)
.await;
}
pub async fn post_session(
State(state): State<AdminState>,
AdminClientIp(client): AdminClientIp,
headers: axum::http::HeaderMap,
Json(body): Json<LoginRequest>,
) -> Result<Response, AdminError> {
let signed_in = sign_in(&state, client, &headers, &body).await?;
Ok(signed_in_response(&signed_in))
}
pub async fn get_session_mfa(pending: PendingMfa) -> Json<serde_json::Value> {
Json(json!({
"step": pending.step.as_str(),
"expiresAt": acme_proxy_core::datetime::rfc3339(pending.session.expires_at),
}))
}
pub async fn post_session_mfa(
State(state): State<AdminState>,
AdminClientIp(client): AdminClientIp,
PendingMfaSubmit(pending): PendingMfaSubmit,
Json(body): Json<MfaRequest>,
) -> Result<Response, AdminError> {
let signed_in = finish_mfa(&state, client, pending, &body.code).await?;
Ok(signed_in_response(&signed_in))
}
fn signed_in_response(signed_in: &SignedIn) -> Response {
let body = match signed_in.pending {
None => session_body(&signed_in.user, &signed_in.session),
Some(step) => json!({
"mfaRequired": true,
"step": step.as_str(),
"csrfToken": signed_in.session.csrf_token,
"expiresAt": acme_proxy_core::datetime::rfc3339(signed_in.session.expires_at),
}),
};
(
StatusCode::OK,
[(header::SET_COOKIE, signed_in.cookie.clone())],
Json(body),
)
.into_response()
}
pub async fn get_session(auth: Authenticated) -> Json<serde_json::Value> {
Json(session_body(&auth.user, &auth.session))
}
pub async fn delete_session(
State(state): State<AdminState>,
Query(query): Query<LogoutQuery>,
SelfServiceWrite(auth): SelfServiceWrite,
request_context: acme_proxy_core::audit::RequestContext,
) -> Result<Response, AdminError> {
apply_logout(&state, &Caller::api(&auth, &request_context), query.all).await?;
Ok((
StatusCode::NO_CONTENT,
[(header::SET_COOKIE, clearing_cookie())],
)
.into_response())
}
pub(crate) async fn apply_logout(
state: &AdminState,
caller: &Caller<'_>,
all: bool,
) -> Result<(), AdminError> {
let scope = if all {
let revoked = AdminSession::delete_for_user(caller.auth.user.id, &state.database).await?;
state
.record_admin_action(caller.request, caller.username(), |actor, ctx| {
acme_proxy_jobs::auditor::admin::session_revoked(
actor,
ctx,
acme_proxy_jobs::auditor::admin::SessionScope::AllOf(
caller.username().to_string(),
),
revoked,
)
})
.await;
"all"
} else {
AdminSession::delete(&caller.auth.session.token_hash, &state.database).await?;
"one"
};
tracing::info!(event = "admin_logout",
outcome = "success",
surface = caller.surface,
username = %caller.username(),
scope = scope);
Ok(())
}
fn session_body(user: &AdminUser, session: &AdminSession) -> serde_json::Value {
json!({
"user": crate::admin::render_admin_user_json(user),
"csrfToken": session.csrf_token,
"expiresAt": acme_proxy_core::datetime::rfc3339(session.expires_at),
})
}