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::sqlite::admin_session::{AdminSession, NewSession};
use crate::sqlite::admin_user::AdminUser;
use crate::webadmin::AdminState;
use crate::webadmin::error::AdminError;
use crate::webadmin::session::{
AdminClientIp, Authenticated, AuthenticatedWrite, MfaStep, PENDING_MFA_TTL, PendingMfa,
PendingMfaSubmit, check_origin, clearing_cookie, cookie_value, hash_token, log_login,
mint_csrf_token, mint_token, session_cookie,
};
#[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)?;
if let Err(retry_after) = state.logins.check(client) {
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 mut user = match outcome {
AuthOutcome::Authenticated(user) => *user,
other => {
let reason = match other {
AuthOutcome::UnknownUser => "unknown_user",
AuthOutcome::WrongPassword(_) => "wrong_password",
AuthOutcome::Disabled(_) => "account_disabled",
AuthOutcome::Authenticated(_) => unreachable!("handled above"),
};
state.logins.record_failure(client);
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 = headers
.get(header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
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,
},
ttl,
&state.database,
)
.await?;
user.mark_logged_in(&state.database).await?;
state.logins.record_success(client);
log_login(true, &user.username, client, "");
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> {
if let Err(retry_after) = state.logins.check(client) {
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 {
state.logins.record_failure(client);
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());
return Err(AdminError::invalid_credentials());
};
let (session, cookie) = promote_pending(state, &pending.session.token_hash).await?;
user.mark_logged_in(&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, "");
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 crate::sqlite::admin_user::AdminUser,
pending_token_hash: &str,
) -> Result<(AdminSession, String), AdminError> {
let (session, cookie) = promote_pending(state, pending_token_hash).await?;
user.mark_logged_in(&state.database).await?;
state.logins.record_success(client);
info!(event = "admin_mfa_enrolled", outcome = "success", username = %user.username);
log_login(true, &user.username, client, "");
Ok((session, cookie))
}
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": crate::sqlite::order::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": crate::sqlite::order::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>,
AuthenticatedWrite(auth): AuthenticatedWrite,
) -> Result<Response, AdminError> {
let scope = if query.all {
AdminSession::delete_for_user(auth.user.id, &state.database).await?;
"all"
} else {
AdminSession::delete(&auth.session.token_hash, &state.database).await?;
"one"
};
tracing::info!(event = "admin_logout", outcome = "success", surface = "api", username = %auth.user.username, scope = scope);
Ok((
StatusCode::NO_CONTENT,
[(header::SET_COOKIE, clearing_cookie())],
)
.into_response())
}
fn session_body(user: &AdminUser, session: &AdminSession) -> serde_json::Value {
json!({
"user": crate::admin::render_admin_user_json(user),
"csrfToken": session.csrf_token,
"expiresAt": crate::sqlite::order::rfc3339(session.expires_at),
})
}