feat(manager-core): admin auth gate (Phase 3a)
Closes the regression risk of the admin API and dashboard being open
to anyone reaching the bound port. Required foundation before v1.1
data-plane services land.
Per-user accounts (admin_users), Argon2id passwords, env-var bootstrap
of the first admin that becomes inert once any admin exists, opaque
32-byte session token doubling as bearer credential, 24h sliding TTL
configurable via PICLOUD_SESSION_TTL_HOURS. is_active column lets
admins be deactivated without losing audit history; last-active-admin
guard on DELETE and on PATCH that flips is_active to false (sessions
also wiped on deactivation).
require_admin middleware fronts every /api/v1/admin/* route. The data
plane (/api/v1/execute/{id}), /healthz, /version, and user routes
stay open. picloud admin reset-password <username> subcommand handles
recovery without going through HTTP.
Dashboard gains /admin/login and /admin/admins surfaces, a top-bar
user menu, and a token store with a localStorage echo so refreshes
don't sign you out. Cookie-based auth works in parallel for non-SPA
clients.
Forward compatibility: future RBAC tables (admin_roles,
admin_user_roles) join on admin_users.id; the auth middleware is the
seam where role checks slot in. Email, 2FA, passkeys, and personal
API tokens are all additive without touching admin_users.
Blueprint §11.4 updated to reflect what actually shipped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
233
crates/manager-core/src/auth_api.rs
Normal file
233
crates/manager-core/src/auth_api.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
//! `/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;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::auth::{generate_session_token, hash_token, verify_password};
|
||||
use crate::auth_middleware::{require_admin, AuthState, AuthedAdmin, 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_admin));
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>) -> 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();
|
||||
}
|
||||
};
|
||||
|
||||
let (stored_hash, user_id, username, is_active) = match creds {
|
||||
Some(c) => (c.password_hash, Some(c.id), c.username, c.is_active),
|
||||
None => (DUMMY_HASH.to_string(), None, String::new(), 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();
|
||||
|
||||
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_id,
|
||||
username,
|
||||
},
|
||||
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
|
||||
// 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(Extension(admin): Extension<AuthedAdmin>) -> Json<AdminUserDto> {
|
||||
Json(AdminUserDto {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 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<Body>) -> Option<String> {
|
||||
// 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()
|
||||
}
|
||||
Reference in New Issue
Block a user