Files
PiCloud/crates/manager-core/src/auth_middleware.rs
MechaCat02 dd9d828018 docs(audit-2026-06-11/tier3): fix stale principal_cache field comment
Review nit — the field is a non-optional Arc; the old '`None` for tests'
note was stale. Clarify that it's one shared Arc so revocation-side
evictions are visible to the middleware's resolve-side lookup.

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

544 lines
20 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Authentication middleware — resolves the caller's `Principal` from
//! a Bearer session-token OR an API key (`Authorization: Bearer pic_…`).
//! Both paths converge on the same request extension so downstream
//! handlers see one shape.
//!
//! Capability checks live in `crate::authz` and are called per-handler
//! (after the relevant resource is loaded, so the capability binds to
//! the actual resource's `app_id`). This middleware is gate-only: it
//! ensures *some* `Principal` is attached, or returns 401.
//!
//! Token discriminator: the `pic_` prefix on a Bearer value selects
//! the API-key path; anything else (raw 32-byte base64-url-encoded
//! string) takes the session path.
//!
//! Audit 2026-06-11 (C-1): cookie auth removed. The platform co-hosts
//! attacker-controlled user-route HTML on the same origin as the admin
//! API; `SameSite=Lax` did not block same-origin POSTs from a malicious
//! script under `/<route>`. Admin clients must send
//! `Authorization: Bearer <session_token>` (the dashboard already does;
//! see `dashboard/src/lib/api.ts`).
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::{header, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Json, Response};
use chrono::Utc;
use picloud_shared::{AdminUserId, Principal};
use serde_json::json;
use crate::admin_session_repo::AdminSessionRepository;
use crate::admin_user_repo::AdminUserRepository;
use crate::api_key_repo::{ApiKeyRepository, ApiKeyVerification};
use crate::auth::{hash_token, verify_password};
/// F-P-009: short-lived cache of resolved `(token → principal)` pairs.
/// Two reasons it's load-bearing:
/// 1. `verify_api_key` Argon2-verifies *every* candidate sharing the
/// 8-char prefix per request — caching cuts that to once per token
/// per TTL window.
/// 2. `attach_principal_if_present` runs on every data-plane request
/// including paths that may not even need authz; even the session
/// path costs three round-trips (lookup + user-get + touch).
///
/// Keyed on the hashed token (sha256 in `hash_token`); value is the
/// resolved Principal + the instant it landed in the cache. TTL is
/// short — the audit cited 60-300s.
#[derive(Debug, Default)]
pub struct PrincipalCache {
inner: Mutex<HashMap<String, (Instant, Principal)>>,
}
/// TTL on cached principals. Short enough that role / scope changes
/// take effect quickly; long enough to amortize Argon2 over many
/// adjacent requests on a hot key.
const PRINCIPAL_CACHE_TTL: Duration = Duration::from_secs(60);
/// F-S-006: maximum candidate API keys we'll Argon2-verify per
/// request. Hoisted to module scope so clippy::items_after_statements
/// is happy.
const MAX_API_KEY_CANDIDATES: usize = 16;
impl PrincipalCache {
#[must_use]
pub fn new() -> Self {
Self::default()
}
fn get(&self, token_hash: &str) -> Option<Principal> {
let mut guard = self.inner.lock().ok()?;
let entry = guard.get(token_hash)?;
if entry.0.elapsed() > PRINCIPAL_CACHE_TTL {
guard.remove(token_hash);
return None;
}
Some(entry.1.clone())
}
fn insert(&self, token_hash: String, principal: Principal) {
let Ok(mut guard) = self.inner.lock() else {
return;
};
// Lazy GC: cap unbounded growth by sweeping expired entries
// when the cache crosses an arbitrary threshold.
if guard.len() > 1024 {
let now = Instant::now();
guard.retain(|_, v| now.duration_since(v.0) <= PRINCIPAL_CACHE_TTL);
}
guard.insert(token_hash, (Instant::now(), principal));
}
/// Evict every cached principal belonging to `user_id`.
///
/// Audit 2026-06-11 (PrincipalCache revocation-lag, Medium) — the
/// revocation paths (`set_active(false)`, password change,
/// API-key delete) flip the backing DB rows, but a cached principal
/// keeps authenticating for up to [`PRINCIPAL_CACHE_TTL`] unless it
/// is evicted. Callers invoke this right after the DB mutation so a
/// just-revoked credential stops working on the next request. The
/// cache value carries the resolved [`Principal`], so we can target
/// the user without knowing which token hashes belong to them.
pub fn evict_user(&self, user_id: AdminUserId) {
let Ok(mut guard) = self.inner.lock() else {
return;
};
guard.retain(|_, (_, p)| p.user_id != user_id);
}
/// Evict a single cached entry by its token hash. Used on logout,
/// where exactly one session token is being invalidated and we
/// don't want to disturb the user's other live sessions.
pub fn evict_token(&self, token_hash: &str) {
if let Ok(mut guard) = self.inner.lock() {
guard.remove(token_hash);
}
}
}
/// Prefix on the wire that selects the API-key path. The body that
/// follows is `base32(32 random bytes)`; the first 8 chars of the body
/// index into `api_keys.prefix` for verification.
pub const API_KEY_PREFIX: &str = "pic_";
/// Length of the indexed prefix portion of an API key (the 8 chars
/// immediately after `pic_`). Schema-side index is on this slice.
pub const API_KEY_PREFIX_LEN: usize = 8;
/// Shared state for auth: the user / session / API-key repos plus the
/// configured sliding session TTL. Cheap to clone (`Arc` everywhere).
#[derive(Clone)]
pub struct AuthState {
pub users: Arc<dyn AdminUserRepository>,
pub sessions: Arc<dyn AdminSessionRepository>,
pub keys: Arc<dyn ApiKeyRepository>,
pub ttl: Duration,
/// F-P-009 — shared cache of resolved Principals. Constructed once
/// at startup and cloned (same `Arc`) into every router state that
/// resolves or revokes credentials, so a revocation-side eviction is
/// visible to this resolve-side lookup.
pub principal_cache: Arc<PrincipalCache>,
/// Audit 2026-06-11 H-B1 — per-`(remote_ip, username)` + per-`username`
/// burst limiter for `/auth/login`. Cheap to clone (Arc).
pub login_rate_limiter: Arc<crate::login_rate_limit::LoginRateLimiter>,
/// Audit 2026-06-11 H-B1 — caps concurrent Argon2 verifies during
/// login so a credential-stuffing flurry can't park every blocking
/// worker. Sized via `PICLOUD_LOGIN_ARGON2_PARALLELISM` (default 2).
pub argon2_login_semaphore: Arc<tokio::sync::Semaphore>,
}
/// Legacy request-extension alias retained so the (only remaining)
/// handler that pulled `AuthedAdmin` out — `GET /admin/auth/me` —
/// keeps compiling during the migration. New handlers should pull
/// `Extension<Principal>` directly.
#[deprecated(note = "use Extension<Principal> directly")]
#[derive(Debug, Clone)]
pub struct AuthedAdmin {
pub id: AdminUserId,
pub username: String,
}
/// Middleware entry point. Wire with
/// `axum::middleware::from_fn_with_state(auth_state, require_authenticated)`.
/// Inserts `Principal` (and the legacy `AuthedAdmin`) as request
/// extensions on success; returns 401 on any failure mode.
pub async fn require_authenticated(
State(state): State<AuthState>,
mut req: Request<Body>,
next: Next,
) -> Response {
let Some(token) = extract_token(&req) else {
return unauthorized();
};
let principal = match resolve_principal(&state, &token).await {
Ok(Some(p)) => p,
Ok(None) => return unauthorized(),
Err(InternalError) => return internal_error(),
};
let username_for_legacy = username_for(&state, principal.user_id).await;
req.extensions_mut().insert(principal.clone());
#[allow(deprecated)]
if let Some(username) = username_for_legacy {
req.extensions_mut().insert(AuthedAdmin {
id: principal.user_id,
username,
});
}
next.run(req).await
}
/// Backwards-compatible alias — the single callsite that still names
/// `require_admin` keeps working without an immediate rename. New
/// wiring should call `require_authenticated`.
#[deprecated(note = "renamed to require_authenticated")]
pub async fn require_admin(state: State<AuthState>, req: Request<Body>, next: Next) -> Response {
require_authenticated(state, req, next).await
}
/// Opportunistic data-plane variant: always inserts an
/// `Extension<Option<Principal>>` and forwards the request. Used on
/// `/execute/{id}` and the user-route fallback, where most invocations
/// are anonymous public HTTP and the few authed ones (dashboard
/// test-runs, API keys) should still let scripts see the caller via
/// `cx.principal` once services consume it.
///
/// Failure modes — all degrade to `None` rather than rejecting:
/// * No bearer / cookie → `None`.
/// * Malformed or unknown token → `None`.
/// * DB blip while resolving → `None` (fail-open; the data plane
/// should not 500 on transient infra failures for an *optional*
/// identity check).
///
/// Admin-side routes that REQUIRE an identity keep using
/// `require_authenticated`.
pub async fn attach_principal_if_present(
State(state): State<AuthState>,
mut req: Request<Body>,
next: Next,
) -> Response {
let principal: Option<Principal> = match extract_token(&req) {
Some(token) => resolve_principal(&state, &token).await.unwrap_or(None),
None => None,
};
req.extensions_mut().insert(principal);
next.run(req).await
}
/// Decide whether the token is an API key (pic_ prefix) or a session
/// token, then resolve the corresponding `Principal`. `Ok(None)`
/// means the token was structurally valid but didn't match any active
/// credential; `Err(InternalError)` means a DB blip.
async fn resolve_principal(
state: &AuthState,
token: &str,
) -> Result<Option<Principal>, InternalError> {
// F-P-009: cache hit short-circuits Argon2 + 3 DB round-trips.
// Keyed on the hashed token so we never store raw bearer values.
let token_hash = hash_token(token);
if let Some(p) = state.principal_cache.get(&token_hash) {
return Ok(Some(p));
}
let resolved = if let Some(rest) = token.strip_prefix(API_KEY_PREFIX) {
verify_api_key(state, rest).await?
} else {
verify_session(state, token).await?
};
if let Some(p) = &resolved {
state.principal_cache.insert(token_hash, p.clone());
}
Ok(resolved)
}
async fn verify_session(
state: &AuthState,
token: &str,
) -> Result<Option<Principal>, InternalError> {
let token_hash = hash_token(token);
let lookup = match state.sessions.lookup(&token_hash).await {
Ok(Some(l)) => l,
Ok(None) => return Ok(None),
Err(err) => {
tracing::error!(?err, "admin_sessions lookup failed");
return Err(InternalError);
}
};
let user = match state.users.get(lookup.user_id).await {
Ok(Some(u)) if u.is_active => u,
Ok(_) => return Ok(None),
Err(err) => {
tracing::error!(?err, "admin_users lookup failed");
return Err(InternalError);
}
};
// Sliding-window bump — inline so a DB blip surfaces as 500 rather
// than silent stale sessions. Same shape as Phase 3a.
let new_expires_at = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default();
if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await {
tracing::error!(?err, "admin_sessions touch failed");
return Err(InternalError);
}
Ok(Some(Principal {
user_id: user.id,
instance_role: user.instance_role,
scopes: None,
app_binding: None,
}))
}
/// API-key verification path. `rest` is the portion of the bearer
/// value *after* `pic_`. We slice off the first 8 chars as the
/// indexed lookup key, then Argon2id-verify each candidate's hash
/// against the full `rest`. At most one match is expected; multiple
/// candidates with the same prefix is statistically negligible but
/// handled correctly (verify each, take the first match).
async fn verify_api_key(state: &AuthState, rest: &str) -> Result<Option<Principal>, InternalError> {
if rest.len() <= API_KEY_PREFIX_LEN {
return Ok(None);
}
let prefix = &rest[..API_KEY_PREFIX_LEN];
let mut candidates = match state.keys.find_active_by_prefix(prefix).await {
Ok(v) => v,
Err(err) => {
tracing::error!(?err, "api_keys lookup failed");
return Err(InternalError);
}
};
// F-S-006: bound the candidate set. Prefix collisions are
// statistically rare for a 32-byte random body, but the 8-char
// index space is small enough that an attacker who provisions
// many keys against one indexed prefix could amplify each public
// bearer-tagged request into M×Argon2 verifies. Cap at MAX and
// log the truncation so operators can spot it.
if candidates.len() > MAX_API_KEY_CANDIDATES {
tracing::warn!(
prefix,
total = candidates.len(),
kept = MAX_API_KEY_CANDIDATES,
"api_keys prefix-bucket overflow; truncating candidate verify set"
);
candidates.truncate(MAX_API_KEY_CANDIDATES);
}
// F-P-002: Argon2id verify is CPU-bound (~tens of ms each at OWASP
// defaults). Move it off the Tokio async worker so a hot user with
// N prefix-colliding keys doesn't park the worker for N×Argon2.
let rest_owned = rest.to_string();
let matched: Option<ApiKeyVerification> = tokio::task::spawn_blocking(move || {
candidates
.into_iter()
.find(|c| verify_password(&c.hash, &rest_owned))
})
.await
.map_err(|e| {
tracing::error!(error = ?e, "verify_api_key spawn_blocking join failed");
InternalError
})?;
let Some(matched) = matched else {
return Ok(None);
};
// Resolve the owning user. is_active = false → reject even if the
// key itself hasn't been expired yet (the expire_all_for_user
// cascade on deactivation is the primary defense; this is the
// belt-and-suspenders check at request time).
let user = match state.users.get(matched.user_id).await {
Ok(Some(u)) if u.is_active => u,
Ok(_) => return Ok(None),
Err(err) => {
tracing::error!(?err, "admin_users lookup for api key failed");
return Err(InternalError);
}
};
if let Err(err) = state.keys.touch_last_used(matched.id).await {
tracing::error!(?err, "api_keys touch_last_used failed");
// Soft-fail: a timestamp blip should not invalidate the
// request. Continue with the resolved Principal.
}
Ok(Some(Principal {
user_id: user.id,
instance_role: user.instance_role,
scopes: Some(matched.scopes),
app_binding: matched.app_id,
}))
}
/// Best-effort username lookup for the legacy `AuthedAdmin` extension.
/// Returns `None` on DB error (the caller treats `None` as "skip the
/// legacy extension"). New handlers use `Principal` and don't depend
/// on this.
async fn username_for(state: &AuthState, id: AdminUserId) -> Option<String> {
match state.users.get(id).await {
Ok(Some(u)) => Some(u.username),
Ok(None) => None,
Err(err) => {
tracing::warn!(
?err,
"username lookup for AuthedAdmin failed; skipping legacy ext"
);
None
}
}
}
/// Pull the bearer token out of an `Authorization` header. Cookie auth
/// was removed in the audit 2026-06-11 C-1 fix; same-origin user-route
/// HTML made `SameSite=Lax` insufficient against CSRF.
fn extract_token(req: &Request<Body>) -> Option<String> {
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())
}
/// Sentinel returned from the resolve functions when a DB error should
/// produce a 500 rather than a 401. Empty struct because the actual
/// error is already logged at the failure site.
struct InternalError;
fn unauthorized() -> Response {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "authentication required" })),
)
.into_response()
}
fn internal_error() -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "internal error" })),
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::Request;
use picloud_shared::InstanceRole;
fn req_with_header(name: &str, value: &str) -> Request<Body> {
Request::builder()
.header(name, value)
.body(Body::empty())
.unwrap()
}
#[test]
fn extracts_bearer_token() {
let r = req_with_header("authorization", "Bearer abc123");
assert_eq!(extract_token(&r).as_deref(), Some("abc123"));
}
#[test]
fn extracts_bearer_pic_prefixed_token() {
let r = req_with_header("authorization", "Bearer pic_abcdefghIJKL");
assert_eq!(extract_token(&r).as_deref(), Some("pic_abcdefghIJKL"));
}
#[test]
fn ignores_bearer_with_no_token() {
let r = req_with_header("authorization", "Bearer ");
assert_eq!(extract_token(&r), None);
}
#[test]
fn cookie_alone_is_rejected_post_audit_2026_06_11() {
// C-1 closure: `picloud_session` cookie is no longer accepted as
// an auth fallback; a same-origin malicious script could ride the
// cookie via SameSite=Lax POST. Bearer-only.
let r = req_with_header("cookie", "foo=bar; picloud_session=xyz; baz=qux");
assert_eq!(extract_token(&r), None);
}
#[test]
fn bearer_wins_when_both_present() {
let r = Request::builder()
.header("authorization", "Bearer header-token")
.header("cookie", "picloud_session=cookie-token")
.body(Body::empty())
.unwrap();
assert_eq!(extract_token(&r).as_deref(), Some("header-token"));
}
#[test]
fn returns_none_when_no_authorization_header() {
let r = Request::builder().body(Body::empty()).unwrap();
assert_eq!(extract_token(&r), None);
}
// Round-trip test for the unused-variable to keep `Principal`
// visibly tied to InstanceRole — caught a real bug during dev when
// the field order in the struct literal had drifted.
#[test]
fn principal_construction_is_explicit() {
let p = Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: None,
app_binding: None,
};
assert_eq!(p.instance_role, InstanceRole::Owner);
assert!(p.scopes.is_none());
assert!(p.app_binding.is_none());
}
fn principal_for(user_id: AdminUserId) -> Principal {
Principal {
user_id,
instance_role: InstanceRole::Owner,
scopes: None,
app_binding: None,
}
}
#[test]
fn evict_user_drops_only_that_users_entries() {
// Audit 2026-06-11 (PrincipalCache revocation-lag).
let cache = PrincipalCache::new();
let alice = AdminUserId::new();
let bob = AdminUserId::new();
cache.insert("hash-alice-1".into(), principal_for(alice));
cache.insert("hash-alice-2".into(), principal_for(alice));
cache.insert("hash-bob-1".into(), principal_for(bob));
cache.evict_user(alice);
assert!(cache.get("hash-alice-1").is_none());
assert!(cache.get("hash-alice-2").is_none());
// Bob is untouched.
assert!(cache.get("hash-bob-1").is_some());
}
#[test]
fn evict_token_drops_only_that_entry() {
let cache = PrincipalCache::new();
let alice = AdminUserId::new();
cache.insert("hash-1".into(), principal_for(alice));
cache.insert("hash-2".into(), principal_for(alice));
cache.evict_token("hash-1");
// Logout drops exactly one session; the user's other live
// session stays cached.
assert!(cache.get("hash-1").is_none());
assert!(cache.get("hash-2").is_some());
}
}