- 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.
104 lines
3.3 KiB
Rust
104 lines
3.3 KiB
Rust
//! 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());
|
|
}
|
|
}
|