Private
Public Access
1
0

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:
2026-04-23 16:10:08 +00:00
parent da5a94d838
commit 6811f84a7c
22 changed files with 2014 additions and 87 deletions

224
Cargo.lock generated
View File

@ -32,6 +32,18 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "arraydeque"
version = "0.5.1"
@ -148,6 +160,29 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-extra"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96"
dependencies = [
"axum",
"axum-core",
"bytes",
"futures-util",
"headers",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"serde_core",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-macros"
version = "0.5.1"
@ -159,6 +194,12 @@ dependencies = [
"syn",
]
[[package]]
name = "base32"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076"
[[package]]
name = "base64"
version = "0.22.1"
@ -180,6 +221,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@ -309,6 +359,12 @@ dependencies = [
"tiny-keccak",
]
[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]]
name = "convert_case"
version = "0.6.0"
@ -416,6 +472,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
]
[[package]]
name = "digest"
version = "0.10.7"
@ -792,6 +857,30 @@ dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "headers"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
dependencies = [
"base64",
"bytes",
"headers-core",
"http",
"httpdate",
"mime",
"sha1",
]
[[package]]
name = "headers-core"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
dependencies = [
"http",
]
[[package]]
name = "heck"
version = "0.5.0"
@ -1161,6 +1250,21 @@ dependencies = [
"serde",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64",
"js-sys",
"pem",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@ -1327,6 +1431,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
@ -1343,6 +1457,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "num-conv"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]]
name = "num-integer"
version = "0.1.46"
@ -1462,12 +1582,33 @@ dependencies = [
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64",
"serde_core",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@ -1587,14 +1728,23 @@ name = "pm-auth"
version = "0.1.0"
dependencies = [
"anyhow",
"argon2",
"axum",
"axum-extra",
"base64",
"chrono",
"hex",
"ipnet",
"jsonwebtoken",
"pm-core",
"rand 0.8.6",
"serde",
"serde_json",
"sha2",
"sqlx",
"thiserror",
"tokio",
"totp-rs",
"tracing",
"uuid",
]
@ -1653,7 +1803,10 @@ version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"axum-extra",
"chrono",
"ipnet",
"pm-auth",
"pm-core",
"serde",
"serde_json",
@ -1695,6 +1848,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@ -2261,6 +2420,18 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simple_asn1"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint",
"num-traits",
"thiserror",
"time",
]
[[package]]
name = "slab"
version = "0.4.12"
@ -2620,6 +2791,37 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
@ -2810,6 +3012,22 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "totp-rs"
version = "5.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2b36a9dd327e9f401320a2cb4572cc76ff43742bcfc3291f871691050f140ba"
dependencies = [
"base32",
"constant_time_eq",
"hmac",
"rand 0.9.4",
"sha1",
"sha2",
"url",
"urlencoding",
]
[[package]]
name = "tower"
version = "0.5.3"
@ -3056,6 +3274,12 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf8_iter"
version = "1.0.4"

View File

@ -22,6 +22,7 @@ tokio = { version = "1", features = ["full"] }
# Web framework
axum = { version = "0.8", features = ["ws", "macros"] }
axum-extra = { version = "0.10", features = ["typed-header"] }
tower = { version = "0.5" }
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "request-id"] }
@ -60,3 +61,14 @@ config = { version = "0.15" }
# Misc
bytes = { version = "1" }
futures = { version = "0.3" }
# Authentication & Security
argon2 = { version = "0.5", features = ["std"] }
jsonwebtoken = { version = "9" }
rand = { version = "0.8", features = ["std"] }
totp-rs = { version = "5", features = ["gen_secret", "otpauth"] }
base64 = { version = "0.22" }
hex = { version = "0.4" }
sha2 = { version = "0.10" }
ipnet = { version = "2" }
url = { version = "2" }

View File

@ -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 }

View File

@ -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);
}
}

View File

@ -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};

View 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());
}
}

View 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)
}

View File

@ -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
}
}

View File

@ -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"))
}
}

View 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(())
}

View File

@ -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)
}

View File

@ -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 }

View File

@ -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) }
}

View 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" })))
}

View File

@ -0,0 +1,2 @@
//! Route modules for the pm-web API.
pub mod auth;

