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:
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user