//! `/api/v1/admin/auth/*` — login, logout, who-am-I. //! //! Login mints an opaque session token, stores its SHA-256, and returns //! the raw token in the JSON body. Clients must send it as //! `Authorization: Bearer …` on subsequent requests; the same token is //! used for the session and admin-API authentication paths (there is no //! separate "API token" concept yet). //! //! Cookie auth was removed in the audit 2026-06-11 C-1 fix — same-origin //! user-route HTML defeated the previous `SameSite=Lax` cookie. The //! dashboard already uses Bearer; non-browser clients always have. //! //! Logout deletes the session row regardless of whether the supplied //! token matched anything (idempotent). `me` returns the row that the //! middleware already attached to the request extensions. use axum::body::Body; use axum::extract::{Extension, Request, State}; use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; use axum::middleware::from_fn_with_state; use axum::response::{IntoResponse, Json, Response}; use axum::routing::{get, post}; use axum::Router; use chrono::{DateTime, Duration as ChronoDuration, Utc}; use picloud_shared::{AdminUserId, InstanceRole}; use serde::{Deserialize, Serialize}; use serde_json::json; use picloud_shared::Principal; use crate::auth::{generate_session_token, hash_token, verify_password}; use crate::auth_middleware::{require_authenticated, AuthState}; use crate::login_rate_limit::LoginRateOutcome; pub fn auth_router(state: AuthState) -> Router { // /login + /logout are unguarded (login is how you get in; logout // is idempotent). /me is guarded — by definition it needs to know // who you are, so the middleware must run first. let guarded = Router::new() .route("/auth/me", get(me)) .route_layer(from_fn_with_state(state.clone(), require_authenticated)); Router::new() .route("/auth/login", post(login)) .route("/auth/logout", post(logout)) .merge(guarded) .with_state(state) } // ---------------------------------------------------------------------------- // DTOs // ---------------------------------------------------------------------------- #[derive(Debug, Deserialize)] pub struct LoginRequest { pub username: String, pub password: String, } #[derive(Debug, Serialize)] pub struct LoginResponse { pub user: AdminUserDto, pub token: String, pub expires_at: DateTime, } #[derive(Debug, Serialize)] pub struct AdminUserDto { pub id: AdminUserId, pub username: String, pub instance_role: InstanceRole, pub email: Option, } // ---------------------------------------------------------------------------- // Handlers // ---------------------------------------------------------------------------- async fn login( State(state): State, headers: HeaderMap, Json(input): Json, ) -> Response { // Audit 2026-06-11 H-B1 — rate limit BEFORE any DB read or // Argon2 verify so an attacker can't even queue. Keying is // (real client IP, username) + (username), so a single hot user and // a credential-stuffing flurry both saturate. The IP is the last // X-Forwarded-For hop (Caddy-appended); see `extract_remote_ip`. let remote_ip = extract_remote_ip(&headers); if let LoginRateOutcome::Denied { retry_after } = state .login_rate_limiter .try_consume(&remote_ip, &input.username) { return rate_limited(retry_after); } // Always perform a verify, even on missing/inactive users, to flatten // timing and prevent username enumeration. The dummy hash is the // shared `TIMING_FLAT_DUMMY_HASH` constant from `auth.rs` so the // single PHC value lives in exactly one place (v1.1.9 F4 dedup). let creds = match state .users .get_credentials_by_username(&input.username) .await { Ok(c) => c, Err(err) => { tracing::error!(?err, "admin_users credentials lookup failed"); return internal_error(); } }; // username from creds is discarded — the re-fetch below carries the // canonical row used in the response DTO. let (stored_hash, user_id, is_active) = match creds { Some(c) => (c.password_hash, Some(c.id), c.is_active), None => (crate::auth::TIMING_FLAT_DUMMY_HASH.to_string(), None, false), }; // Audit 2026-06-11 H-B1 — global Argon2 concurrency cap. Acquired // AFTER the bucket check so attackers can't queue; held only across // the verify, not the surrounding DB I/O. acquire() awaits, but the // bucket already gated so the queue is bounded. let Ok(permit) = state.argon2_login_semaphore.clone().acquire_owned().await else { // Semaphore is closed (process shutting down). Fail closed. return internal_error(); }; // F-P-002: Argon2id verify off the async worker. let password = input.password.clone(); let password_ok = match tokio::task::spawn_blocking(move || verify_password(&stored_hash, &password)).await { Ok(b) => b, Err(err) => { tracing::error!(?err, "verify_password spawn_blocking join failed"); drop(permit); return internal_error(); } }; drop(permit); if !password_ok || user_id.is_none() || !is_active { return invalid_credentials(); } let user_id = user_id.unwrap(); // Re-fetch the full row so the login response carries the same // shape /me does (instance_role, email). The credentials struct // intentionally omits email; one extra query per login is fine. let user_row = match state.users.get(user_id).await { Ok(Some(row)) => row, Ok(None) => return invalid_credentials(), Err(err) => { tracing::error!(?err, "admin_users lookup after login failed"); return internal_error(); } }; let token = generate_session_token(); let now = Utc::now(); // C1: an admin session dies at the EARLIER of its sliding window and its // absolute cap. At create the sliding window is shorter, so `expires_at` // starts as `now + ttl`; the absolute cap `now + absolute_ttl` is stored so // the sliding `touch` can clamp to it as the session ages. let expires_at = now + ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24)); let absolute_expires_at = now + ChronoDuration::from_std(state.absolute_ttl).unwrap_or_else(|_| ChronoDuration::days(30)); if let Err(err) = state .sessions .create(user_id, &token.hash, expires_at, absolute_expires_at) .await { tracing::error!(?err, "admin_sessions insert failed"); return internal_error(); } if let Err(err) = state.users.touch_last_login(user_id).await { // Non-fatal — log and continue. Login itself succeeded. tracing::warn!(?err, "failed to touch admin last_login_at"); } ( StatusCode::OK, no_store_headers(), Json(LoginResponse { user: AdminUserDto { id: user_row.id, username: user_row.username, instance_role: user_row.instance_role, email: user_row.email, }, token: token.raw, expires_at, }), ) .into_response() } /// `Cache-Control: no-store` for a response carrying a raw credential (a session /// token, an API key). Prevents a browser / proxy / CDN cache from retaining the /// secret where a later client could read it back. (C2) pub(crate) fn no_store_headers() -> HeaderMap { let mut headers = HeaderMap::new(); headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); headers } async fn logout(State(state): State, req: Request) -> Response { // Pull token without requiring a valid session (logout is idempotent). let token = extract_token_for_logout(&req); if let Some(raw) = token { let hash = hash_token(&raw); if let Err(err) = state.sessions.delete(&hash).await { tracing::error!(?err, "admin_sessions delete failed"); } // Audit 2026-06-11 (PrincipalCache revocation-lag) — drop just // this token from the cache so the logged-out session can't keep // authenticating for the cache TTL. Other sessions for the same // user are left untouched. state.principal_cache.evict_token(&hash); } StatusCode::NO_CONTENT.into_response() } async fn me( State(state): State, Extension(principal): Extension, ) -> Response { // /me consumes the resolved Principal directly; we re-fetch the // user row only to surface a fresh username (it can change via // PATCH while a session/key is still valid). match state.users.get(principal.user_id).await { Ok(Some(row)) => Json(AdminUserDto { id: row.id, username: row.username, instance_role: row.instance_role, email: row.email, }) .into_response(), Ok(None) => invalid_credentials(), Err(err) => { tracing::error!(?err, "admin_users lookup for /me failed"); internal_error() } } } // ---------------------------------------------------------------------------- // Helpers // ---------------------------------------------------------------------------- fn extract_token_for_logout(req: &Request) -> Option { // Duplicated from auth_middleware::extract_token because logout runs // before the auth middleware (logout is idempotent and must accept // already-expired tokens). Cookie auth was dropped in the audit // 2026-06-11 C-1 fix — Bearer only. let value = req.headers().get(header::AUTHORIZATION)?; let s = value.to_str().ok()?; let token = s.strip_prefix("Bearer ")?.trim(); if token.is_empty() { return None; } Some(token.to_string()) } /// Pull the originating client IP from the request for rate-limiting. /// /// Audit 2026-06-11 (H-B1 follow-up) — Caddy is the single trusted /// proxy in front of picloud and *appends* the immediate peer to /// `X-Forwarded-For`, so the **last** entry is the address Caddy /// actually saw. Every earlier entry is client-supplied: an attacker /// can send `X-Forwarded-For: ` and Caddy forwards /// `, `. Taking the first entry (the previous /// behavior) therefore let an attacker rotate the per-IP bucket key on /// every request and evade the per-IP limit. Take the last entry so the /// qualifier reflects the real client. Returns `"unknown"` when the /// header is absent/malformed so the per-username bucket still gates. /// /// (This assumes exactly one trusted proxy hop, which is the documented /// single-node topology. A multi-proxy deployment would need a /// configured trusted-hop count; tracked for the cluster work.) fn extract_remote_ip(headers: &HeaderMap) -> String { if let Some(xff) = headers.get("x-forwarded-for") { if let Ok(s) = xff.to_str() { if let Some(last) = s.split(',').next_back() { let trimmed = last.trim(); if !trimmed.is_empty() { return trimmed.to_string(); } } } } "unknown".to_string() } fn rate_limited(retry_after: std::time::Duration) -> Response { let mut secs = retry_after.as_secs(); if secs == 0 { secs = 1; } let mut hdrs = HeaderMap::new(); if let Ok(v) = HeaderValue::from_str(&secs.to_string()) { hdrs.insert("retry-after", v); } ( StatusCode::TOO_MANY_REQUESTS, hdrs, Json(json!({ "error": "too many login attempts; try again later", })), ) .into_response() } fn invalid_credentials() -> Response { ( StatusCode::UNAUTHORIZED, Json(json!({ "error": "invalid credentials" })), ) .into_response() } fn internal_error() -> Response { ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "internal error" })), ) .into_response() } #[cfg(test)] mod tests { use super::extract_remote_ip; use axum::http::{HeaderMap, HeaderValue}; fn xff(value: &str) -> HeaderMap { let mut h = HeaderMap::new(); h.insert("x-forwarded-for", HeaderValue::from_str(value).unwrap()); h } #[test] fn takes_last_xff_entry_caddy_appended_peer() { // Audit 2026-06-11 (H-B1 follow-up) — the real client is the // last hop Caddy appended; earlier entries are client-spoofable. assert_eq!(extract_remote_ip(&xff("203.0.113.9")), "203.0.113.9"); assert_eq!( extract_remote_ip(&xff("9.9.9.9, 203.0.113.9")), "203.0.113.9" ); } #[test] fn spoofed_leading_entry_does_not_change_the_key() { // Attacker prepends junk; Caddy still appends the real peer last, // so the rate-limit key is stable regardless of the spoof. let a = extract_remote_ip(&xff("evil-1, 203.0.113.9")); let b = extract_remote_ip(&xff("evil-2, 203.0.113.9")); assert_eq!(a, b); assert_eq!(a, "203.0.113.9"); } #[test] fn missing_or_empty_header_is_unknown() { assert_eq!(extract_remote_ip(&HeaderMap::new()), "unknown"); assert_eq!(extract_remote_ip(&xff(" ")), "unknown"); } }