Files
PiCloud/crates/manager-core/src/auth_api.rs
MechaCat02 3688c26cb4 feat(manager-core,picloud): expose instance_role + email on /auth/me
Login and /auth/me now return the same shape — id, username,
instance_role, email — so the dashboard can gate UI on role from
either the login response or the layout's me() refetch without an
extra round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:39:06 +02:00

270 lines
9.3 KiB
Rust

//! `/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<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>, 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();
}
};
// 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<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(
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 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()
}