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:
@ -9,6 +9,7 @@ license.workspace = true
|
||||
pm-core = { path = "../pm-core" }
|
||||
tokio = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
axum-extra = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@ -17,3 +18,11 @@ anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
argon2 = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
totp-rs = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
ipnet = { workspace = true }
|
||||
|
||||
@ -1 +1,156 @@
|
||||
//! jwt — stub for M2.
|
||||
//! JWT issuance and validation using EdDSA / Ed25519.
|
||||
//!
|
||||
//! - Access tokens: 15-minute TTL, signed with Ed25519 private key
|
||||
//! - Key rotation: 90-day cycle with 24-hour overlap window
|
||||
//! - The web process holds the signing key; worker holds only the public key
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// JWT algorithm — EdDSA with Ed25519 curve.
|
||||
const JWT_ALGORITHM: Algorithm = Algorithm::EdDSA;
|
||||
|
||||
/// Default access token TTL in seconds.
|
||||
pub const DEFAULT_ACCESS_TTL_SECS: i64 = 900; // 15 minutes
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum JwtError {
|
||||
#[error("Failed to encode JWT: {0}")]
|
||||
Encode(String),
|
||||
#[error("Failed to decode JWT: {0}")]
|
||||
Decode(String),
|
||||
#[error("Token is expired")]
|
||||
Expired,
|
||||
#[error("Token has invalid claims")]
|
||||
InvalidClaims,
|
||||
#[error("Failed to load signing key: {0}")]
|
||||
KeyLoad(String),
|
||||
}
|
||||
|
||||
/// Standard JWT claims for access tokens.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AccessClaims {
|
||||
/// Subject: user ID (UUID)
|
||||
pub sub: String,
|
||||
/// Issued at (Unix timestamp)
|
||||
pub iat: i64,
|
||||
/// Expiry (Unix timestamp)
|
||||
pub exp: i64,
|
||||
/// JWT ID (unique per token)
|
||||
pub jti: String,
|
||||
/// User role: "admin" or "operator"
|
||||
pub role: String,
|
||||
/// Username (for display / logging)
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
impl AccessClaims {
|
||||
/// Create new claims for the given user.
|
||||
pub fn new(user_id: Uuid, username: &str, role: &str, ttl_secs: i64) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
sub: user_id.to_string(),
|
||||
iat: now.timestamp(),
|
||||
exp: (now + Duration::seconds(ttl_secs)).timestamp(),
|
||||
jti: Uuid::new_v4().to_string(),
|
||||
role: role.to_string(),
|
||||
username: username.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the token is expired (redundant with validation but useful for explicit checks).
|
||||
pub fn is_expired(&self) -> bool {
|
||||
Utc::now().timestamp() > self.exp
|
||||
}
|
||||
|
||||
/// Return the user UUID parsed from the `sub` field.
|
||||
pub fn user_id(&self) -> Result<Uuid, JwtError> {
|
||||
Uuid::parse_str(&self.sub).map_err(|_| JwtError::InvalidClaims)
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue an access token signed with the Ed25519 private key PEM.
|
||||
pub fn issue_access_token(
|
||||
user_id: Uuid,
|
||||
username: &str,
|
||||
role: &str,
|
||||
ttl_secs: i64,
|
||||
signing_key_pem: &str,
|
||||
) -> Result<String, JwtError> {
|
||||
let claims = AccessClaims::new(user_id, username, role, ttl_secs);
|
||||
|
||||
let key = EncodingKey::from_ed_pem(signing_key_pem.as_bytes())
|
||||
.map_err(|e| JwtError::KeyLoad(e.to_string()))?;
|
||||
|
||||
let header = Header::new(JWT_ALGORITHM);
|
||||
|
||||
encode(&header, &claims, &key).map_err(|e| JwtError::Encode(e.to_string()))
|
||||
}
|
||||
|
||||
/// Validate and decode an access token using the Ed25519 public key PEM.
|
||||
pub fn validate_access_token(
|
||||
token: &str,
|
||||
verify_key_pem: &str,
|
||||
) -> Result<AccessClaims, JwtError> {
|
||||
let key = DecodingKey::from_ed_pem(verify_key_pem.as_bytes())
|
||||
.map_err(|e| JwtError::KeyLoad(e.to_string()))?;
|
||||
|
||||
let mut validation = Validation::new(JWT_ALGORITHM);
|
||||
validation.validate_exp = true;
|
||||
validation.leeway = 5; // 5-second clock skew tolerance
|
||||
|
||||
decode::<AccessClaims>(token, &key, &validation)
|
||||
.map(|data| data.claims)
|
||||
.map_err(|e| {
|
||||
if e.kind() == &jsonwebtoken::errors::ErrorKind::ExpiredSignature {
|
||||
JwtError::Expired
|
||||
} else {
|
||||
JwtError::Decode(e.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Load the Ed25519 signing key from a PEM file path.
|
||||
pub fn load_signing_key(path: &str) -> Result<String, JwtError> {
|
||||
std::fs::read_to_string(path)
|
||||
.map_err(|e| JwtError::KeyLoad(format!("Cannot read {path}: {e}")))
|
||||
}
|
||||
|
||||
/// Load the Ed25519 verification (public) key from a PEM file path.
|
||||
pub fn load_verify_key(path: &str) -> Result<String, JwtError> {
|
||||
std::fs::read_to_string(path)
|
||||
.map_err(|e| JwtError::KeyLoad(format!("Cannot read {path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Test keys generated with:
|
||||
// openssl genpkey -algorithm ed25519 -out signing.pem
|
||||
// openssl pkey -in signing.pem -pubout -out verify.pem
|
||||
const TEST_SIGNING_KEY: &str = "-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIHNzPc3LkpODUVFr8GjVPm4M2yiKrXsZ/1uJQ/tQMjNb
|
||||
-----END PRIVATE KEY-----
|
||||
";
|
||||
|
||||
const TEST_VERIFY_KEY: &str = "-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEA8nRzpCYzZ1xFKNJDGt9wuXdq7kKS/ck9PfLJu/r3VEw=
|
||||
-----END PUBLIC KEY-----
|
||||
";
|
||||
|
||||
// Note: real tests require valid key pairs; these are placeholders.
|
||||
// Integration tests in the test suite use generated keys.
|
||||
#[test]
|
||||
fn claims_construction() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let claims = AccessClaims::new(user_id, "admin", "admin", 900);
|
||||
assert_eq!(claims.sub, user_id.to_string());
|
||||
assert_eq!(claims.role, "admin");
|
||||
assert!(!claims.is_expired());
|
||||
assert_eq!(claims.user_id().unwrap(), user_id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,24 @@
|
||||
//! pm-auth — Authentication and authorization.
|
||||
//!
|
||||
//! Modules: password (Argon2id), jwt (EdDSA), refresh tokens,
|
||||
//! mfa_totp, mfa_webauthn, rbac, session.
|
||||
//!
|
||||
//! M1: Stub. Full implementation in M2.
|
||||
pub mod password;
|
||||
//! Modules:
|
||||
//! - `password` — Argon2id password hashing (m=65536, t=3, p=1)
|
||||
//! - `jwt` — EdDSA/Ed25519 JWT issuance and validation (15-min TTL)
|
||||
//! - `refresh` — Opaque 256-bit refresh tokens (1-hour sliding window)
|
||||
//! - `mfa_totp` — TOTP setup and verification (Google Authenticator compatible)
|
||||
//! - `mfa_webauthn` — WebAuthn stub (full implementation pending)
|
||||
//! - `rbac` — Axum middleware for JWT authentication and role enforcement
|
||||
//! - `session` — Login flow orchestration (password → MFA → tokens)
|
||||
|
||||
pub mod jwt;
|
||||
pub mod mfa_totp;
|
||||
pub mod mfa_webauthn;
|
||||
pub mod password;
|
||||
pub mod rbac;
|
||||
pub mod refresh;
|
||||
pub mod session;
|
||||
|
||||
// Commonly re-exported types
|
||||
pub use jwt::{AccessClaims, JwtError};
|
||||
pub use password::{hash_password, verify_password, PasswordError};
|
||||
pub use rbac::{AuthConfig, AuthUser, UserRole};
|
||||
pub use session::{LoginRequest, LoginResponse, SessionError, SessionUser};
|
||||
|
||||
103
crates/pm-auth/src/mfa_totp.rs
Normal file
103
crates/pm-auth/src/mfa_totp.rs
Normal file
@ -0,0 +1,103 @@
|
||||
//! TOTP (Time-based One-Time Password) MFA implementation.
|
||||
//!
|
||||
//! Uses TOTP-rs with HMAC-SHA1, 6-digit codes, 30-second window.
|
||||
//! Compatible with Google Authenticator, Authy, and standard TOTP apps.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use totp_rs::{Algorithm, Secret, TOTP};
|
||||
|
||||
/// TOTP issuer label shown in authenticator apps.
|
||||
const ISSUER: &str = "Linux Patch Manager";
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TotpError {
|
||||
#[error("Failed to create TOTP: {0}")]
|
||||
Creation(String),
|
||||
#[error("Invalid TOTP secret")]
|
||||
InvalidSecret,
|
||||
#[error("TOTP code verification failed")]
|
||||
VerificationFailed,
|
||||
}
|
||||
|
||||
/// TOTP setup response returned to the user during MFA enrollment.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TotpSetup {
|
||||
/// Base32-encoded secret for manual entry in authenticator apps.
|
||||
pub secret_base32: String,
|
||||
/// OTP Auth URI for QR code generation (otpauth://totp/...).
|
||||
pub otp_uri: String,
|
||||
}
|
||||
|
||||
/// Generate a new TOTP secret and return setup information.
|
||||
///
|
||||
/// The caller should store `secret_base32` in the database after
|
||||
/// the user verifies the first code.
|
||||
pub fn generate_setup(username: &str) -> Result<TotpSetup, TotpError> {
|
||||
let secret = Secret::generate_secret();
|
||||
let secret_base32 = secret.to_encoded().to_string();
|
||||
|
||||
let totp = build_totp(username, &secret_base32)?;
|
||||
let otp_uri = totp.get_url();
|
||||
|
||||
Ok(TotpSetup {
|
||||
secret_base32,
|
||||
otp_uri,
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify a TOTP code against the stored secret.
|
||||
///
|
||||
/// Accepts codes within a ±1 step window (±30 seconds) to handle clock skew.
|
||||
pub fn verify_code(username: &str, secret_base32: &str, code: &str) -> Result<bool, TotpError> {
|
||||
let totp = build_totp(username, secret_base32)?;
|
||||
let valid = totp
|
||||
.check_current(code)
|
||||
.map_err(|_| TotpError::VerificationFailed)?;
|
||||
Ok(valid)
|
||||
}
|
||||
|
||||
/// Build a TOTP instance from a base32 secret.
|
||||
fn build_totp(username: &str, secret_base32: &str) -> Result<TOTP, TotpError> {
|
||||
let secret = Secret::Encoded(secret_base32.to_string());
|
||||
let secret_bytes = secret.to_bytes().map_err(|_| TotpError::InvalidSecret)?;
|
||||
|
||||
// With the `otpauth` feature, TOTP::new signature is:
|
||||
// new(issuer, account_name, algorithm, digits, skew, step, secret)
|
||||
TOTP::new(
|
||||
Algorithm::SHA1,
|
||||
6, // digits
|
||||
1, // skew
|
||||
30, // step (seconds)
|
||||
secret_bytes,
|
||||
Some(ISSUER.to_string()),
|
||||
username.to_string(),
|
||||
)
|
||||
.map_err(|e| TotpError::Creation(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generate_setup_produces_valid_uri() {
|
||||
let setup = generate_setup("testuser").unwrap();
|
||||
assert!(!setup.secret_base32.is_empty());
|
||||
assert!(setup.otp_uri.starts_with("otpauth://totp/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_with_current_code() {
|
||||
let setup = generate_setup("testuser").unwrap();
|
||||
let totp = build_totp("testuser", &setup.secret_base32).unwrap();
|
||||
let code = totp.generate_current().unwrap();
|
||||
assert!(verify_code("testuser", &setup.secret_base32, &code).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_code_fails() {
|
||||
let setup = generate_setup("testuser").unwrap();
|
||||
assert!(!verify_code("testuser", &setup.secret_base32, "000000").unwrap());
|
||||
}
|
||||
}
|
||||
51
crates/pm-auth/src/mfa_webauthn.rs
Normal file
51
crates/pm-auth/src/mfa_webauthn.rs
Normal file
@ -0,0 +1,51 @@
|
||||
//! WebAuthn (FIDO2) MFA stub.
|
||||
//!
|
||||
//! Full implementation planned for M2 extension or M3.
|
||||
//! WebAuthn requires stateful registration/authentication ceremonies
|
||||
//! and a compatible client library (webauthn-rs).
|
||||
//!
|
||||
//! For M2, TOTP is the primary MFA method.
|
||||
//! WebAuthn credentials are stored in the `users.webauthn_credential` JSONB
|
||||
//! column and will be processed here when implemented.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WebAuthnError {
|
||||
#[error("WebAuthn not yet implemented")]
|
||||
NotImplemented,
|
||||
}
|
||||
|
||||
/// Placeholder for WebAuthn registration options.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RegistrationOptions {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Begin WebAuthn registration ceremony (stub).
|
||||
pub fn begin_registration(_username: &str) -> Result<RegistrationOptions, WebAuthnError> {
|
||||
Err(WebAuthnError::NotImplemented)
|
||||
}
|
||||
|
||||
/// Complete WebAuthn registration ceremony (stub).
|
||||
pub fn complete_registration(
|
||||
_username: &str,
|
||||
_response: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, WebAuthnError> {
|
||||
Err(WebAuthnError::NotImplemented)
|
||||
}
|
||||
|
||||
/// Begin WebAuthn authentication ceremony (stub).
|
||||
pub fn begin_authentication(_username: &str) -> Result<serde_json::Value, WebAuthnError> {
|
||||
Err(WebAuthnError::NotImplemented)
|
||||
}
|
||||
|
||||
/// Verify WebAuthn authentication response (stub).
|
||||
pub fn verify_authentication(
|
||||
_username: &str,
|
||||
_credential: &serde_json::Value,
|
||||
_response: &serde_json::Value,
|
||||
) -> Result<bool, WebAuthnError> {
|
||||
Err(WebAuthnError::NotImplemented)
|
||||
}
|
||||
@ -1 +1,93 @@
|
||||
//! password — stub for M2.
|
||||
//! Password hashing and verification using Argon2id.
|
||||
//!
|
||||
//! Parameters (calibrated per OWASP recommendations):
|
||||
//! - Algorithm: Argon2id
|
||||
//! - Memory cost: 65536 KiB (64 MiB)
|
||||
//! - Time cost: 3 iterations
|
||||
//! - Parallelism: 1
|
||||
|
||||
use argon2::{
|
||||
password_hash::{
|
||||
rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
|
||||
},
|
||||
Argon2, Params, Version,
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Argon2id parameters per spec.
|
||||
const M_COST: u32 = 65536; // 64 MiB
|
||||
const T_COST: u32 = 3; // 3 iterations
|
||||
const P_COST: u32 = 1; // 1 thread
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PasswordError {
|
||||
#[error("Failed to hash password: {0}")]
|
||||
HashError(String),
|
||||
#[error("Failed to verify password: {0}")]
|
||||
VerifyError(String),
|
||||
#[error("Invalid password hash format")]
|
||||
InvalidHash,
|
||||
}
|
||||
|
||||
/// Build an Argon2id instance with calibrated parameters.
|
||||
fn argon2() -> Result<Argon2<'static>, PasswordError> {
|
||||
let params = Params::new(M_COST, T_COST, P_COST, None)
|
||||
.map_err(|e| PasswordError::HashError(e.to_string()))?;
|
||||
Ok(Argon2::new(argon2::Algorithm::Argon2id, Version::V0x13, params))
|
||||
}
|
||||
|
||||
/// Hash a plaintext password using Argon2id with a random salt.
|
||||
///
|
||||
/// Returns the PHC string format hash suitable for storage.
|
||||
pub fn hash_password(password: &str) -> Result<String, PasswordError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = argon2()?;
|
||||
|
||||
let hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| PasswordError::HashError(e.to_string()))?;
|
||||
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
/// Verify a plaintext password against a stored Argon2id PHC hash.
|
||||
///
|
||||
/// Returns `Ok(true)` if the password matches, `Ok(false)` if not.
|
||||
pub fn verify_password(password: &str, hash: &str) -> Result<bool, PasswordError> {
|
||||
let parsed_hash =
|
||||
PasswordHash::new(hash).map_err(|_| PasswordError::InvalidHash)?;
|
||||
|
||||
let argon2 = argon2()?;
|
||||
|
||||
match argon2.verify_password(password.as_bytes(), &parsed_hash) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(argon2::password_hash::Error::Password) => Ok(false),
|
||||
Err(e) => Err(PasswordError::VerifyError(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hash_and_verify_roundtrip() {
|
||||
let password = "super-secret-password-123!";
|
||||
let hash = hash_password(password).unwrap();
|
||||
assert!(hash.starts_with("$argon2id$"));
|
||||
assert!(verify_password(password, &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_password_fails() {
|
||||
let hash = hash_password("correct-horse").unwrap();
|
||||
assert!(!verify_password("wrong-password", &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_salts_produce_different_hashes() {
|
||||
let hash1 = hash_password("same-password").unwrap();
|
||||
let hash2 = hash_password("same-password").unwrap();
|
||||
assert_ne!(hash1, hash2); // different salts
|
||||
}
|
||||
}
|
||||
|
||||
@ -1 +1,214 @@
|
||||
//! rbac — stub for M2.
|
||||
//! Role-Based Access Control (RBAC) middleware for Axum.
|
||||
//!
|
||||
//! Provides:
|
||||
//! - JWT extraction and validation from `Authorization: Bearer <token>` header
|
||||
//! - Role enforcement (`admin`, `operator`)
|
||||
//! - Group-scoped access (enforced at the handler level using `AuthUser` extension)
|
||||
//! - IP whitelist enforcement
|
||||
|
||||
use axum::{
|
||||
extract::Request,
|
||||
http::{HeaderMap, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Json, Response},
|
||||
};
|
||||
use ipnet::IpNet;
|
||||
use serde_json::json;
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::jwt::{validate_access_token, AccessClaims, JwtError};
|
||||
|
||||
/// User identity extracted from a validated JWT, inserted as a request extension.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthUser {
|
||||
pub user_id: Uuid,
|
||||
pub username: String,
|
||||
pub role: UserRole,
|
||||
pub claims: AccessClaims,
|
||||
}
|
||||
|
||||
/// Application roles.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UserRole {
|
||||
Admin,
|
||||
Operator,
|
||||
}
|
||||
|
||||
impl UserRole {
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"admin" => Some(Self::Admin),
|
||||
"operator" => Some(Self::Operator),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Admin => "admin",
|
||||
Self::Operator => "operator",
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin can do everything; operator has limited scope.
|
||||
pub fn is_admin(&self) -> bool {
|
||||
matches!(self, Self::Admin)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared auth configuration injected via Axum state.
|
||||
#[derive(Clone)]
|
||||
pub struct AuthConfig {
|
||||
/// Ed25519 public key PEM for JWT verification.
|
||||
pub verify_key_pem: String,
|
||||
/// IP whitelist (empty = allow all).
|
||||
pub ip_whitelist: Vec<IpNet>,
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
pub fn new(verify_key_pem: String, ip_whitelist_cidrs: &[String]) -> Self {
|
||||
let ip_whitelist = ip_whitelist_cidrs
|
||||
.iter()
|
||||
.filter_map(|cidr| IpNet::from_str(cidr).ok())
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
verify_key_pem,
|
||||
ip_whitelist,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an IP address is allowed by the whitelist.
|
||||
/// If the whitelist is empty, all IPs are allowed.
|
||||
pub fn is_ip_allowed(&self, ip: &IpAddr) -> bool {
|
||||
if self.ip_whitelist.is_empty() {
|
||||
return true;
|
||||
}
|
||||
self.ip_whitelist.iter().any(|net| net.contains(ip))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract `Authorization: Bearer <token>` from request headers.
|
||||
fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
}
|
||||
|
||||
/// Extract the remote IP from `X-Forwarded-For`.
|
||||
fn extract_remote_ip(headers: &HeaderMap) -> Option<IpAddr> {
|
||||
headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.split(',').next())
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
}
|
||||
|
||||
/// Unauthorized JSON response helper.
|
||||
fn unauthorized(message: &str) -> Response {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": { "code": "unauthorized", "message": message } })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Forbidden JSON response helper.
|
||||
fn forbidden(message: &str) -> Response {
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({ "error": { "code": "forbidden", "message": message } })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Middleware: authenticate any valid JWT (admin or operator).
|
||||
///
|
||||
/// Inserts `AuthUser` into request extensions on success.
|
||||
/// Rejects with 401 if token is missing/invalid, 403 if IP is blocked.
|
||||
pub async fn require_auth(
|
||||
auth_config: Arc<AuthConfig>,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// IP whitelist check
|
||||
if let Some(ip) = extract_remote_ip(req.headers()) {
|
||||
if !auth_config.is_ip_allowed(&ip) {
|
||||
tracing::warn!(ip = %ip, "Request blocked by IP whitelist");
|
||||
return forbidden("Access denied");
|
||||
}
|
||||
}
|
||||
|
||||
// Extract and validate JWT
|
||||
let token = match extract_bearer_token(req.headers()) {
|
||||
Some(t) => t,
|
||||
None => return unauthorized("Missing authorization token"),
|
||||
};
|
||||
|
||||
let claims = match validate_access_token(token, &auth_config.verify_key_pem) {
|
||||
Ok(c) => c,
|
||||
Err(JwtError::Expired) => return unauthorized("Token expired"),
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "JWT validation failed");
|
||||
return unauthorized("Invalid token");
|
||||
}
|
||||
};
|
||||
|
||||
let role = match UserRole::from_str(&claims.role) {
|
||||
Some(r) => r,
|
||||
None => return unauthorized("Invalid role in token"),
|
||||
};
|
||||
|
||||
let user_id = match claims.user_id() {
|
||||
Ok(id) => id,
|
||||
Err(_) => return unauthorized("Invalid user ID in token"),
|
||||
};
|
||||
|
||||
let auth_user = AuthUser {
|
||||
user_id,
|
||||
username: claims.username.clone(),
|
||||
role,
|
||||
claims,
|
||||
};
|
||||
|
||||
req.extensions_mut().insert(auth_user);
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
/// Middleware: require the `admin` role.
|
||||
/// Must be chained AFTER `require_auth` (which inserts `AuthUser`).
|
||||
pub async fn require_admin(req: Request, next: Next) -> Response {
|
||||
let auth_user = match req.extensions().get::<AuthUser>().cloned() {
|
||||
Some(u) => u,
|
||||
None => return unauthorized("Authentication required"),
|
||||
};
|
||||
|
||||
if !auth_user.role.is_admin() {
|
||||
return forbidden("Admin role required");
|
||||
}
|
||||
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
/// Axum extractor: pulls `AuthUser` from request extensions.
|
||||
impl<S> axum::extract::FromRequestParts<S> for AuthUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut axum::http::request::Parts,
|
||||
_state: &S,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
parts
|
||||
.extensions
|
||||
.get::<AuthUser>()
|
||||
.cloned()
|
||||
.ok_or_else(|| unauthorized("Authentication required"))
|
||||
}
|
||||
}
|
||||
|
||||
171
crates/pm-auth/src/refresh.rs
Normal file
171
crates/pm-auth/src/refresh.rs
Normal file
@ -0,0 +1,171 @@
|
||||
//! Opaque refresh token management.
|
||||
//!
|
||||
//! - 256-bit cryptographically random opaque tokens
|
||||
//! - Stored as SHA-256 hash in the database (never the raw token)
|
||||
//! - 1-hour sliding inactivity timeout, updated on each use
|
||||
//! - Rotated on use (old token revoked, new one issued)
|
||||
//! - Revocable by admin force-logout
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::PgPool;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Length of the raw refresh token in bytes (256 bits).
|
||||
const TOKEN_BYTES: usize = 32;
|
||||
|
||||
/// Sliding inactivity window: 1 hour.
|
||||
const INACTIVITY_TIMEOUT_HOURS: i64 = 1;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RefreshError {
|
||||
#[error("Refresh token not found or revoked")]
|
||||
Invalid,
|
||||
#[error("Refresh token expired")]
|
||||
Expired,
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
}
|
||||
|
||||
/// Raw (plaintext) refresh token — returned to the client, never stored.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RawRefreshToken(pub String);
|
||||
|
||||
impl RawRefreshToken {
|
||||
/// Hex-encode a raw 256-bit random token.
|
||||
pub fn generate() -> Self {
|
||||
let mut bytes = [0u8; TOKEN_BYTES];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
Self(hex::encode(bytes))
|
||||
}
|
||||
|
||||
/// Return the SHA-256 hash of this token for database storage.
|
||||
pub fn hash(&self) -> String {
|
||||
let digest = Sha256::digest(self.0.as_bytes());
|
||||
hex::encode(digest)
|
||||
}
|
||||
}
|
||||
|
||||
/// Database row representation of a stored refresh token.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct StoredRefreshToken {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub expires_at: chrono::DateTime<Utc>,
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
/// Issue a new refresh token for the given user and store it in the database.
|
||||
///
|
||||
/// Returns the raw (plaintext) token to be sent to the client.
|
||||
pub async fn issue(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
user_agent: Option<&str>,
|
||||
ip_address: Option<&str>,
|
||||
) -> Result<RawRefreshToken, RefreshError> {
|
||||
let token = RawRefreshToken::generate();
|
||||
let hash = token.hash();
|
||||
let expires_at = Utc::now() + Duration::hours(INACTIVITY_TIMEOUT_HOURS);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO refresh_tokens (user_id, token_hash, expires_at, user_agent, ip_address)
|
||||
VALUES ($1, $2, $3, $4, $5::inet)
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&hash)
|
||||
.bind(expires_at)
|
||||
.bind(user_agent)
|
||||
.bind(ip_address)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
tracing::debug!(user_id = %user_id, "Refresh token issued");
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Validate a refresh token, then rotate it (revoke old, issue new).
|
||||
///
|
||||
/// Returns `(new_raw_token, user_id)` if valid.
|
||||
pub async fn rotate(
|
||||
pool: &PgPool,
|
||||
raw_token: &str,
|
||||
user_agent: Option<&str>,
|
||||
ip_address: Option<&str>,
|
||||
) -> Result<(RawRefreshToken, Uuid), RefreshError> {
|
||||
let hash = hex::encode(Sha256::digest(raw_token.as_bytes()));
|
||||
let now = Utc::now();
|
||||
|
||||
// Look up token
|
||||
let row: Option<StoredRefreshToken> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, user_id, expires_at, revoked
|
||||
FROM refresh_tokens
|
||||
WHERE token_hash = $1
|
||||
"#,
|
||||
)
|
||||
.bind(&hash)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let stored = row.ok_or(RefreshError::Invalid)?;
|
||||
|
||||
if stored.revoked {
|
||||
return Err(RefreshError::Invalid);
|
||||
}
|
||||
|
||||
if stored.expires_at < now {
|
||||
return Err(RefreshError::Expired);
|
||||
}
|
||||
|
||||
// Revoke old token
|
||||
sqlx::query(
|
||||
"UPDATE refresh_tokens SET revoked = TRUE, revoked_at = NOW() WHERE id = $1",
|
||||
)
|
||||
.bind(stored.id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Issue new token
|
||||
let new_token = issue(pool, stored.user_id, user_agent, ip_address).await?;
|
||||
|
||||
tracing::debug!(user_id = %stored.user_id, "Refresh token rotated");
|
||||
Ok((new_token, stored.user_id))
|
||||
}
|
||||
|
||||
/// Revoke all refresh tokens for a user (force logout).
|
||||
pub async fn revoke_all_for_user(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
) -> Result<u64, RefreshError> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE refresh_tokens SET revoked = TRUE, revoked_at = NOW() WHERE user_id = $1 AND revoked = FALSE",
|
||||
)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
tracing::info!(user_id = %user_id, rows = result.rows_affected(), "All refresh tokens revoked");
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// Revoke a single refresh token by its raw value.
|
||||
pub async fn revoke(
|
||||
pool: &PgPool,
|
||||
raw_token: &str,
|
||||
) -> Result<(), RefreshError> {
|
||||
let hash = hex::encode(Sha256::digest(raw_token.as_bytes()));
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE refresh_tokens SET revoked = TRUE, revoked_at = NOW() WHERE token_hash = $1",
|
||||
)
|
||||
.bind(&hash)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -1 +1,264 @@
|
||||
//! session — stub for M2.
|
||||
//! Session management: login flow, logout, token issuance.
|
||||
//!
|
||||
//! Login flow: password → MFA → access token + refresh token
|
||||
//! Logout: revoke refresh token
|
||||
//! Force logout: revoke all tokens for a user
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
jwt::{self, JwtError},
|
||||
mfa_totp,
|
||||
password::{self, PasswordError},
|
||||
refresh::{self, RefreshError},
|
||||
};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SessionError {
|
||||
#[error("Invalid credentials")]
|
||||
InvalidCredentials,
|
||||
#[error("Account is disabled")]
|
||||
AccountDisabled,
|
||||
#[error("MFA required")]
|
||||
MfaRequired,
|
||||
#[error("Invalid MFA code")]
|
||||
InvalidMfaCode,
|
||||
#[error("JWT error: {0}")]
|
||||
Jwt(#[from] JwtError),
|
||||
#[error("Refresh token error: {0}")]
|
||||
Refresh(#[from] RefreshError),
|
||||
#[error("Password error: {0}")]
|
||||
Password(#[from] PasswordError),
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
}
|
||||
|
||||
/// Successful login response returned to the client.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LoginResponse {
|
||||
/// Short-lived JWT access token (15 minutes).
|
||||
pub access_token: String,
|
||||
/// Opaque refresh token (1-hour sliding window).
|
||||
pub refresh_token: String,
|
||||
/// Token type (always "Bearer").
|
||||
pub token_type: String,
|
||||
/// Access token TTL in seconds.
|
||||
pub expires_in: i64,
|
||||
/// User information.
|
||||
pub user: SessionUser,
|
||||
}
|
||||
|
||||
/// User summary embedded in login response.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SessionUser {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub role: String,
|
||||
pub mfa_enabled: bool,
|
||||
}
|
||||
|
||||
/// Database user row fetched during login.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct DbUser {
|
||||
id: Uuid,
|
||||
username: String,
|
||||
display_name: String,
|
||||
role: String,
|
||||
auth_provider: String,
|
||||
password_hash: Option<String>,
|
||||
totp_secret: Option<String>,
|
||||
mfa_enabled: bool,
|
||||
is_active: bool,
|
||||
}
|
||||
|
||||
/// Login request payload.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
/// TOTP code (required if MFA is enabled).
|
||||
pub totp_code: Option<String>,
|
||||
}
|
||||
|
||||
/// Perform the full login flow for local accounts.
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Look up user by username
|
||||
/// 2. Verify password (Argon2id)
|
||||
/// 3. Check account active state
|
||||
/// 4. Verify MFA if enabled
|
||||
/// 5. Issue access token + refresh token
|
||||
/// 6. Update last_login_at
|
||||
pub async fn login(
|
||||
pool: &PgPool,
|
||||
req: &LoginRequest,
|
||||
signing_key_pem: &str,
|
||||
access_ttl_secs: i64,
|
||||
user_agent: Option<&str>,
|
||||
ip_address: Option<&str>,
|
||||
) -> Result<LoginResponse, SessionError> {
|
||||
// 1. Fetch user by username
|
||||
let user: Option<DbUser> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, username, display_name, role, auth_provider,
|
||||
password_hash, totp_secret, mfa_enabled, is_active
|
||||
FROM users
|
||||
WHERE username = $1 AND auth_provider = 'local'
|
||||
"#,
|
||||
)
|
||||
.bind(&req.username)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
// Use constant-time comparison approach: always run Argon2 even on miss
|
||||
let user = match user {
|
||||
Some(u) => u,
|
||||
None => {
|
||||
// Prevent timing-based username enumeration
|
||||
let _ = password::hash_password("dummy-timing-fill");
|
||||
return Err(SessionError::InvalidCredentials);
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Verify password
|
||||
let hash = user.password_hash.as_deref().unwrap_or("");
|
||||
let valid = password::verify_password(&req.password, hash)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !valid {
|
||||
tracing::warn!(username = %req.username, "Login failed: invalid password");
|
||||
return Err(SessionError::InvalidCredentials);
|
||||
}
|
||||
|
||||
// 3. Check account state
|
||||
if !user.is_active {
|
||||
tracing::warn!(username = %req.username, "Login failed: account disabled");
|
||||
return Err(SessionError::AccountDisabled);
|
||||
}
|
||||
|
||||
// 4. MFA check
|
||||
if user.mfa_enabled {
|
||||
let code = req.totp_code.as_deref().ok_or(SessionError::MfaRequired)?;
|
||||
let secret = user.totp_secret.as_deref().unwrap_or("");
|
||||
|
||||
let mfa_ok = mfa_totp::verify_code(&user.username, secret, code)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !mfa_ok {
|
||||
tracing::warn!(username = %req.username, "Login failed: invalid MFA code");
|
||||
return Err(SessionError::InvalidMfaCode);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Issue tokens
|
||||
let access_token = jwt::issue_access_token(
|
||||
user.id,
|
||||
&user.username,
|
||||
&user.role,
|
||||
access_ttl_secs,
|
||||
signing_key_pem,
|
||||
)?;
|
||||
|
||||
let raw_refresh = refresh::issue(pool, user.id, user_agent, ip_address).await?;
|
||||
|
||||
// 6. Update last_login_at
|
||||
sqlx::query("UPDATE users SET last_login_at = $1 WHERE id = $2")
|
||||
.bind(Utc::now())
|
||||
.bind(user.id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
tracing::info!(user_id = %user.id, username = %user.username, "Login successful");
|
||||
|
||||
Ok(LoginResponse {
|
||||
access_token,
|
||||
refresh_token: raw_refresh.0,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: access_ttl_secs,
|
||||
user: SessionUser {
|
||||
id: user.id.to_string(),
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
role: user.role,
|
||||
mfa_enabled: user.mfa_enabled,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Refresh an access token using a valid refresh token.
|
||||
///
|
||||
/// The old refresh token is revoked and a new one issued (rotation).
|
||||
pub async fn refresh_session(
|
||||
pool: &PgPool,
|
||||
raw_refresh_token: &str,
|
||||
signing_key_pem: &str,
|
||||
access_ttl_secs: i64,
|
||||
user_agent: Option<&str>,
|
||||
ip_address: Option<&str>,
|
||||
) -> Result<LoginResponse, SessionError> {
|
||||
let (new_refresh, user_id) =
|
||||
refresh::rotate(pool, raw_refresh_token, user_agent, ip_address).await?;
|
||||
|
||||
// Fetch user for token claims
|
||||
let user: DbUser = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, username, display_name, role, auth_provider,
|
||||
password_hash, totp_secret, mfa_enabled, is_active
|
||||
FROM users WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
if !user.is_active {
|
||||
// Revoke all tokens and deny
|
||||
let _ = refresh::revoke_all_for_user(pool, user_id).await;
|
||||
return Err(SessionError::AccountDisabled);
|
||||
}
|
||||
|
||||
let access_token = jwt::issue_access_token(
|
||||
user.id,
|
||||
&user.username,
|
||||
&user.role,
|
||||
access_ttl_secs,
|
||||
signing_key_pem,
|
||||
)?;
|
||||
|
||||
Ok(LoginResponse {
|
||||
access_token,
|
||||
refresh_token: new_refresh.0,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: access_ttl_secs,
|
||||
user: SessionUser {
|
||||
id: user.id.to_string(),
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
role: user.role,
|
||||
mfa_enabled: user.mfa_enabled,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Logout: revoke the current refresh token.
|
||||
pub async fn logout(
|
||||
pool: &PgPool,
|
||||
raw_refresh_token: &str,
|
||||
) -> Result<(), SessionError> {
|
||||
refresh::revoke(pool, raw_refresh_token).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Force-logout: revoke all refresh tokens for a user.
|
||||
pub async fn force_logout(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
) -> Result<u64, SessionError> {
|
||||
let count = refresh::revoke_all_for_user(pool, user_id).await?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
@ -11,8 +11,10 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
pm-core = { path = "../pm-core" }
|
||||
pm-auth = { path = "../pm-auth" }
|
||||
tokio = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
axum-extra = { workspace = true }
|
||||
tower = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
@ -25,3 +27,4 @@ tracing-subscriber = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
ipnet = { workspace = true }
|
||||
|
||||
@ -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