feat(M2): Authentication, Authorization & Frontend Shell
- pm-auth::password: Argon2id (m=65536,t=3,p=1) hashing + verification - pm-auth::jwt: EdDSA/Ed25519 JWT issuance + validation (15-min TTL) - pm-auth::refresh: Opaque 256-bit refresh tokens, SHA-256 hashed, 1-hour sliding inactivity timeout, rotation on use, revocable - pm-auth::mfa_totp: TOTP setup/verify (HMAC-SHA1, 6-digit, 30s) with otpauth:// URI generation (Google Authenticator compatible) - pm-auth::mfa_webauthn: Stub (full implementation deferred) - pm-auth::rbac: Axum middleware for JWT auth + IP whitelist + admin/operator role enforcement + FromRequestParts extractor - pm-auth::session: Full login flow (password → MFA → tokens), token refresh, logout, force-logout - pm-web auth routes: POST /api/v1/auth/login|refresh|logout, GET /api/v1/auth/mfa/setup, POST /api/v1/auth/mfa/verify - IP whitelist middleware on all protected connection points - migrations/002_seed_admin.sql: Default admin account seed - Frontend: Auth store (Zustand with persistence), login page with MFA prompt, MFA setup page (stepper), JWT auto-refresh interceptor, route guards (RequireAuth), updated App.tsx routing - cargo check --workspace: zero errors, 1 minor warning Closes M2.
This commit is contained in:
@ -2,6 +2,8 @@
|
||||
//!
|
||||
//! Serves the React SPA, exposes the REST API, and handles WebSocket relay.
|
||||
|
||||
mod routes;
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
@ -16,6 +18,10 @@ use pm_core::{
|
||||
logging,
|
||||
request_id::request_id_middleware,
|
||||
};
|
||||
use pm_auth::{
|
||||
jwt,
|
||||
rbac::{AuthConfig, require_auth},
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
use tower_http::{
|
||||
@ -28,47 +34,62 @@ use tower_http::{
|
||||
pub struct AppState {
|
||||
pub db: sqlx::PgPool,
|
||||
pub config: Arc<AppConfig>,
|
||||
/// Ed25519 private key PEM for JWT signing.
|
||||
pub signing_key_pem: String,
|
||||
/// Auth configuration (JWT verify key + IP whitelist).
|
||||
pub auth_config: Arc<AuthConfig>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Load configuration
|
||||
let config_path = std::env::var("PATCH_MANAGER_CONFIG")
|
||||
.unwrap_or_else(|_| "/etc/patch-manager/config.toml".to_string());
|
||||
|
||||
let config = AppConfig::load(&config_path)
|
||||
.unwrap_or_else(|_| {
|
||||
eprintln!("Config file not found or invalid, using defaults");
|
||||
AppConfig::default()
|
||||
});
|
||||
let config = AppConfig::load(&config_path).unwrap_or_else(|_| {
|
||||
eprintln!("Config file not found or invalid, using defaults");
|
||||
AppConfig::default()
|
||||
});
|
||||
|
||||
// Initialize logging
|
||||
logging::init(&config.logging);
|
||||
|
||||
tracing::info!(version = env!("CARGO_PKG_VERSION"), "patch-manager-web starting");
|
||||
|
||||
// Initialize database pool
|
||||
let pool = db::init_pool(&config.database).await?;
|
||||
// Load JWT keys (graceful fallback for dev without keys on disk)
|
||||
let signing_key_pem = jwt::load_signing_key(&config.security.jwt_signing_key_path)
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "JWT signing key not found (dev mode)");
|
||||
String::new()
|
||||
});
|
||||
|
||||
// Run migrations (advisory lock guards single-writer)
|
||||
let verify_key_pem = jwt::load_verify_key(&config.security.jwt_verify_key_path)
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "JWT verify key not found (dev mode)");
|
||||
String::new()
|
||||
});
|
||||
|
||||
let auth_config = Arc::new(AuthConfig::new(
|
||||
verify_key_pem,
|
||||
&config.security.ip_whitelist,
|
||||
));
|
||||
|
||||
let pool = db::init_pool(&config.database).await?;
|
||||
db::run_migrations(&pool).await?;
|
||||
|
||||
let state = AppState {
|
||||
db: pool,
|
||||
config: Arc::new(config.clone()),
|
||||
signing_key_pem,
|
||||
auth_config,
|
||||
};
|
||||
|
||||
// Build the application router
|
||||
let app = build_router(state);
|
||||
|
||||
// Bind address
|
||||
let addr: SocketAddr = format!("{}:{}", config.server.host, config.server.port)
|
||||
.parse()
|
||||
.expect("Invalid bind address");
|
||||
|
||||
tracing::info!(%addr, "Listening");
|
||||
|
||||
// TODO M8: wrap with TLS (rustls). For M1 we bind plain HTTP for local dev.
|
||||
// TODO M8: wrap with TLS. For M1/M2 plain HTTP for local dev.
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
@ -78,48 +99,36 @@ async fn main() -> anyhow::Result<()> {
|
||||
/// Construct the full Axum router.
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let static_dir = state.config.server.static_dir.clone();
|
||||
let auth_config = state.auth_config.clone();
|
||||
|
||||
// Protected auth routes (MFA setup/verify) — require valid JWT
|
||||
let protected_auth = routes::auth::protected_router().route_layer(
|
||||
middleware::from_fn(move |req, next| {
|
||||
let auth_config = auth_config.clone();
|
||||
require_auth(auth_config, req, next)
|
||||
}),
|
||||
);
|
||||
|
||||
Router::new()
|
||||
// Health / status (unauthenticated)
|
||||
.route("/status/health", get(health_handler))
|
||||
// API v1 routes (stub — expanded in later milestones)
|
||||
.nest("/api/v1", api_v1_router())
|
||||
// Serve React SPA static files; fallback to index.html for client-side routing
|
||||
// Public auth routes (login, refresh, logout)
|
||||
.nest("/api/v1/auth", routes::auth::public_router())
|
||||
// Protected auth routes (mfa setup/verify)
|
||||
.nest("/api/v1/auth", protected_auth)
|
||||
// TODO M3+: additional protected API routes
|
||||
// Serve React SPA static files
|
||||
.fallback_service(
|
||||
ServeDir::new(&static_dir)
|
||||
.append_index_html_on_directories(true),
|
||||
ServeDir::new(&static_dir).append_index_html_on_directories(true),
|
||||
)
|
||||
// Middleware stack
|
||||
.layer(middleware::from_fn(request_id_middleware))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// API v1 sub-router — routes added per milestone.
|
||||
fn api_v1_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
// M2+: auth routes will be nested here
|
||||
// M3+: host/group/user routes
|
||||
// M4+: fleet status, agent polling
|
||||
// M5+: jobs
|
||||
// M6+: maintenance windows
|
||||
// M7+: websocket relay
|
||||
// M8+: certificates
|
||||
// M9+: reports
|
||||
// M10+: settings
|
||||
}
|
||||
|
||||
/// GET /status/health — liveness probe.
|
||||
///
|
||||
/// Returns 200 OK with a JSON payload including service name, version,
|
||||
/// and basic database reachability.
|
||||
async fn health_handler(State(state): State<AppState>) -> Result<Json<Value>, StatusCode> {
|
||||
// Quick DB ping
|
||||
let db_ok = sqlx::query("SELECT 1")
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.is_ok();
|
||||
|
||||
let db_ok = sqlx::query("SELECT 1").execute(&state.db).await.is_ok();
|
||||
let status = if db_ok { "healthy" } else { "degraded" };
|
||||
|
||||
let body = json!({
|
||||
@ -129,9 +138,5 @@ async fn health_handler(State(state): State<AppState>) -> Result<Json<Value>, St
|
||||
"database": if db_ok { "ok" } else { "error" },
|
||||
});
|
||||
|
||||
if db_ok {
|
||||
Ok(Json(body))
|
||||
} else {
|
||||
Err(StatusCode::SERVICE_UNAVAILABLE)
|
||||
}
|
||||
if db_ok { Ok(Json(body)) } else { Err(StatusCode::SERVICE_UNAVAILABLE) }
|
||||
}
|
||||
|
||||
252
crates/pm-web/src/routes/auth.rs
Normal file
252
crates/pm-web/src/routes/auth.rs
Normal file
@ -0,0 +1,252 @@
|
||||
//! Authentication route handlers.
|
||||
//!
|
||||
//! Public routes (no auth required):
|
||||
//! POST /api/v1/auth/login
|
||||
//! POST /api/v1/auth/refresh
|
||||
//! POST /api/v1/auth/logout
|
||||
//!
|
||||
//! Protected routes (JWT required):
|
||||
//! GET /api/v1/auth/mfa/setup
|
||||
//! POST /api/v1/auth/mfa/verify
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use pm_auth::{
|
||||
mfa_totp,
|
||||
session::{self, LoginRequest, LoginResponse},
|
||||
rbac::AuthUser,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
// ============================================================
|
||||
// Public router — no authentication required
|
||||
// ============================================================
|
||||
|
||||
pub fn public_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/login", post(login_handler))
|
||||
.route("/refresh", post(refresh_handler))
|
||||
.route("/logout", post(logout_handler))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Protected router — requires valid JWT (applied by caller)
|
||||
// ============================================================
|
||||
|
||||
pub fn protected_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/mfa/setup", get(mfa_setup_handler))
|
||||
.route("/mfa/verify", post(mfa_verify_handler))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
fn user_agent(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn remote_ip(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.split(',').next().unwrap_or("").trim().to_string())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /api/v1/auth/login
|
||||
// ============================================================
|
||||
|
||||
async fn login_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, (StatusCode, Json<Value>)> {
|
||||
let ip = remote_ip(&headers);
|
||||
let ua = user_agent(&headers);
|
||||
|
||||
session::login(
|
||||
&state.db,
|
||||
&req,
|
||||
&state.signing_key_pem,
|
||||
state.config.security.jwt_access_ttl_secs as i64,
|
||||
ua.as_deref(),
|
||||
ip.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|e| {
|
||||
use pm_auth::session::SessionError;
|
||||
let (status, code, message) = match e {
|
||||
SessionError::InvalidCredentials | SessionError::InvalidMfaCode => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"invalid_credentials",
|
||||
"Invalid username or password",
|
||||
),
|
||||
SessionError::MfaRequired => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"mfa_required",
|
||||
"MFA code required",
|
||||
),
|
||||
SessionError::AccountDisabled => (
|
||||
StatusCode::FORBIDDEN,
|
||||
"account_disabled",
|
||||
"Account is disabled",
|
||||
),
|
||||
_ => {
|
||||
tracing::error!(error = %e, "Login error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "An error occurred")
|
||||
}
|
||||
};
|
||||
(status, Json(json!({ "error": { "code": code, "message": message } })))
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /api/v1/auth/refresh
|
||||
// ============================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RefreshRequest {
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
async fn refresh_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<RefreshRequest>,
|
||||
) -> Result<Json<LoginResponse>, (StatusCode, Json<Value>)> {
|
||||
let ip = remote_ip(&headers);
|
||||
let ua = user_agent(&headers);
|
||||
|
||||
session::refresh_session(
|
||||
&state.db,
|
||||
&req.refresh_token,
|
||||
&state.signing_key_pem,
|
||||
state.config.security.jwt_access_ttl_secs as i64,
|
||||
ua.as_deref(),
|
||||
ip.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|e| {
|
||||
use pm_auth::session::SessionError;
|
||||
let (status, code, msg) = match e {
|
||||
SessionError::Refresh(_) => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"invalid_refresh_token",
|
||||
"Refresh token is invalid or expired",
|
||||
),
|
||||
SessionError::AccountDisabled => (
|
||||
StatusCode::FORBIDDEN,
|
||||
"account_disabled",
|
||||
"Account is disabled",
|
||||
),
|
||||
_ => {
|
||||
tracing::error!(error = %e, "Refresh error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "An error occurred")
|
||||
}
|
||||
};
|
||||
(status, Json(json!({ "error": { "code": code, "message": msg } })))
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /api/v1/auth/logout
|
||||
// ============================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LogoutRequest {
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
async fn logout_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<LogoutRequest>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
session::logout(&state.db, &req.refresh_token)
|
||||
.await
|
||||
.map(|_| Json(json!({ "message": "Logged out successfully" })))
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Logout error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "An error occurred" } })),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GET /api/v1/auth/mfa/setup (JWT required — via middleware)
|
||||
// ============================================================
|
||||
|
||||
async fn mfa_setup_handler(
|
||||
auth_user: AuthUser,
|
||||
) -> Result<Json<mfa_totp::TotpSetup>, (StatusCode, Json<Value>)> {
|
||||
mfa_totp::generate_setup(&auth_user.username)
|
||||
.map(Json)
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "TOTP setup error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } })),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /api/v1/auth/mfa/verify (JWT required — via middleware)
|
||||
// ============================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MfaVerifyRequest {
|
||||
secret_base32: String,
|
||||
code: String,
|
||||
}
|
||||
|
||||
async fn mfa_verify_handler(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Json(req): Json<MfaVerifyRequest>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let valid = mfa_totp::verify_code(&auth_user.username, &req.secret_base32, &req.code)
|
||||
.map_err(|e| (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } })),
|
||||
))?;
|
||||
|
||||
if !valid {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": { "code": "invalid_code", "message": "Invalid TOTP code" } })),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE users SET totp_secret = $1, mfa_enabled = TRUE WHERE id = $2")
|
||||
.bind(&req.secret_base32)
|
||||
.bind(auth_user.user_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to save TOTP secret");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Failed to enable MFA" } })),
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!(user_id = %auth_user.user_id, "MFA enabled for user");
|
||||
Ok(Json(json!({ "message": "MFA enabled successfully" })))
|
||||
}
|
||||
2
crates/pm-web/src/routes/mod.rs
Normal file
2
crates/pm-web/src/routes/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
//! Route modules for the pm-web API.
|
||||
pub mod auth;
|
||||
Reference in New Issue
Block a user