1use axum::Json;
4use axum::extract::{Query, State};
5use axum::http::{StatusCode, header};
6use axum::response::{IntoResponse, Response};
7use serde::Deserialize;
8use serde_json::json;
9use std::time::Duration;
10use tracing::{info, warn};
11
12use crate::admin::mfa::MfaOutcome;
13use crate::admin::users::{self, AuthOutcome};
14use crate::sqlite::admin_session::{AdminSession, NewSession};
15use crate::sqlite::admin_user::AdminUser;
16use crate::webadmin::AdminState;
17use crate::webadmin::error::AdminError;
18use crate::webadmin::session::{
19 AdminClientIp, Authenticated, AuthenticatedWrite, MfaStep, PENDING_MFA_TTL, PendingMfa,
20 PendingMfaSubmit, check_origin, clearing_cookie, cookie_value, hash_token, log_login,
21 mint_csrf_token, mint_token, session_cookie,
22};
23
24#[derive(Debug, Deserialize)]
25pub struct LoginRequest {
26 pub username: String,
27 pub password: String,
28}
29
30#[derive(Debug, Deserialize, Default)]
31pub struct LogoutQuery {
32 #[serde(default)]
34 pub all: bool,
35}
36
37#[derive(Debug, Deserialize)]
40pub struct MfaRequest {
41 pub code: String,
42}
43
44pub(crate) struct SignedIn {
46 pub user: AdminUser,
47 pub session: AdminSession,
48 pub cookie: String,
51 pub pending: Option<MfaStep>,
54}
55
56pub(crate) async fn sign_in(
64 state: &AdminState,
65 client: Option<std::net::IpAddr>,
66 headers: &axum::http::HeaderMap,
67 credentials: &LoginRequest,
68) -> Result<SignedIn, AdminError> {
69 let body = credentials;
70 check_origin(headers, &state.config.admin.base_url)?;
71
72 if let Err(retry_after) = state.logins.check(client) {
73 log_login(false, &body.username, client, "rate_limited");
74 return Err(AdminError::rate_limited(retry_after));
75 }
76
77 let outcome =
78 users::authenticate(&body.username, &body.password, state.database.clone()).await?;
79
80 let mut user = match outcome {
82 AuthOutcome::Authenticated(user) => *user,
83 other => {
84 let reason = match other {
85 AuthOutcome::UnknownUser => "unknown_user",
86 AuthOutcome::WrongPassword(_) => "wrong_password",
87 AuthOutcome::Disabled(_) => "account_disabled",
88 AuthOutcome::Authenticated(_) => unreachable!("handled above"),
89 };
90 state.logins.record_failure(client);
91 log_login(false, &body.username, client, reason);
92 return Err(AdminError::invalid_credentials());
93 }
94 };
95
96 let step = if user.has_totp() {
100 Some(MfaStep::Verify)
101 } else if state.config.admin.require_mfa {
102 Some(MfaStep::Enrol)
103 } else {
104 None
105 };
106
107 if let Some(existing) = cookie_value(headers) {
111 AdminSession::delete(&hash_token(&existing), &state.database).await?;
112 }
113
114 let minted = mint_token();
115 let csrf_token = mint_csrf_token();
116 let created_ip = client.map(|ip| ip.to_string());
117 let user_agent = headers
118 .get(header::USER_AGENT)
119 .and_then(|v| v.to_str().ok())
120 .map(str::to_string);
121
122 let Some(step) = step else {
123 let ttl = Duration::from_secs(state.config.admin.session_ttl_seconds);
124 let session = AdminSession::create(
125 NewSession {
126 user_id: &user.id,
127 token_hash: &minted.token_hash,
128 csrf_token: &csrf_token,
129 created_ip,
130 user_agent,
131 },
132 ttl,
133 &state.database,
134 )
135 .await?;
136
137 user.mark_logged_in(&state.database).await?;
138 state.logins.record_success(client);
139 log_login(true, &user.username, client, "");
140
141 return Ok(SignedIn {
142 user,
143 session,
144 cookie: session_cookie(&minted.token, ttl),
145 pending: None,
146 });
147 };
148
149 let session = AdminSession::create_pending(
161 NewSession {
162 user_id: &user.id,
163 token_hash: &minted.token_hash,
164 csrf_token: &csrf_token,
165 created_ip,
166 user_agent,
167 },
168 PENDING_MFA_TTL,
169 &state.database,
170 )
171 .await?;
172
173 info!(event = "admin_login_mfa_pending",
174 outcome = "success",
175 username = %user.username,
176 client_ip = ?client,
177 step = step.as_str());
178
179 Ok(SignedIn {
180 user,
181 session,
182 cookie: session_cookie(&minted.token, PENDING_MFA_TTL),
186 pending: Some(step),
187 })
188}
189
190pub(crate) async fn finish_mfa(
197 state: &AdminState,
198 client: Option<std::net::IpAddr>,
199 pending: PendingMfa,
200 submitted: &str,
201) -> Result<SignedIn, AdminError> {
202 if let Err(retry_after) = state.logins.check(client) {
205 log_login(false, &pending.user.username, client, "rate_limited");
206 return Err(AdminError::rate_limited(retry_after));
207 }
208
209 let mut user = pending.user;
210 let outcome =
211 crate::admin::mfa::verify_second_factor(&mut user, submitted, state.database.clone())
212 .await?;
213
214 let MfaOutcome::Accepted { via, .. } = outcome else {
215 state.logins.record_failure(client);
216
217 let spent = AdminSession::record_mfa_failure(&pending.session.token_hash, &state.database)
226 .await?
227 .is_some_and(|attempts| attempts >= i64::from(state.config.admin.login_max_attempts));
228
229 if spent {
230 AdminSession::delete(&pending.session.token_hash, &state.database).await?;
234 warn!(event = "admin_mfa_attempts_exhausted",
235 outcome = "failure",
236 username = %user.username,
237 client_ip = ?client,
238 max_attempts = state.config.admin.login_max_attempts);
239 }
240
241 warn!(event = "admin_mfa_failed",
242 outcome = "failure",
243 username = %user.username,
244 client_ip = ?client,
245 reason = outcome.reason());
246 return Err(AdminError::invalid_credentials());
252 };
253
254 let (session, cookie) = promote_pending(state, &pending.session.token_hash).await?;
255
256 user.mark_logged_in(&state.database).await?;
257 state.logins.record_success(client);
258 info!(event = "admin_mfa_verified",
259 outcome = "success",
260 username = %user.username,
261 method = via.as_str());
262 log_login(true, &user.username, client, "");
263
264 Ok(SignedIn {
265 user,
266 session,
267 cookie,
268 pending: None,
269 })
270}
271
272pub(crate) async fn promote_pending(
280 state: &AdminState,
281 pending_token_hash: &str,
282) -> Result<(AdminSession, String), AdminError> {
283 let ttl = Duration::from_secs(state.config.admin.session_ttl_seconds);
284 let minted = mint_token();
285 let csrf_token = mint_csrf_token();
286
287 let Some(session) = AdminSession::promote(
288 pending_token_hash,
289 &minted.token_hash,
290 &csrf_token,
291 ttl,
292 &state.database,
293 )
294 .await?
295 else {
296 return Err(AdminError::session_invalid());
299 };
300
301 Ok((session, session_cookie(&minted.token, ttl)))
302}
303
304pub(crate) async fn finish_enrolment(
322 state: &AdminState,
323 client: Option<std::net::IpAddr>,
324 user: &mut crate::sqlite::admin_user::AdminUser,
325 pending_token_hash: &str,
326) -> Result<(AdminSession, String), AdminError> {
327 let (session, cookie) = promote_pending(state, pending_token_hash).await?;
328 user.mark_logged_in(&state.database).await?;
329 state.logins.record_success(client);
330 info!(event = "admin_mfa_enrolled", outcome = "success", username = %user.username);
331 log_login(true, &user.username, client, "");
332 Ok((session, cookie))
333}
334
335pub async fn post_session(
343 State(state): State<AdminState>,
344 AdminClientIp(client): AdminClientIp,
345 headers: axum::http::HeaderMap,
346 Json(body): Json<LoginRequest>,
347) -> Result<Response, AdminError> {
348 let signed_in = sign_in(&state, client, &headers, &body).await?;
349 Ok(signed_in_response(&signed_in))
350}
351
352pub async fn get_session_mfa(pending: PendingMfa) -> Json<serde_json::Value> {
357 Json(json!({
358 "step": pending.step.as_str(),
359 "expiresAt": crate::sqlite::order::rfc3339(pending.session.expires_at),
360 }))
361}
362
363pub async fn post_session_mfa(
372 State(state): State<AdminState>,
373 AdminClientIp(client): AdminClientIp,
374 PendingMfaSubmit(pending): PendingMfaSubmit,
375 Json(body): Json<MfaRequest>,
376) -> Result<Response, AdminError> {
377 let signed_in = finish_mfa(&state, client, pending, &body.code).await?;
378 Ok(signed_in_response(&signed_in))
379}
380
381fn signed_in_response(signed_in: &SignedIn) -> Response {
387 let body = match signed_in.pending {
388 None => session_body(&signed_in.user, &signed_in.session),
389 Some(step) => json!({
390 "mfaRequired": true,
391 "step": step.as_str(),
392 "csrfToken": signed_in.session.csrf_token,
393 "expiresAt": crate::sqlite::order::rfc3339(signed_in.session.expires_at),
394 }),
395 };
396
397 (
398 StatusCode::OK,
399 [(header::SET_COOKIE, signed_in.cookie.clone())],
400 Json(body),
401 )
402 .into_response()
403}
404
405pub async fn get_session(auth: Authenticated) -> Json<serde_json::Value> {
410 Json(session_body(&auth.user, &auth.session))
411}
412
413pub async fn delete_session(
415 State(state): State<AdminState>,
416 Query(query): Query<LogoutQuery>,
417 AuthenticatedWrite(auth): AuthenticatedWrite,
418) -> Result<Response, AdminError> {
419 let scope = if query.all {
420 AdminSession::delete_for_user(&auth.user.id, &state.database).await?;
421 "all"
422 } else {
423 AdminSession::delete(&auth.session.token_hash, &state.database).await?;
424 "one"
425 };
426
427 tracing::info!(event = "admin_logout", outcome = "success", surface = "api", username = %auth.user.username, scope = scope);
428 Ok((
429 StatusCode::NO_CONTENT,
430 [(header::SET_COOKIE, clearing_cookie())],
431 )
432 .into_response())
433}
434
435fn session_body(user: &AdminUser, session: &AdminSession) -> serde_json::Value {
437 json!({
438 "user": crate::admin::render_admin_user_json(user),
439 "csrfToken": session.csrf_token,
440 "expiresAt": crate::sqlite::order::rfc3339(session.expires_at),
441 })
442}