extract_remote_ip took the FIRST X-Forwarded-For entry, but Caddy (the single trusted proxy) appends the real peer as the LAST hop — earlier entries are client-supplied. An attacker could prepend a rotating X-Forwarded-For value to get a fresh per-IP bucket every request and evade the per-IP login limiter (the per-username bucket + global Argon2 semaphore still bounded it, but the per-IP layer was defeated). Take the last hop so the key reflects the real client. Added unit tests covering the spoof, the single-hop case, and the missing-header fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
352 lines
12 KiB
Rust
352 lines
12 KiB
Rust
//! `/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<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct AdminUserDto {
|
|
pub id: AdminUserId,
|
|
pub username: String,
|
|
pub instance_role: InstanceRole,
|
|
pub email: Option<String>,
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Handlers
|
|
// ----------------------------------------------------------------------------
|
|
|
|
async fn login(
|
|
State(state): State<AuthState>,
|
|
headers: HeaderMap,
|
|
Json(input): Json<LoginRequest>,
|
|
) -> 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 expires_at = Utc::now()
|
|
+ ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24));
|
|
|
|
if let Err(err) = state
|
|
.sessions
|
|
.create(user_id, &token.hash, 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,
|
|
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()
|
|
}
|
|
|
|
async fn logout(State(state): State<AuthState>, req: Request<Body>) -> 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<AuthState>,
|
|
Extension(principal): Extension<Principal>,
|
|
) -> 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<Body>) -> Option<String> {
|
|
// 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: <random>` and Caddy forwards
|
|
/// `<random>, <real-client>`. 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");
|
|
}
|
|
}
|