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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
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]]
|
[[package]]
|
||||||
name = "arraydeque"
|
name = "arraydeque"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@ -148,6 +160,29 @@ dependencies = [
|
|||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "axum-macros"
|
name = "axum-macros"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@ -159,6 +194,12 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base32"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@ -180,6 +221,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@ -309,6 +359,12 @@ dependencies = [
|
|||||||
"tiny-keccak",
|
"tiny-keccak",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "constant_time_eq"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@ -416,6 +472,15 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
dependencies = [
|
||||||
|
"powerfmt",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@ -792,6 +857,30 @@ dependencies = [
|
|||||||
"hashbrown 0.15.5",
|
"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]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@ -1161,6 +1250,21 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@ -1327,6 +1431,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "num-bigint-dig"
|
name = "num-bigint-dig"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@ -1343,6 +1457,12 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-integer"
|
name = "num-integer"
|
||||||
version = "0.1.46"
|
version = "0.1.46"
|
||||||
@ -1462,12 +1582,33 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "pathdiff"
|
name = "pathdiff"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
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]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@ -1587,14 +1728,23 @@ name = "pm-auth"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"argon2",
|
||||||
"axum",
|
"axum",
|
||||||
|
"axum-extra",
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"hex",
|
||||||
|
"ipnet",
|
||||||
|
"jsonwebtoken",
|
||||||
"pm-core",
|
"pm-core",
|
||||||
|
"rand 0.8.6",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"totp-rs",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
@ -1653,7 +1803,10 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
"axum-extra",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"ipnet",
|
||||||
|
"pm-auth",
|
||||||
"pm-core",
|
"pm-core",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@ -1695,6 +1848,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@ -2261,6 +2420,18 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"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]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@ -2620,6 +2791,37 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "tiny-keccak"
|
name = "tiny-keccak"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
@ -2810,6 +3012,22 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
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]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@ -3056,6 +3274,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|||||||
12
Cargo.toml
12
Cargo.toml
@ -22,6 +22,7 @@ tokio = { version = "1", features = ["full"] }
|
|||||||
|
|
||||||
# Web framework
|
# Web framework
|
||||||
axum = { version = "0.8", features = ["ws", "macros"] }
|
axum = { version = "0.8", features = ["ws", "macros"] }
|
||||||
|
axum-extra = { version = "0.10", features = ["typed-header"] }
|
||||||
tower = { version = "0.5" }
|
tower = { version = "0.5" }
|
||||||
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "request-id"] }
|
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "request-id"] }
|
||||||
|
|
||||||
@ -60,3 +61,14 @@ config = { version = "0.15" }
|
|||||||
# Misc
|
# Misc
|
||||||
bytes = { version = "1" }
|
bytes = { version = "1" }
|
||||||
futures = { version = "0.3" }
|
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" }
|
pm-core = { path = "../pm-core" }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
|
axum-extra = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
@ -17,3 +18,11 @@ anyhow = { workspace = true }
|
|||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { 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.
|
//! pm-auth — Authentication and authorization.
|
||||||
//!
|
//!
|
||||||
//! Modules: password (Argon2id), jwt (EdDSA), refresh tokens,
|
//! Modules:
|
||||||
//! mfa_totp, mfa_webauthn, rbac, session.
|
//! - `password` — Argon2id password hashing (m=65536, t=3, p=1)
|
||||||
//!
|
//! - `jwt` — EdDSA/Ed25519 JWT issuance and validation (15-min TTL)
|
||||||
//! M1: Stub. Full implementation in M2.
|
//! - `refresh` — Opaque 256-bit refresh tokens (1-hour sliding window)
|
||||||
pub mod password;
|
//! - `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 jwt;
|
||||||
|
pub mod mfa_totp;
|
||||||
|
pub mod mfa_webauthn;
|
||||||
|
pub mod password;
|
||||||
pub mod rbac;
|
pub mod rbac;
|
||||||
|
pub mod refresh;
|
||||||
pub mod session;
|
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]
|
[dependencies]
|
||||||
pm-core = { path = "../pm-core" }
|
pm-core = { path = "../pm-core" }
|
||||||
|
pm-auth = { path = "../pm-auth" }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
|
axum-extra = { workspace = true }
|
||||||
tower = { workspace = true }
|
tower = { workspace = true }
|
||||||
tower-http = { workspace = true }
|
tower-http = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
@ -25,3 +27,4 @@ tracing-subscriber = { workspace = true }
|
|||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
ulid = { workspace = true }
|
ulid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
ipnet = { workspace = true }
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
//!
|
//!
|
||||||
//! Serves the React SPA, exposes the REST API, and handles WebSocket relay.
|
//! Serves the React SPA, exposes the REST API, and handles WebSocket relay.
|
||||||
|
|
||||||
|
mod routes;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::State,
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
@ -16,6 +18,10 @@ use pm_core::{
|
|||||||
logging,
|
logging,
|
||||||
request_id::request_id_middleware,
|
request_id::request_id_middleware,
|
||||||
};
|
};
|
||||||
|
use pm_auth::{
|
||||||
|
jwt,
|
||||||
|
rbac::{AuthConfig, require_auth},
|
||||||
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::{net::SocketAddr, sync::Arc};
|
use std::{net::SocketAddr, sync::Arc};
|
||||||
use tower_http::{
|
use tower_http::{
|
||||||
@ -28,47 +34,62 @@ use tower_http::{
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: sqlx::PgPool,
|
pub db: sqlx::PgPool,
|
||||||
pub config: Arc<AppConfig>,
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
// Load configuration
|
|
||||||
let config_path = std::env::var("PATCH_MANAGER_CONFIG")
|
let config_path = std::env::var("PATCH_MANAGER_CONFIG")
|
||||||
.unwrap_or_else(|_| "/etc/patch-manager/config.toml".to_string());
|
.unwrap_or_else(|_| "/etc/patch-manager/config.toml".to_string());
|
||||||
|
|
||||||
let config = AppConfig::load(&config_path)
|
let config = AppConfig::load(&config_path).unwrap_or_else(|_| {
|
||||||
.unwrap_or_else(|_| {
|
eprintln!("Config file not found or invalid, using defaults");
|
||||||
eprintln!("Config file not found or invalid, using defaults");
|
AppConfig::default()
|
||||||
AppConfig::default()
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize logging
|
|
||||||
logging::init(&config.logging);
|
logging::init(&config.logging);
|
||||||
|
|
||||||
tracing::info!(version = env!("CARGO_PKG_VERSION"), "patch-manager-web starting");
|
tracing::info!(version = env!("CARGO_PKG_VERSION"), "patch-manager-web starting");
|
||||||
|
|
||||||
// Initialize database pool
|
// Load JWT keys (graceful fallback for dev without keys on disk)
|
||||||
let pool = db::init_pool(&config.database).await?;
|
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?;
|
db::run_migrations(&pool).await?;
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
db: pool,
|
db: pool,
|
||||||
config: Arc::new(config.clone()),
|
config: Arc::new(config.clone()),
|
||||||
|
signing_key_pem,
|
||||||
|
auth_config,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build the application router
|
|
||||||
let app = build_router(state);
|
let app = build_router(state);
|
||||||
|
|
||||||
// Bind address
|
|
||||||
let addr: SocketAddr = format!("{}:{}", config.server.host, config.server.port)
|
let addr: SocketAddr = format!("{}:{}", config.server.host, config.server.port)
|
||||||
.parse()
|
.parse()
|
||||||
.expect("Invalid bind address");
|
.expect("Invalid bind address");
|
||||||
|
|
||||||
tracing::info!(%addr, "Listening");
|
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?;
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
@ -78,48 +99,36 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
/// Construct the full Axum router.
|
/// Construct the full Axum router.
|
||||||
pub fn build_router(state: AppState) -> Router {
|
pub fn build_router(state: AppState) -> Router {
|
||||||
let static_dir = state.config.server.static_dir.clone();
|
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()
|
Router::new()
|
||||||
// Health / status (unauthenticated)
|
// Health / status (unauthenticated)
|
||||||
.route("/status/health", get(health_handler))
|
.route("/status/health", get(health_handler))
|
||||||
// API v1 routes (stub — expanded in later milestones)
|
// Public auth routes (login, refresh, logout)
|
||||||
.nest("/api/v1", api_v1_router())
|
.nest("/api/v1/auth", routes::auth::public_router())
|
||||||
// Serve React SPA static files; fallback to index.html for client-side routing
|
// 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(
|
.fallback_service(
|
||||||
ServeDir::new(&static_dir)
|
ServeDir::new(&static_dir).append_index_html_on_directories(true),
|
||||||
.append_index_html_on_directories(true),
|
|
||||||
)
|
)
|
||||||
// Middleware stack
|
|
||||||
.layer(middleware::from_fn(request_id_middleware))
|
.layer(middleware::from_fn(request_id_middleware))
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state)
|
.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.
|
/// 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> {
|
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 status = if db_ok { "healthy" } else { "degraded" };
|
||||||
|
|
||||||
let body = json!({
|
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" },
|
"database": if db_ok { "ok" } else { "error" },
|
||||||
});
|
});
|
||||||
|
|
||||||
if db_ok {
|
if db_ok { Ok(Json(body)) } else { Err(StatusCode::SERVICE_UNAVAILABLE) }
|
||||||
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 { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { CssBaseline, ThemeProvider } from '@mui/material'
|
import { CssBaseline, ThemeProvider } from '@mui/material'
|
||||||
import { lightTheme } from './theme/theme'
|
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 }) => (
|
const PlaceholderPage = ({ title }: { title: string }) => (
|
||||||
<div style={{ padding: 32 }}>
|
<div style={{ padding: 32 }}>
|
||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
@ -10,24 +13,36 @@ const PlaceholderPage = ({ title }: { title: string }) => (
|
|||||||
</div>
|
</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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={lightTheme}>
|
<ThemeProvider theme={lightTheme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
{/* Public routes */}
|
||||||
<Route path="/dashboard" element={<PlaceholderPage title="Dashboard" />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/hosts" element={<PlaceholderPage title="Hosts" />} />
|
|
||||||
<Route path="/hosts/:id" element={<PlaceholderPage title="Host Detail" />} />
|
{/* Protected routes */}
|
||||||
<Route path="/jobs" element={<PlaceholderPage title="Jobs" />} />
|
<Route path="/" element={<RequireAuth><Navigate to="/dashboard" replace /></RequireAuth>} />
|
||||||
<Route path="/deployment" element={<PlaceholderPage title="Patch Deployment" />} />
|
<Route path="/dashboard" element={<RequireAuth><PlaceholderPage title="Dashboard" /></RequireAuth>} />
|
||||||
<Route path="/maintenance" element={<PlaceholderPage title="Maintenance Windows" />} />
|
<Route path="/hosts" element={<RequireAuth><PlaceholderPage title="Hosts" /></RequireAuth>} />
|
||||||
<Route path="/groups" element={<PlaceholderPage title="Groups" />} />
|
<Route path="/hosts/:id" element={<RequireAuth><PlaceholderPage title="Host Detail" /></RequireAuth>} />
|
||||||
<Route path="/reports" element={<PlaceholderPage title="Reports" />} />
|
<Route path="/jobs" element={<RequireAuth><PlaceholderPage title="Jobs" /></RequireAuth>} />
|
||||||
<Route path="/users" element={<PlaceholderPage title="Users" />} />
|
<Route path="/deployment" element={<RequireAuth><PlaceholderPage title="Patch Deployment" /></RequireAuth>} />
|
||||||
<Route path="/certificates" element={<PlaceholderPage title="Certificates" />} />
|
<Route path="/maintenance" element={<RequireAuth><PlaceholderPage title="Maintenance Windows" /></RequireAuth>} />
|
||||||
<Route path="/settings" element={<PlaceholderPage title="Settings" />} />
|
<Route path="/groups" element={<RequireAuth><PlaceholderPage title="Groups" /></RequireAuth>} />
|
||||||
<Route path="/login" element={<PlaceholderPage title="Login" />} />
|
<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" />} />
|
<Route path="*" element={<PlaceholderPage title="404 Not Found" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</ThemeProvider>
|
</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({
|
export const apiClient = axios.create({
|
||||||
baseURL: '/api/v1',
|
baseURL: BASE_URL,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
timeout: 30_000,
|
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
|
### M2: Authentication & Authorization + Frontend Shell
|
||||||
**Goal:** Users can log in with MFA, JWT auth works, RBAC middleware enforces roles.
|
**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`)
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] Implement `pm-auth::mfa_totp` — TOTP setup, verify, QR code generation
|
||||||
- [ ] Implement `pm-auth::mfa_webauthn` — WebAuthn registration and authentication
|
- [x] Implement `pm-auth::mfa_webauthn` — WebAuthn registration and authentication
|
||||||
- [ ] Implement `pm-auth::rbac` — Admin/Operator role middleware, group-scoped access enforcement
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] Frontend: API client with JWT interceptors (auto-refresh), 401 redirect to login
|
||||||
- [ ] Create seed migration: default admin account
|
- [x] 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] Verify: login with MFA, JWT validation, refresh token rotation, RBAC blocks unauthorized access, IP whitelist blocks unknown IPs
|
||||||
|
|
||||||
### M3: Host Management + Groups + Frontend Pages
|
### M3: Host Management + Groups + Frontend Pages
|
||||||
**Goal:** Full host CRUD, group management, auto-discovery.
|
**Goal:** Full host CRUD, group management, auto-discovery.
|
||||||
|
|||||||
Reference in New Issue
Block a user