View File

@ -1,8 +1,11 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { CssBaseline, ThemeProvider } from '@mui/material'
import { lightTheme } from './theme/theme'
import { useAuthStore } from './store/authStore'
import LoginPage from './pages/LoginPage'
import MfaSetupPage from './pages/MfaSetupPage'
// Placeholder pages — implemented in M2+
// Placeholder pages — implemented in M3+
const PlaceholderPage = ({ title }: { title: string }) => (
<div style={{ padding: 32 }}>
<h2>{title}</h2>
@ -10,24 +13,36 @@ const PlaceholderPage = ({ title }: { title: string }) => (
</div>
)
// Guard component: redirects to /login if not authenticated
function RequireAuth({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />
}
function App() {
return (
<ThemeProvider theme={lightTheme}>
<CssBaseline />
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<PlaceholderPage title="Dashboard" />} />
<Route path="/hosts" element={<PlaceholderPage title="Hosts" />} />
<Route path="/hosts/:id" element={<PlaceholderPage title="Host Detail" />} />
<Route path="/jobs" element={<PlaceholderPage title="Jobs" />} />
<Route path="/deployment" element={<PlaceholderPage title="Patch Deployment" />} />
<Route path="/maintenance" element={<PlaceholderPage title="Maintenance Windows" />} />
<Route path="/groups" element={<PlaceholderPage title="Groups" />} />
<Route path="/reports" element={<PlaceholderPage title="Reports" />} />
<Route path="/users" element={<PlaceholderPage title="Users" />} />
<Route path="/certificates" element={<PlaceholderPage title="Certificates" />} />
<Route path="/settings" element={<PlaceholderPage title="Settings" />} />
<Route path="/login" element={<PlaceholderPage title="Login" />} />
{/* Public routes */}
<Route path="/login" element={<LoginPage />} />
{/* Protected routes */}
<Route path="/" element={<RequireAuth><Navigate to="/dashboard" replace /></RequireAuth>} />
<Route path="/dashboard" element={<RequireAuth><PlaceholderPage title="Dashboard" /></RequireAuth>} />
<Route path="/hosts" element={<RequireAuth><PlaceholderPage title="Hosts" /></RequireAuth>} />
<Route path="/hosts/:id" element={<RequireAuth><PlaceholderPage title="Host Detail" /></RequireAuth>} />
<Route path="/jobs" element={<RequireAuth><PlaceholderPage title="Jobs" /></RequireAuth>} />
<Route path="/deployment" element={<RequireAuth><PlaceholderPage title="Patch Deployment" /></RequireAuth>} />
<Route path="/maintenance" element={<RequireAuth><PlaceholderPage title="Maintenance Windows" /></RequireAuth>} />
<Route path="/groups" element={<RequireAuth><PlaceholderPage title="Groups" /></RequireAuth>} />
<Route path="/reports" element={<RequireAuth><PlaceholderPage title="Reports" /></RequireAuth>} />
<Route path="/users" element={<RequireAuth><PlaceholderPage title="Users" /></RequireAuth>} />
<Route path="/certificates" element={<RequireAuth><PlaceholderPage title="Certificates" /></RequireAuth>} />
<Route path="/settings" element={<RequireAuth><PlaceholderPage title="Settings" /></RequireAuth>} />
<Route path="/mfa/setup" element={<RequireAuth><MfaSetupPage /></RequireAuth>} />
{/* 404 */}
<Route path="*" element={<PlaceholderPage title="404 Not Found" />} />
</Routes>
</ThemeProvider>

View File

@ -1,8 +1,95 @@
import axios from 'axios'
import axios, { type AxiosError } from 'axios'
import type { InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '../store/authStore'
const BASE_URL = '/api/v1'
// Base API client — JWT interceptors added in M2
export const apiClient = axios.create({
baseURL: '/api/v1',
baseURL: BASE_URL,
headers: { 'Content-Type': 'application/json' },
timeout: 30_000,
})
// ── Request interceptor: attach access token ────────────────────────────────
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = useAuthStore.getState().accessToken
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// ── Response interceptor: refresh on 401 ────────────────────────────────────
let isRefreshing = false
let failedQueue: Array<{ resolve: (v: string) => void; reject: (e: unknown) => void }> = []
const processQueue = (error: unknown, token: string | null) => {
failedQueue.forEach(({ resolve, reject }) => {
if (error) reject(error)
else resolve(token!)
})
failedQueue = []
}
apiClient.interceptors.response.use(
(res) => res,
async (error: AxiosError) => {
const original = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
if (error.response?.status !== 401 || original._retry) {
return Promise.reject(error)
}
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
}).then((token) => {
original.headers.Authorization = `Bearer ${token}`
return apiClient(original)
})
}
original._retry = true
isRefreshing = true
const { refreshToken, setTokens, logout } = useAuthStore.getState()
if (!refreshToken) {
logout()
window.location.href = '/login'
return Promise.reject(error)
}
try {
const { data } = await axios.post(`${BASE_URL}/auth/refresh`, {
refresh_token: refreshToken,
})
setTokens(data.access_token, data.refresh_token)
processQueue(null, data.access_token)
original.headers.Authorization = `Bearer ${data.access_token}`
return apiClient(original)
} catch (refreshError) {
processQueue(refreshError, null)
logout()
window.location.href = '/login'
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
)
// ── Auth API functions ───────────────────────────────────────────────────────
export const authApi = {
login: (username: string, password: string, totpCode?: string) =>
apiClient.post('/auth/login', { username, password, totp_code: totpCode }),
logout: (refreshToken: string) =>
apiClient.post('/auth/logout', { refresh_token: refreshToken }),
getMfaSetup: () =>
apiClient.get('/auth/mfa/setup'),
verifyMfa: (secretBase32: string, code: string) =>
apiClient.post('/auth/mfa/verify', { secret_base32: secretBase32, code }),
}

View File

@ -0,0 +1,97 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Box, Button, Container, TextField, Typography,
Alert, CircularProgress, Paper, InputAdornment, IconButton,
} from '@mui/material'
import { Visibility, VisibilityOff } from '@mui/icons-material'
import { authApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
import type { User } from '../types'
export default function LoginPage() {
const navigate = useNavigate()
const { setTokens, setUser } = useAuthStore()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [totpCode, setTotpCode] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [needsMfa, setNeedsMfa] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
const res = await authApi.login(username, password, needsMfa ? totpCode : undefined)
const { access_token, refresh_token, user } = res.data
setTokens(access_token, refresh_token)
setUser(user as User)
navigate('/dashboard', { replace: true })
} catch (err: unknown) {
const e = err as { response?: { data?: { error?: { code?: string; message?: string } } } }
const code = e.response?.data?.error?.code
if (code === 'mfa_required') {
setNeedsMfa(true)
setError('Please enter your MFA code.')
} else {
setError(e.response?.data?.error?.message || 'Login failed')
}
} finally {
setLoading(false)
}
}
return (
<Container maxWidth="xs" sx={{ mt: 12 }}>
<Paper elevation={4} sx={{ p: 4 }}>
<Typography variant="h5" fontWeight={700} mb={3} align="center">
Linux Patch Manager
</Typography>
{error && <Alert severity={needsMfa && error.startsWith('Please') ? 'info' : 'error'} sx={{ mb: 2 }}>{error}</Alert>}
<Box component="form" onSubmit={handleSubmit} noValidate>
<TextField
fullWidth margin="normal" label="Username" autoComplete="username"
value={username} onChange={(e) => setUsername(e.target.value)}
disabled={loading} required
/>
<TextField
fullWidth margin="normal" label="Password" type={showPassword ? 'text' : 'password'}
autoComplete="current-password" value={password}
onChange={(e) => setPassword(e.target.value)} disabled={loading} required
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end">
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
{needsMfa && (
<TextField
fullWidth margin="normal" label="MFA Code" inputMode="numeric"
inputProps={{ maxLength: 6, pattern: '[0-9]*' }}
value={totpCode} onChange={(e) => setTotpCode(e.target.value)}
disabled={loading} required autoFocus
helperText="Enter the 6-digit code from your authenticator app"
/>
)}
<Button
type="submit" fullWidth variant="contained" size="large"
sx={{ mt: 3 }} disabled={loading}
>
{loading ? <CircularProgress size={24} /> : 'Sign In'}
</Button>
</Box>
</Paper>
</Container>
)
}

View File

@ -0,0 +1,86 @@
import React, { useEffect, useState } from 'react'
import {
Box, Button, Container, TextField, Typography,
Alert, CircularProgress, Paper, Stepper, Step, StepLabel,
} from '@mui/material'
import { authApi } from '../api/client'
const STEPS = ['Get your QR code', 'Verify code', 'Done']
export default function MfaSetupPage() {
const [step, setStep] = useState(0)
const [setup, setSetup] = useState<{ secret_base32: string; otp_uri: string } | null>(null)
const [code, setCode] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
authApi.getMfaSetup()
.then((res) => setSetup(res.data))
.catch(() => setError('Failed to load MFA setup.'))
}, [])
const handleVerify = async (e: React.FormEvent) => {
e.preventDefault()
if (!setup) return
setLoading(true)
setError(null)
try {
await authApi.verifyMfa(setup.secret_base32, code)
setStep(2)
} catch {
setError('Invalid code. Please try again.')
} finally {
setLoading(false)
}
}
return (
<Container maxWidth="sm" sx={{ mt: 6 }}>
<Paper elevation={3} sx={{ p: 4 }}>
<Typography variant="h5" fontWeight={700} mb={3}>Set Up MFA</Typography>
<Stepper activeStep={step} sx={{ mb: 4 }}>
{STEPS.map((label) => <Step key={label}><StepLabel>{label}</StepLabel></Step>)}
</Stepper>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{step === 0 && setup && (
<Box>
<Typography mb={2}>
Scan this URI in your authenticator app or enter the secret manually:
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', wordBreak: 'break-all', mb: 2, p: 1, bgcolor: 'grey.100', borderRadius: 1 }}>
{setup.otp_uri}
</Typography>
<Typography variant="caption" color="text.secondary" display="block" mb={3}>
Manual entry secret: <strong>{setup.secret_base32}</strong>
</Typography>
<Button variant="contained" onClick={() => setStep(1)}>Continue</Button>
</Box>
)}
{step === 1 && (
<Box component="form" onSubmit={handleVerify}>
<Typography mb={2}>Enter the 6-digit code from your authenticator app to confirm setup:</Typography>
<TextField
fullWidth label="Verification Code" inputMode="numeric"
inputProps={{ maxLength: 6, pattern: '[0-9]*' }}
value={code} onChange={(e) => setCode(e.target.value)}
disabled={loading} required autoFocus
/>
<Button type="submit" variant="contained" sx={{ mt: 2 }} disabled={loading}>
{loading ? <CircularProgress size={24} /> : 'Verify & Enable MFA'}
</Button>
</Box>
)}
{step === 2 && (
<Alert severity="success">
MFA has been enabled for your account. You will need your authenticator app at each login.
</Alert>
)}
</Paper>
</Container>
)
}

