Files
PiCloud/crates/manager-core/src/auth_api.rs
MechaCat02 31ecfae86e fix(audit-2026-06-11/revocation-lag): evict PrincipalCache on credential change
The PrincipalCache (token-hash -> resolved Principal, 60s TTL) had no
eviction hook, so deactivation / password change / API-key revocation
flipped the DB rows but the cache kept serving the stale principal for
up to the TTL window. Closes the audit's PrincipalCache revocation-lag
Medium and greens authz::deactivating_user_revokes_their_api_keys.

- PrincipalCache::evict_user(user_id) + evict_token(hash).
- One shared cache Arc threaded into AuthState / AdminsState /
  ApiKeysState (a per-state cache would let the middleware's own copy
  keep authenticating a revoked principal).
- Evict on: deactivation, password change (both evict_user), logout
  (evict_token, precise), single API-key delete (evict_user).
- H-E1 note: DB-write invalidation stays best-effort by design (a blip
  must not undo the rotation); the cache evict is what makes it take
  effect on the next request.
- Renamed bearer_and_cookie_produce_same_principal ->
  session_token_and_api_key_produce_same_principal (cookie auth was
  removed in C-1; the test never tested cookies).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:12:54 +02:00

301 lines
10 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
// (X-Forwarded-For first hop, username) + (username), so a single
// hot user and a credential-stuffing flurry both saturate.
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. Caddy is the only
/// trusted proxy in front of picloud; X-Forwarded-For carries the
/// client. We take the first entry (closest to the original client).
/// Returns `"unknown"` if the header is absent/malformed so the
/// per-user bucket still gates without an IP qualifier.
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(first) = s.split(',').next() {
let trimmed = first.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()
}