//! `/api/v1/admin/auth/*` — login, logout, who-am-I. //! //! Login mints an opaque session token, stores its SHA-256, sets the //! `picloud_session` HttpOnly cookie, and also returns the raw token in //! the JSON body for non-browser clients. The same token works as //! `Authorization: Bearer …` afterward; there is no separate "API //! token" concept yet. //! //! 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, SESSION_COOKIE}; 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, Json(input): Json) -> Response { // Always perform a verify, even on missing/inactive users, to flatten // timing and prevent username enumeration. The dummy hash is a real // Argon2id PHC string for "x" — the verify will simply fail. const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$dGltaW5nLWZsYXR0ZW4$Ux6dgPqgX1Mhg5fRgIeKZF3MWdYqJplKEz/cKLcSdks"; 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 => (DUMMY_HASH.to_string(), None, false), }; let password_ok = verify_password(&stored_hash, &input.password); 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"); } let mut headers = HeaderMap::new(); headers.insert( header::SET_COOKIE, HeaderValue::from_str(&build_cookie(&token.raw, state.ttl)).unwrap_or_else(|_| { // Cookie text is ASCII-clean by construction; this branch is // unreachable in practice but the type signature requires it. HeaderValue::from_static("") }), ); ( StatusCode::OK, 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() } async fn logout(State(state): State, req: Request) -> Response { // Pull token without requiring a valid session (logout is idempotent // and we still want to clear the cookie on the client side). 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"); // Still clear the cookie below. } } let mut headers = HeaderMap::new(); headers.insert( header::SET_COOKIE, HeaderValue::from_static("picloud_session=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0"), ); (StatusCode::NO_CONTENT, headers).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 build_cookie(raw_token: &str, ttl: std::time::Duration) -> String { // Secure is on by default; flip to off for HTTP-only dev with // PICLOUD_COOKIE_SECURE=0. The header-injected bearer token works // either way, so this is purely for browsers that prefer the cookie // path (e.g., direct API hits without the dashboard's auth.ts). let secure = std::env::var("PICLOUD_COOKIE_SECURE").ok().is_none_or(|v| { !matches!( v.to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off" ) }); let secure_attr = if secure { "; Secure" } else { "" }; format!( "{SESSION_COOKIE}={raw_token}; HttpOnly{secure_attr}; SameSite=Lax; Path=/; Max-Age={}", ttl.as_secs() ) } fn extract_token_for_logout(req: &Request) -> Option { // Same precedence as the middleware — Authorization first, cookie // fallback. Duplicated here because logout has to read the request // before any middleware would run. if let Some(value) = req.headers().get(header::AUTHORIZATION) { if let Ok(s) = value.to_str() { if let Some(token) = s.strip_prefix("Bearer ") { let trimmed = token.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } } } } if let Some(value) = req.headers().get(header::COOKIE) { if let Ok(s) = value.to_str() { for chunk in s.split(';') { let chunk = chunk.trim(); if let Some(rest) = chunk.strip_prefix(&format!("{SESSION_COOKIE}=")) { if !rest.is_empty() { return Some(rest.to_string()); } } } } } None } 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() }