1use axum::Json;
9use axum::extract::{Query, State};
10use axum::http::{StatusCode, header};
11use axum::response::{IntoResponse, Response};
12use serde::Deserialize;
13use serde_json::json;
14use std::time::Duration;
15use tracing::{info, warn};
16
17use crate::admin::mfa::MfaOutcome;
18use crate::admin::users::{self, AuthOutcome};
19use crate::webadmin::AdminState;
20use crate::webadmin::error::AdminError;
21use crate::webadmin::handlers::Caller;
22use crate::webadmin::session::{
23 AdminClientIp, Authenticated, MfaStep, PENDING_MFA_TTL, PendingMfa, PendingMfaSubmit,
24 SelfServiceWrite, check_origin, clearing_cookie, cookie_value, hash_token, log_login,
25 mint_csrf_token, mint_token, session_cookie,
26};
27use acme_proxy_store::admin_session::AdminSession;
28use acme_proxy_store::admin_session::NewSession;
29use acme_proxy_store::admin_user::AdminUser;
30
31#[derive(Debug, Deserialize)]
33pub struct LoginRequest {
34 pub username: String,
35 pub password: String,
36}
37
38#[derive(Debug, Deserialize, Default)]
40pub struct LogoutQuery {
41 #[serde(default)]
43 pub all: bool,
44}
45
46#[derive(Debug, Deserialize)]
49pub struct MfaRequest {
50 pub code: String,
51}
52
53pub(crate) struct SignedIn {
55 pub user: AdminUser,
56 pub session: AdminSession,
57 pub cookie: String,
60 pub pending: Option<MfaStep>,
63}
64
65pub(crate) async fn sign_in(
73 state: &AdminState,
74 client: Option<std::net::IpAddr>,
75 headers: &axum::http::HeaderMap,
76 credentials: &LoginRequest,
77) -> Result<SignedIn, AdminError> {
78 let body = credentials;
79 check_origin(headers, &state.config.admin.base_url)?;
80
81 let attempt = match state.logins.begin(client) {
82 Ok(attempt) => attempt,
83 Err(retry_after) => {
84 log_login(false, &body.username, client, "rate_limited");
85 return Err(AdminError::rate_limited(retry_after));
86 }
87 };
88
89 let outcome =
90 users::authenticate(&body.username, &body.password, state.database.clone()).await?;
91
92 let refused = match outcome {
94 AuthOutcome::Authenticated(user) => Ok(*user),
95 AuthOutcome::UnknownUser => Err("unknown_user"),
96 AuthOutcome::WrongPassword(_) => Err("wrong_password"),
97 AuthOutcome::Disabled(_) => Err("account_disabled"),
98 };
99 let mut user = match refused {
100 Ok(user) => user,
101 Err(reason) => {
102 attempt.failed();
103 log_login(false, &body.username, client, reason);
104 return Err(AdminError::invalid_credentials());
105 }
106 };
107
108 let step = if user.has_totp() {
112 Some(MfaStep::Verify)
113 } else if state.config.admin.require_mfa {
114 Some(MfaStep::Enrol)
115 } else {
116 None
117 };
118
119 if let Some(existing) = cookie_value(headers) {
123 AdminSession::delete(&hash_token(&existing), &state.database).await?;
124 }
125
126 let minted = mint_token();
127 let csrf_token = mint_csrf_token();
128 let created_ip = client.map(|ip| ip.to_string());
129 let user_agent = crate::webadmin::user_agent_of(headers);
132
133 let Some(step) = step else {
134 let ttl = Duration::from_secs(state.config.admin.session_ttl_seconds);
135 let session = AdminSession::create(
136 NewSession {
137 user_id: user.id,
138 token_hash: &minted.token_hash,
139 csrf_token: &csrf_token,
140 created_ip,
141 user_agent: user_agent.clone(),
142 },
143 ttl,
144 &state.database,
145 )
146 .await?;
147
148 let known_before = user.known_login_ips.clone();
149 user.mark_logged_in(client_ip_str(client).as_deref(), &state.database)
150 .await?;
151 state.logins.record_success(client);
152 log_login(true, &user.username, client, "");
153 notify_sign_in_from_new_address(state, &user, &known_before, client, user_agent).await;
154
155 return Ok(SignedIn {
156 user,
157 session,
158 cookie: session_cookie(&minted.token, ttl),
159 pending: None,
160 });
161 };
162
163 let session = AdminSession::create_pending(
176 NewSession {
177 user_id: user.id,
178 token_hash: &minted.token_hash,
179 csrf_token: &csrf_token,
180 created_ip,
181 user_agent,
182 },
183 PENDING_MFA_TTL,
184 &state.database,
185 )
186 .await?;
187
188 info!(event = "admin_login_mfa_pending",
189 outcome = "success",
190 username = %user.username,
191 client_ip = ?client,
192 step = step.as_str());
193
194 Ok(SignedIn {
195 user,
196 session,
197 cookie: session_cookie(&minted.token, PENDING_MFA_TTL),
201 pending: Some(step),
202 })
203}
204
205pub(crate) async fn finish_mfa(
212 state: &AdminState,
213 client: Option<std::net::IpAddr>,
214 pending: PendingMfa,
215 submitted: &str,
216) -> Result<SignedIn, AdminError> {
217 let attempt = match state.logins.begin(client) {
220 Ok(attempt) => attempt,
221 Err(retry_after) => {
222 log_login(false, &pending.user.username, client, "rate_limited");
223 return Err(AdminError::rate_limited(retry_after));
224 }
225 };
226
227 let mut user = pending.user;
228 let outcome =
229 crate::admin::mfa::verify_second_factor(&mut user, submitted, state.database.clone())
230 .await?;
231
232 let MfaOutcome::Accepted { via, .. } = outcome else {
233 attempt.failed();
234
235 let spent = AdminSession::record_mfa_failure(&pending.session.token_hash, &state.database)
244 .await?
245 .is_some_and(|attempts| attempts >= i64::from(state.config.admin.login_max_attempts));
246
247 if spent {
248 AdminSession::delete(&pending.session.token_hash, &state.database).await?;
252 warn!(event = "admin_mfa_attempts_exhausted",
253 outcome = "failure",
254 username = %user.username,
255 client_ip = ?client,
256 max_attempts = state.config.admin.login_max_attempts);
257 }
258
259 warn!(event = "admin_mfa_failed",
260 outcome = "failure",
261 username = %user.username,
262 client_ip = ?client,
263 reason = outcome.reason());
264
265 let user_agent = pending.session.user_agent.clone();
269 notify_sign_in(
270 state,
271 &user,
272 acme_proxy_jobs::notify::AdminSignInOutcome::SecondFactorRefused,
273 client,
274 user_agent.clone(),
275 )
276 .await;
277 if spent {
278 notify_sign_in(
279 state,
280 &user,
281 acme_proxy_jobs::notify::AdminSignInOutcome::LockedOut,
282 client,
283 user_agent,
284 )
285 .await;
286 }
287
288 return Err(AdminError::invalid_credentials());
294 };
295
296 let (session, cookie) = promote_pending(state, &pending.session.token_hash).await?;
297
298 let known_before = user.known_login_ips.clone();
299 user.mark_logged_in(client_ip_str(client).as_deref(), &state.database)
300 .await?;
301 state.logins.record_success(client);
302 info!(event = "admin_mfa_verified",
303 outcome = "success",
304 username = %user.username,
305 method = via.as_str());
306 log_login(true, &user.username, client, "");
307 notify_sign_in_from_new_address(
308 state,
309 &user,
310 &known_before,
311 client,
312 pending.session.user_agent.clone(),
313 )
314 .await;
315
316 Ok(SignedIn {
317 user,
318 session,
319 cookie,
320 pending: None,
321 })
322}
323
324pub(crate) async fn promote_pending(
332 state: &AdminState,
333 pending_token_hash: &str,
334) -> Result<(AdminSession, String), AdminError> {
335 let ttl = Duration::from_secs(state.config.admin.session_ttl_seconds);
336 let minted = mint_token();
337 let csrf_token = mint_csrf_token();
338
339 let Some(session) = AdminSession::promote(
340 pending_token_hash,
341 &minted.token_hash,
342 &csrf_token,
343 ttl,
344 &state.database,
345 )
346 .await?
347 else {
348 return Err(AdminError::session_invalid());
351 };
352
353 Ok((session, session_cookie(&minted.token, ttl)))
354}
355
356pub(crate) async fn finish_enrolment(
374 state: &AdminState,
375 client: Option<std::net::IpAddr>,
376 user: &mut acme_proxy_store::admin_user::AdminUser,
377 pending_token_hash: &str,
378 user_agent: Option<String>,
379) -> Result<(AdminSession, String), AdminError> {
380 let (session, cookie) = promote_pending(state, pending_token_hash).await?;
381 let known_before = user.known_login_ips.clone();
382 user.mark_logged_in(client_ip_str(client).as_deref(), &state.database)
383 .await?;
384 state.logins.record_success(client);
385 info!(event = "admin_mfa_enrolled", outcome = "success", username = %user.username);
386 log_login(true, &user.username, client, "");
387 notify_sign_in_from_new_address(state, user, &known_before, client, user_agent).await;
388 Ok((session, cookie))
389}
390
391fn client_ip_str(client: Option<std::net::IpAddr>) -> Option<String> {
393 client.map(|ip| ip.to_string())
394}
395
396async fn notify_sign_in(
398 state: &AdminState,
399 user: &AdminUser,
400 outcome: acme_proxy_jobs::notify::AdminSignInOutcome,
401 client: Option<std::net::IpAddr>,
402 user_agent: Option<String>,
403) {
404 state
405 .notify_security(acme_proxy_jobs::notify::NotifyEvent::AdminSignIn(
406 acme_proxy_jobs::notify::AdminSignInData {
407 profile: acme_proxy_jobs::notify::ADMIN_DISPATCHER_KEY.to_string(),
408 username: user.username.clone(),
409 recipient: user.contact_email.clone(),
410 outcome,
411 client_ip: client_ip_str(client),
412 user_agent,
413 at: acme_proxy_store::nonce::now_secs(),
414 },
415 ))
416 .await;
417}
418
419async fn notify_sign_in_from_new_address(
425 state: &AdminState,
426 user: &AdminUser,
427 known_before: &[String],
428 client: Option<std::net::IpAddr>,
429 user_agent: Option<String>,
430) {
431 let Some(ip) = client_ip_str(client) else {
432 return;
433 };
434 if known_before.is_empty() || known_before.contains(&ip) {
435 return;
436 }
437 notify_sign_in(
438 state,
439 user,
440 acme_proxy_jobs::notify::AdminSignInOutcome::SucceededFromNewAddress,
441 client,
442 user_agent,
443 )
444 .await;
445}
446
447pub async fn post_session(
455 State(state): State<AdminState>,
456 AdminClientIp(client): AdminClientIp,
457 headers: axum::http::HeaderMap,
458 Json(body): Json<LoginRequest>,
459) -> Result<Response, AdminError> {
460 let signed_in = sign_in(&state, client, &headers, &body).await?;
461 Ok(signed_in_response(&signed_in))
462}
463
464pub async fn get_session_mfa(pending: PendingMfa) -> Json<serde_json::Value> {
469 Json(json!({
470 "step": pending.step.as_str(),
471 "expiresAt": acme_proxy_core::datetime::rfc3339(pending.session.expires_at),
472 }))
473}
474
475pub async fn post_session_mfa(
484 State(state): State<AdminState>,
485 AdminClientIp(client): AdminClientIp,
486 PendingMfaSubmit(pending): PendingMfaSubmit,
487 Json(body): Json<MfaRequest>,
488) -> Result<Response, AdminError> {
489 let signed_in = finish_mfa(&state, client, pending, &body.code).await?;
490 Ok(signed_in_response(&signed_in))
491}
492
493fn signed_in_response(signed_in: &SignedIn) -> Response {
499 let body = match signed_in.pending {
500 None => session_body(&signed_in.user, &signed_in.session),
501 Some(step) => json!({
502 "mfaRequired": true,
503 "step": step.as_str(),
504 "csrfToken": signed_in.session.csrf_token,
505 "expiresAt": acme_proxy_core::datetime::rfc3339(signed_in.session.expires_at),
506 }),
507 };
508
509 (
510 StatusCode::OK,
511 [(header::SET_COOKIE, signed_in.cookie.clone())],
512 Json(body),
513 )
514 .into_response()
515}
516
517pub async fn get_session(auth: Authenticated) -> Json<serde_json::Value> {
522 Json(session_body(&auth.user, &auth.session))
523}
524
525pub async fn delete_session(
527 State(state): State<AdminState>,
528 Query(query): Query<LogoutQuery>,
529 SelfServiceWrite(auth): SelfServiceWrite,
530 request_context: acme_proxy_core::audit::RequestContext,
531) -> Result<Response, AdminError> {
532 apply_logout(&state, &Caller::api(&auth, &request_context), query.all).await?;
533 Ok((
534 StatusCode::NO_CONTENT,
535 [(header::SET_COOKIE, clearing_cookie())],
536 )
537 .into_response())
538}
539
540pub(crate) async fn apply_logout(
546 state: &AdminState,
547 caller: &Caller<'_>,
548 all: bool,
549) -> Result<(), AdminError> {
550 let scope = if all {
551 let revoked = AdminSession::delete_for_user(caller.auth.user.id, &state.database).await?;
552 state
553 .record_admin_action(caller.request, caller.username(), |actor, ctx| {
554 acme_proxy_jobs::auditor::admin::session_revoked(
555 actor,
556 ctx,
557 acme_proxy_jobs::auditor::admin::SessionScope::AllOf(
558 caller.username().to_string(),
559 ),
560 revoked,
561 )
562 })
563 .await;
564 "all"
565 } else {
566 AdminSession::delete(&caller.auth.session.token_hash, &state.database).await?;
567 "one"
568 };
569
570 tracing::info!(event = "admin_logout",
571 outcome = "success",
572 surface = caller.surface,
573 username = %caller.username(),
574 scope = scope);
575 Ok(())
576}
577
578fn session_body(user: &AdminUser, session: &AdminSession) -> serde_json::Value {
580 json!({
581 "user": crate::admin::render_admin_user_json(user),
582 "csrfToken": session.csrf_token,
583 "expiresAt": acme_proxy_core::datetime::rfc3339(session.expires_at),
584 })
585}