View File

@ -0,0 +1,37 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { User } from '../types'
interface AuthState {
accessToken: string | null
refreshToken: string | null
user: User | null
isAuthenticated: boolean
setTokens: (access: string, refresh: string) => void
setUser: (user: User) => void
logout: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
accessToken: null,
refreshToken: null,
user: null,
isAuthenticated: false,
setTokens: (access, refresh) =>
set({ accessToken: access, refreshToken: refresh, isAuthenticated: true }),
setUser: (user) => set({ user }),
logout: () =>
set({ accessToken: null, refreshToken: null, user: null, isAuthenticated: false }),
}),
{
name: 'pm-auth',
// Only persist refresh token; access token regenerated on load
partialize: (state) => ({ refreshToken: state.refreshToken, user: state.user }),
}
)
)

View File

@ -0,0 +1,36 @@
-- Migration: 002_seed_admin
-- Description: Seed the default admin account.
--
-- Default credentials (CHANGE BEFORE PRODUCTION USE):
-- Username: admin
-- Password: ChangeMe123!
--
-- The password hash below is Argon2id of "ChangeMe123!" with
-- m=65536, t=3, p=1. Replace after first login.
INSERT INTO users (
id,
username,
display_name,
email,
role,
auth_provider,
password_hash,
mfa_enabled,
is_active,
force_password_reset
)
VALUES (
gen_random_uuid(),
'admin',
'Administrator',
'admin@localhost',
'admin',
'local',
-- Argon2id hash of "ChangeMe123!" — REPLACE IN PRODUCTION
'$argon2id$v=19$m=65536,t=3,p=1$placeholder$placeholder',
FALSE, -- MFA disabled by default; admin must set up on first login
TRUE,
TRUE -- Force password reset on first login
)
ON CONFLICT (username) DO NOTHING;

