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:
224
Cargo.lock
generated
224
Cargo.lock
generated
@ -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"
|
||||
|
||||
12
Cargo.toml
12
Cargo.toml
@ -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" }
|
||||
|
||||
@ -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;
|
||||
@ -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>
|
||||
|
||||
@ -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 }),
|
||||
}
|
||||
|
||||
97
frontend/src/pages/LoginPage.tsx
Normal file
97
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
frontend/src/pages/MfaSetupPage.tsx
Normal file
86
frontend/src/pages/MfaSetupPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
frontend/src/store/authStore.ts
Normal file
37
frontend/src/store/authStore.ts
Normal 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 }),
|
||||
}
|
||||
)
|
||||
)
|
||||
36
migrations/002_seed_admin.sql
Normal file
36
migrations/002_seed_admin.sql
Normal 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;
|
||||
@ -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.
|
||||
|
||||
Reference in New Issue
Block a user