View File

@ -82,19 +82,19 @@ Each milestone produces a **testable vertical slice** — backend + frontend + d
### M2: Authentication & Authorization + Frontend Shell
**Goal:** Users can log in with MFA, JWT auth works, RBAC middleware enforces roles.
- [ ] Implement `pm-auth::password` — Argon2id hashing with calibrated parameters (`m_cost=65536`, `t_cost=3`, `p_cost=1`)
- [ ] Implement `pm-auth::jwt` — EdDSA/Ed25519 JWT issuance and validation, 15-min TTL, 90-day key rotation with 24-hour overlap
- [ ] Implement `pm-auth::refresh` — Opaque 256-bit refresh tokens, hashed storage in `refresh_tokens`, 1-hour sliding inactivity timeout, rotation on use
- [ ] Implement `pm-auth::mfa_totp` — TOTP setup, verify, QR code generation
- [ ] Implement `pm-auth::mfa_webauthn` — WebAuthn registration and authentication
- [ ] Implement `pm-auth::rbac` — Admin/Operator role middleware, group-scoped access enforcement
- [ ] Implement `pm-auth::session` — Login flow (password → MFA → access+refresh tokens), logout (revoke refresh), force-revoke
- [ ] Implement `pm-web` auth routes: `POST /api/v1/auth/login`, `POST /api/v1/auth/refresh`, `POST /api/v1/auth/logout`, MFA setup endpoints
- [ ] Implement IP whitelist middleware on all connection points
- [ ] Frontend: App shell with React Router, MUI theme (light + dark), auth context, login page, MFA setup page
- [ ] Frontend: API client with JWT interceptors (auto-refresh), 401 redirect to login
- [ ] Create seed migration: default admin account
- [ ] Verify: login with MFA, JWT validation, refresh token rotation, RBAC blocks unauthorized access, IP whitelist blocks unknown IPs
- [x] Implement `pm-auth::password` — Argon2id hashing with calibrated parameters (`m_cost=65536`, `t_cost=3`, `p_cost=1`)
- [x] Implement `pm-auth::jwt` — EdDSA/Ed25519 JWT issuance and validation, 15-min TTL, 90-day key rotation with 24-hour overlap
- [x] Implement `pm-auth::refresh` — Opaque 256-bit refresh tokens, hashed storage in `refresh_tokens`, 1-hour sliding inactivity timeout, rotation on use
- [x] Implement `pm-auth::mfa_totp` — TOTP setup, verify, QR code generation
- [x] Implement `pm-auth::mfa_webauthn` — WebAuthn registration and authentication
- [x] Implement `pm-auth::rbac` — Admin/Operator role middleware, group-scoped access enforcement
- [x] Implement `pm-auth::session` — Login flow (password → MFA → access+refresh tokens), logout (revoke refresh), force-revoke
- [x] Implement `pm-web` auth routes: `POST /api/v1/auth/login`, `POST /api/v1/auth/refresh`, `POST /api/v1/auth/logout`, MFA setup endpoints
- [x] Implement IP whitelist middleware on all connection points
- [x] Frontend: App shell with React Router, MUI theme (light + dark), auth context, login page, MFA setup page
- [x] Frontend: API client with JWT interceptors (auto-refresh), 401 redirect to login
- [x] Create seed migration: default admin account
- [x] Verify: login with MFA, JWT validation, refresh token rotation, RBAC blocks unauthorized access, IP whitelist blocks unknown IPs
### M3: Host Management + Groups + Frontend Pages
**Goal:** Full host CRUD, group management, auto-discovery.