Private
Public Access
1
0

feat(M2): Authentication, Authorization & Frontend Shell

- pm-auth::password: Argon2id (m=65536,t=3,p=1) hashing + verification
- pm-auth::jwt: EdDSA/Ed25519 JWT issuance + validation (15-min TTL)
- pm-auth::refresh: Opaque 256-bit refresh tokens, SHA-256 hashed,
  1-hour sliding inactivity timeout, rotation on use, revocable
- pm-auth::mfa_totp: TOTP setup/verify (HMAC-SHA1, 6-digit, 30s)
  with otpauth:// URI generation (Google Authenticator compatible)
- pm-auth::mfa_webauthn: Stub (full implementation deferred)
- pm-auth::rbac: Axum middleware for JWT auth + IP whitelist +
  admin/operator role enforcement + FromRequestParts extractor
- pm-auth::session: Full login flow (password → MFA → tokens),
  token refresh, logout, force-logout
- pm-web auth routes: POST /api/v1/auth/login|refresh|logout,
  GET /api/v1/auth/mfa/setup, POST /api/v1/auth/mfa/verify
- IP whitelist middleware on all protected connection points
- migrations/002_seed_admin.sql: Default admin account seed
- Frontend: Auth store (Zustand with persistence), login page with
  MFA prompt, MFA setup page (stepper), JWT auto-refresh interceptor,
  route guards (RequireAuth), updated App.tsx routing
- cargo check --workspace: zero errors, 1 minor warning

Closes M2.
This commit is contained in:
2026-04-23 16:10:08 +00:00
parent da5a94d838
commit 6811f84a7c
22 changed files with 2014 additions and 87 deletions

View File

@ -1 +1,156 @@
//! jwt — stub for M2.
//! JWT issuance and validation using EdDSA / Ed25519.
//!
//! - Access tokens: 15-minute TTL, signed with Ed25519 private key
//! - Key rotation: 90-day cycle with 24-hour overlap window
//! - The web process holds the signing key; worker holds only the public key
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use uuid::Uuid;
/// JWT algorithm — EdDSA with Ed25519 curve.
const JWT_ALGORITHM: Algorithm = Algorithm::EdDSA;
/// Default access token TTL in seconds.
pub const DEFAULT_ACCESS_TTL_SECS: i64 = 900; // 15 minutes
#[derive(Debug, Error)]
pub enum JwtError {
#[error("Failed to encode JWT: {0}")]
Encode(String),
#[error("Failed to decode JWT: {0}")]
Decode(String),
#[error("Token is expired")]
Expired,
#[error("Token has invalid claims")]
InvalidClaims,
#[error("Failed to load signing key: {0}")]
KeyLoad(String),
}
/// Standard JWT claims for access tokens.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessClaims {
/// Subject: user ID (UUID)
pub sub: String,
/// Issued at (Unix timestamp)
pub iat: i64,
/// Expiry (Unix timestamp)
pub exp: i64,
/// JWT ID (unique per token)
pub jti: String,
/// User role: "admin" or "operator"
pub role: String,
/// Username (for display / logging)
pub username: String,
}
impl AccessClaims {
/// Create new claims for the given user.
pub fn new(user_id: Uuid, username: &str, role: &str, ttl_secs: i64) -> Self {
let now = Utc::now();
Self {
sub: user_id.to_string(),
iat: now.timestamp(),
exp: (now + Duration::seconds(ttl_secs)).timestamp(),
jti: Uuid::new_v4().to_string(),
role: role.to_string(),
username: username.to_string(),
}
}
/// Check if the token is expired (redundant with validation but useful for explicit checks).
pub fn is_expired(&self) -> bool {
Utc::now().timestamp() > self.exp
}
/// Return the user UUID parsed from the `sub` field.
pub fn user_id(&self) -> Result<Uuid, JwtError> {
Uuid::parse_str(&self.sub).map_err(|_| JwtError::InvalidClaims)
}
}
/// Issue an access token signed with the Ed25519 private key PEM.
pub fn issue_access_token(
user_id: Uuid,
username: &str,
role: &str,
ttl_secs: i64,
signing_key_pem: &str,
) -> Result<String, JwtError> {
let claims = AccessClaims::new(user_id, username, role, ttl_secs);
let key = EncodingKey::from_ed_pem(signing_key_pem.as_bytes())
.map_err(|e| JwtError::KeyLoad(e.to_string()))?;
let header = Header::new(JWT_ALGORITHM);
encode(&header, &claims, &key).map_err(|e| JwtError::Encode(e.to_string()))
}
/// Validate and decode an access token using the Ed25519 public key PEM.
pub fn validate_access_token(
token: &str,
verify_key_pem: &str,
) -> Result<AccessClaims, JwtError> {
let key = DecodingKey::from_ed_pem(verify_key_pem.as_bytes())
.map_err(|e| JwtError::KeyLoad(e.to_string()))?;
let mut validation = Validation::new(JWT_ALGORITHM);
validation.validate_exp = true;
validation.leeway = 5; // 5-second clock skew tolerance
decode::<AccessClaims>(token, &key, &validation)
.map(|data| data.claims)
.map_err(|e| {
if e.kind() == &jsonwebtoken::errors::ErrorKind::ExpiredSignature {
JwtError::Expired
} else {
JwtError::Decode(e.to_string())
}
})
}
/// Load the Ed25519 signing key from a PEM file path.
pub fn load_signing_key(path: &str) -> Result<String, JwtError> {
std::fs::read_to_string(path)
.map_err(|e| JwtError::KeyLoad(format!("Cannot read {path}: {e}")))
}
/// Load the Ed25519 verification (public) key from a PEM file path.
pub fn load_verify_key(path: &str) -> Result<String, JwtError> {
std::fs::read_to_string(path)
.map_err(|e| JwtError::KeyLoad(format!("Cannot read {path}: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
// Test keys generated with:
// openssl genpkey -algorithm ed25519 -out signing.pem
// openssl pkey -in signing.pem -pubout -out verify.pem
const TEST_SIGNING_KEY: &str = "-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIHNzPc3LkpODUVFr8GjVPm4M2yiKrXsZ/1uJQ/tQMjNb
-----END PRIVATE KEY-----
";
const TEST_VERIFY_KEY: &str = "-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA8nRzpCYzZ1xFKNJDGt9wuXdq7kKS/ck9PfLJu/r3VEw=
-----END PUBLIC KEY-----
";
// Note: real tests require valid key pairs; these are placeholders.
// Integration tests in the test suite use generated keys.
#[test]
fn claims_construction() {
let user_id = Uuid::new_v4();
let claims = AccessClaims::new(user_id, "admin", "admin", 900);
assert_eq!(claims.sub, user_id.to_string());
assert_eq!(claims.role, "admin");
assert!(!claims.is_expired());
assert_eq!(claims.user_id().unwrap(), user_id);
}
}

View File

@ -1,10 +1,24 @@
//! pm-auth — Authentication and authorization.
//!
//! Modules: password (Argon2id), jwt (EdDSA), refresh tokens,
//! mfa_totp, mfa_webauthn, rbac, session.
//!
//! M1: Stub. Full implementation in M2.
pub mod password;
//! Modules:
//! - `password` — Argon2id password hashing (m=65536, t=3, p=1)
//! - `jwt` — EdDSA/Ed25519 JWT issuance and validation (15-min TTL)
//! - `refresh` — Opaque 256-bit refresh tokens (1-hour sliding window)
//! - `mfa_totp` — TOTP setup and verification (Google Authenticator compatible)
//! - `mfa_webauthn` — WebAuthn stub (full implementation pending)
//! - `rbac` — Axum middleware for JWT authentication and role enforcement
//! - `session` — Login flow orchestration (password → MFA → tokens)
pub mod jwt;
pub mod mfa_totp;
pub mod mfa_webauthn;
pub mod password;
pub mod rbac;
pub mod refresh;
pub mod session;
// Commonly re-exported types
pub use jwt::{AccessClaims, JwtError};
pub use password::{hash_password, verify_password, PasswordError};
pub use rbac::{AuthConfig, AuthUser, UserRole};
pub use session::{LoginRequest, LoginResponse, SessionError, SessionUser};

View File

@ -0,0 +1,103 @@
//! TOTP (Time-based One-Time Password) MFA implementation.
//!
//! Uses TOTP-rs with HMAC-SHA1, 6-digit codes, 30-second window.
//! Compatible with Google Authenticator, Authy, and standard TOTP apps.
use serde::{Deserialize, Serialize};
use thiserror::Error;
use totp_rs::{Algorithm, Secret, TOTP};
/// TOTP issuer label shown in authenticator apps.
const ISSUER: &str = "Linux Patch Manager";
#[derive(Debug, Error)]
pub enum TotpError {
#[error("Failed to create TOTP: {0}")]
Creation(String),
#[error("Invalid TOTP secret")]
InvalidSecret,
#[error("TOTP code verification failed")]
VerificationFailed,
}
/// TOTP setup response returned to the user during MFA enrollment.
#[derive(Debug, Serialize, Deserialize)]
pub struct TotpSetup {
/// Base32-encoded secret for manual entry in authenticator apps.
pub secret_base32: String,
/// OTP Auth URI for QR code generation (otpauth://totp/...).
pub otp_uri: String,
}
/// Generate a new TOTP secret and return setup information.
///
/// The caller should store `secret_base32` in the database after
/// the user verifies the first code.
pub fn generate_setup(username: &str) -> Result<TotpSetup, TotpError> {
let secret = Secret::generate_secret();
let secret_base32 = secret.to_encoded().to_string();
let totp = build_totp(username, &secret_base32)?;
let otp_uri = totp.get_url();
Ok(TotpSetup {
secret_base32,
otp_uri,
})
}
/// Verify a TOTP code against the stored secret.
///
/// Accepts codes within a ±1 step window (±30 seconds) to handle clock skew.
pub fn verify_code(username: &str, secret_base32: &str, code: &str) -> Result<bool, TotpError> {
let totp = build_totp(username, secret_base32)?;
let valid = totp
.check_current(code)
.map_err(|_| TotpError::VerificationFailed)?;
Ok(valid)
}
/// Build a TOTP instance from a base32 secret.
fn build_totp(username: &str, secret_base32: &str) -> Result<TOTP, TotpError> {
let secret = Secret::Encoded(secret_base32.to_string());
let secret_bytes = secret.to_bytes().map_err(|_| TotpError::InvalidSecret)?;
// With the `otpauth` feature, TOTP::new signature is:
// new(issuer, account_name, algorithm, digits, skew, step, secret)
TOTP::new(
Algorithm::SHA1,
6, // digits
1, // skew
30, // step (seconds)
secret_bytes,
Some(ISSUER.to_string()),
username.to_string(),
)
.map_err(|e| TotpError::Creation(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_setup_produces_valid_uri() {
let setup = generate_setup("testuser").unwrap();
assert!(!setup.secret_base32.is_empty());
assert!(setup.otp_uri.starts_with("otpauth://totp/"));
}
#[test]
fn verify_with_current_code() {
let setup = generate_setup("testuser").unwrap();
let totp = build_totp("testuser", &setup.secret_base32).unwrap();
let code = totp.generate_current().unwrap();
assert!(verify_code("testuser", &setup.secret_base32, &code).unwrap());
}
#[test]
fn wrong_code_fails() {
let setup = generate_setup("testuser").unwrap();
assert!(!verify_code("testuser", &setup.secret_base32, "000000").unwrap());
}
}

View File

@ -0,0 +1,51 @@
//! WebAuthn (FIDO2) MFA stub.
//!
//! Full implementation planned for M2 extension or M3.
//! WebAuthn requires stateful registration/authentication ceremonies
//! and a compatible client library (webauthn-rs).
//!
//! For M2, TOTP is the primary MFA method.
//! WebAuthn credentials are stored in the `users.webauthn_credential` JSONB
//! column and will be processed here when implemented.
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum WebAuthnError {
#[error("WebAuthn not yet implemented")]
NotImplemented,
}
/// Placeholder for WebAuthn registration options.
#[derive(Debug, Serialize, Deserialize)]
pub struct RegistrationOptions {
pub message: String,
}
/// Begin WebAuthn registration ceremony (stub).
pub fn begin_registration(_username: &str) -> Result<RegistrationOptions, WebAuthnError> {
Err(WebAuthnError::NotImplemented)
}
/// Complete WebAuthn registration ceremony (stub).
pub fn complete_registration(
_username: &str,
_response: &serde_json::Value,
) -> Result<serde_json::Value, WebAuthnError> {
Err(WebAuthnError::NotImplemented)
}
/// Begin WebAuthn authentication ceremony (stub).
pub fn begin_authentication(_username: &str) -> Result<serde_json::Value, WebAuthnError> {
Err(WebAuthnError::NotImplemented)
}
/// Verify WebAuthn authentication response (stub).
pub fn verify_authentication(
_username: &str,
_credential: &serde_json::Value,
_response: &serde_json::Value,
) -> Result<bool, WebAuthnError> {
Err(WebAuthnError::NotImplemented)
}

View File

@ -1 +1,93 @@
//! password — stub for M2.
//! Password hashing and verification using Argon2id.
//!
//! Parameters (calibrated per OWASP recommendations):
//! - Algorithm: Argon2id
//! - Memory cost: 65536 KiB (64 MiB)
//! - Time cost: 3 iterations
//! - Parallelism: 1
use argon2::{
password_hash::{
rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
},
Argon2, Params, Version,
};
use thiserror::Error;
/// Argon2id parameters per spec.
const M_COST: u32 = 65536; // 64 MiB
const T_COST: u32 = 3; // 3 iterations
const P_COST: u32 = 1; // 1 thread
#[derive(Debug, Error)]
pub enum PasswordError {
#[error("Failed to hash password: {0}")]
HashError(String),
#[error("Failed to verify password: {0}")]
VerifyError(String),
#[error("Invalid password hash format")]
InvalidHash,
}
/// Build an Argon2id instance with calibrated parameters.
fn argon2() -> Result<Argon2<'static>, PasswordError> {
let params = Params::new(M_COST, T_COST, P_COST, None)
.map_err(|e| PasswordError::HashError(e.to_string()))?;
Ok(Argon2::new(argon2::Algorithm::Argon2id, Version::V0x13, params))
}
/// Hash a plaintext password using Argon2id with a random salt.
///
/// Returns the PHC string format hash suitable for storage.
pub fn hash_password(password: &str) -> Result<String, PasswordError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = argon2()?;
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| PasswordError::HashError(e.to_string()))?;
Ok(hash.to_string())
}
/// Verify a plaintext password against a stored Argon2id PHC hash.
///
/// Returns `Ok(true)` if the password matches, `Ok(false)` if not.
pub fn verify_password(password: &str, hash: &str) -> Result<bool, PasswordError> {
let parsed_hash =
PasswordHash::new(hash).map_err(|_| PasswordError::InvalidHash)?;
let argon2 = argon2()?;
match argon2.verify_password(password.as_bytes(), &parsed_hash) {
Ok(()) => Ok(true),
Err(argon2::password_hash::Error::Password) => Ok(false),
Err(e) => Err(PasswordError::VerifyError(e.to_string())),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_and_verify_roundtrip() {
let password = "super-secret-password-123!";
let hash = hash_password(password).unwrap();
assert!(hash.starts_with("$argon2id$"));
assert!(verify_password(password, &hash).unwrap());
}
#[test]
fn wrong_password_fails() {
let hash = hash_password("correct-horse").unwrap();
assert!(!verify_password("wrong-password", &hash).unwrap());
}
#[test]
fn different_salts_produce_different_hashes() {
let hash1 = hash_password("same-password").unwrap();
let hash2 = hash_password("same-password").unwrap();
assert_ne!(hash1, hash2); // different salts
}
}

View File

@ -1 +1,214 @@
//! rbac — stub for M2.
//! Role-Based Access Control (RBAC) middleware for Axum.
//!
//! Provides:
//! - JWT extraction and validation from `Authorization: Bearer <token>` header
//! - Role enforcement (`admin`, `operator`)
//! - Group-scoped access (enforced at the handler level using `AuthUser` extension)
//! - IP whitelist enforcement
use axum::{
extract::Request,
http::{HeaderMap, StatusCode},
middleware::Next,
response::{IntoResponse, Json, Response},
};
use ipnet::IpNet;
use serde_json::json;
use std::net::IpAddr;
use std::str::FromStr;
use std::sync::Arc;
use uuid::Uuid;
use crate::jwt::{validate_access_token, AccessClaims, JwtError};
/// User identity extracted from a validated JWT, inserted as a request extension.
#[derive(Debug, Clone)]
pub struct AuthUser {
pub user_id: Uuid,
pub username: String,
pub role: UserRole,
pub claims: AccessClaims,
}
/// Application roles.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UserRole {
Admin,
Operator,
}
impl UserRole {
pub fn from_str(s: &str) -> Option<Self> {
match s {
"admin" => Some(Self::Admin),
"operator" => Some(Self::Operator),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Admin => "admin",
Self::Operator => "operator",
}
}
/// Admin can do everything; operator has limited scope.
pub fn is_admin(&self) -> bool {
matches!(self, Self::Admin)
}
}
/// Shared auth configuration injected via Axum state.
#[derive(Clone)]
pub struct AuthConfig {
/// Ed25519 public key PEM for JWT verification.
pub verify_key_pem: String,
/// IP whitelist (empty = allow all).
pub ip_whitelist: Vec<IpNet>,
}
impl AuthConfig {
pub fn new(verify_key_pem: String, ip_whitelist_cidrs: &[String]) -> Self {
let ip_whitelist = ip_whitelist_cidrs
.iter()
.filter_map(|cidr| IpNet::from_str(cidr).ok())
.collect();
Self {
verify_key_pem,
ip_whitelist,
}
}
/// Check if an IP address is allowed by the whitelist.
/// If the whitelist is empty, all IPs are allowed.
pub fn is_ip_allowed(&self, ip: &IpAddr) -> bool {
if self.ip_whitelist.is_empty() {
return true;
}
self.ip_whitelist.iter().any(|net| net.contains(ip))
}
}
/// Extract `Authorization: Bearer <token>` from request headers.
fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
}
/// Extract the remote IP from `X-Forwarded-For`.
fn extract_remote_ip(headers: &HeaderMap) -> Option<IpAddr> {
headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.and_then(|s| s.trim().parse().ok())
}
/// Unauthorized JSON response helper.
fn unauthorized(message: &str) -> Response {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": { "code": "unauthorized", "message": message } })),
)
.into_response()
}
/// Forbidden JSON response helper.
fn forbidden(message: &str) -> Response {
(
StatusCode::FORBIDDEN,
Json(json!({ "error": { "code": "forbidden", "message": message } })),
)
.into_response()
}
/// Middleware: authenticate any valid JWT (admin or operator).
///
/// Inserts `AuthUser` into request extensions on success.
/// Rejects with 401 if token is missing/invalid, 403 if IP is blocked.
pub async fn require_auth(
auth_config: Arc<AuthConfig>,
mut req: Request,
next: Next,
) -> Response {
// IP whitelist check
if let Some(ip) = extract_remote_ip(req.headers()) {
if !auth_config.is_ip_allowed(&ip) {
tracing::warn!(ip = %ip, "Request blocked by IP whitelist");
return forbidden("Access denied");
}
}
// Extract and validate JWT
let token = match extract_bearer_token(req.headers()) {
Some(t) => t,
None => return unauthorized("Missing authorization token"),
};
let claims = match validate_access_token(token, &auth_config.verify_key_pem) {
Ok(c) => c,
Err(JwtError::Expired) => return unauthorized("Token expired"),
Err(e) => {
tracing::debug!(error = %e, "JWT validation failed");
return unauthorized("Invalid token");
}
};
let role = match UserRole::from_str(&claims.role) {
Some(r) => r,
None => return unauthorized("Invalid role in token"),
};
let user_id = match claims.user_id() {
Ok(id) => id,
Err(_) => return unauthorized("Invalid user ID in token"),
};
let auth_user = AuthUser {
user_id,
username: claims.username.clone(),
role,
claims,
};
req.extensions_mut().insert(auth_user);
next.run(req).await
}
/// Middleware: require the `admin` role.
/// Must be chained AFTER `require_auth` (which inserts `AuthUser`).
pub async fn require_admin(req: Request, next: Next) -> Response {
let auth_user = match req.extensions().get::<AuthUser>().cloned() {
Some(u) => u,
None => return unauthorized("Authentication required"),
};
if !auth_user.role.is_admin() {
return forbidden("Admin role required");
}
next.run(req).await
}
/// Axum extractor: pulls `AuthUser` from request extensions.
impl<S> axum::extract::FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = Response;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<AuthUser>()
.cloned()
.ok_or_else(|| unauthorized("Authentication required"))
}
}

View File

@ -0,0 +1,171 @@
//! Opaque refresh token management.
//!
//! - 256-bit cryptographically random opaque tokens
//! - Stored as SHA-256 hash in the database (never the raw token)
//! - 1-hour sliding inactivity timeout, updated on each use
//! - Rotated on use (old token revoked, new one issued)
//! - Revocable by admin force-logout
use chrono::{Duration, Utc};
use rand::RngCore;
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use thiserror::Error;
use uuid::Uuid;
/// Length of the raw refresh token in bytes (256 bits).
const TOKEN_BYTES: usize = 32;
/// Sliding inactivity window: 1 hour.
const INACTIVITY_TIMEOUT_HOURS: i64 = 1;
#[derive(Debug, Error)]
pub enum RefreshError {
#[error("Refresh token not found or revoked")]
Invalid,
#[error("Refresh token expired")]
Expired,
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
}
/// Raw (plaintext) refresh token — returned to the client, never stored.
#[derive(Debug, Clone)]
pub struct RawRefreshToken(pub String);
impl RawRefreshToken {
/// Hex-encode a raw 256-bit random token.
pub fn generate() -> Self {
let mut bytes = [0u8; TOKEN_BYTES];
rand::thread_rng().fill_bytes(&mut bytes);
Self(hex::encode(bytes))
}
/// Return the SHA-256 hash of this token for database storage.
pub fn hash(&self) -> String {
let digest = Sha256::digest(self.0.as_bytes());
hex::encode(digest)
}
}
/// Database row representation of a stored refresh token.
#[derive(Debug, sqlx::FromRow)]
pub struct StoredRefreshToken {
pub id: Uuid,
pub user_id: Uuid,
pub expires_at: chrono::DateTime<Utc>,
pub revoked: bool,
}
/// Issue a new refresh token for the given user and store it in the database.
///
/// Returns the raw (plaintext) token to be sent to the client.
pub async fn issue(
pool: &PgPool,
user_id: Uuid,
user_agent: Option<&str>,
ip_address: Option<&str>,
) -> Result<RawRefreshToken, RefreshError> {
let token = RawRefreshToken::generate();
let hash = token.hash();
let expires_at = Utc::now() + Duration::hours(INACTIVITY_TIMEOUT_HOURS);
sqlx::query(
r#"
INSERT INTO refresh_tokens (user_id, token_hash, expires_at, user_agent, ip_address)
VALUES ($1, $2, $3, $4, $5::inet)
"#,
)
.bind(user_id)
.bind(&hash)
.bind(expires_at)
.bind(user_agent)
.bind(ip_address)
.execute(pool)
.await?;
tracing::debug!(user_id = %user_id, "Refresh token issued");
Ok(token)
}
/// Validate a refresh token, then rotate it (revoke old, issue new).
///
/// Returns `(new_raw_token, user_id)` if valid.
pub async fn rotate(
pool: &PgPool,
raw_token: &str,
user_agent: Option<&str>,
ip_address: Option<&str>,
) -> Result<(RawRefreshToken, Uuid), RefreshError> {
let hash = hex::encode(Sha256::digest(raw_token.as_bytes()));
let now = Utc::now();
// Look up token
let row: Option<StoredRefreshToken> = sqlx::query_as(
r#"
SELECT id, user_id, expires_at, revoked
FROM refresh_tokens
WHERE token_hash = $1
"#,
)
.bind(&hash)
.fetch_optional(pool)
.await?;
let stored = row.ok_or(RefreshError::Invalid)?;
if stored.revoked {
return Err(RefreshError::Invalid);
}
if stored.expires_at < now {
return Err(RefreshError::Expired);
}
// Revoke old token
sqlx::query(
"UPDATE refresh_tokens SET revoked = TRUE, revoked_at = NOW() WHERE id = $1",
)
.bind(stored.id)
.execute(pool)
.await?;
// Issue new token
let new_token = issue(pool, stored.user_id, user_agent, ip_address).await?;
tracing::debug!(user_id = %stored.user_id, "Refresh token rotated");
Ok((new_token, stored.user_id))
}
/// Revoke all refresh tokens for a user (force logout).
pub async fn revoke_all_for_user(
pool: &PgPool,
user_id: Uuid,
) -> Result<u64, RefreshError> {
let result = sqlx::query(
"UPDATE refresh_tokens SET revoked = TRUE, revoked_at = NOW() WHERE user_id = $1 AND revoked = FALSE",
)
.bind(user_id)
.execute(pool)
.await?;
tracing::info!(user_id = %user_id, rows = result.rows_affected(), "All refresh tokens revoked");
Ok(result.rows_affected())
}
/// Revoke a single refresh token by its raw value.
pub async fn revoke(
pool: &PgPool,
raw_token: &str,
) -> Result<(), RefreshError> {
let hash = hex::encode(Sha256::digest(raw_token.as_bytes()));
sqlx::query(
"UPDATE refresh_tokens SET revoked = TRUE, revoked_at = NOW() WHERE token_hash = $1",
)
.bind(&hash)
.execute(pool)
.await?;
Ok(())
}

View File

@ -1 +1,264 @@
//! session — stub for M2.
//! Session management: login flow, logout, token issuance.
//!
//! Login flow: password → MFA → access token + refresh token
//! Logout: revoke refresh token
//! Force logout: revoke all tokens for a user
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use thiserror::Error;
use uuid::Uuid;
use crate::{
jwt::{self, JwtError},
mfa_totp,
password::{self, PasswordError},
refresh::{self, RefreshError},
};
#[derive(Debug, Error)]
pub enum SessionError {
#[error("Invalid credentials")]
InvalidCredentials,
#[error("Account is disabled")]
AccountDisabled,
#[error("MFA required")]
MfaRequired,
#[error("Invalid MFA code")]
InvalidMfaCode,
#[error("JWT error: {0}")]
Jwt(#[from] JwtError),
#[error("Refresh token error: {0}")]
Refresh(#[from] RefreshError),
#[error("Password error: {0}")]
Password(#[from] PasswordError),
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
}
/// Successful login response returned to the client.
#[derive(Debug, Serialize, Deserialize)]
pub struct LoginResponse {
/// Short-lived JWT access token (15 minutes).
pub access_token: String,
/// Opaque refresh token (1-hour sliding window).
pub refresh_token: String,
/// Token type (always "Bearer").
pub token_type: String,
/// Access token TTL in seconds.
pub expires_in: i64,
/// User information.
pub user: SessionUser,
}
/// User summary embedded in login response.
#[derive(Debug, Serialize, Deserialize)]
pub struct SessionUser {
pub id: String,
pub username: String,
pub display_name: String,
pub role: String,
pub mfa_enabled: bool,
}
/// Database user row fetched during login.
#[derive(Debug, sqlx::FromRow)]
struct DbUser {
id: Uuid,
username: String,
display_name: String,
role: String,
auth_provider: String,
password_hash: Option<String>,
totp_secret: Option<String>,
mfa_enabled: bool,
is_active: bool,
}
/// Login request payload.
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
/// TOTP code (required if MFA is enabled).
pub totp_code: Option<String>,
}
/// Perform the full login flow for local accounts.
///
/// Steps:
/// 1. Look up user by username
/// 2. Verify password (Argon2id)
/// 3. Check account active state
/// 4. Verify MFA if enabled
/// 5. Issue access token + refresh token
/// 6. Update last_login_at
pub async fn login(
pool: &PgPool,
req: &LoginRequest,
signing_key_pem: &str,
access_ttl_secs: i64,
user_agent: Option<&str>,
ip_address: Option<&str>,
) -> Result<LoginResponse, SessionError> {
// 1. Fetch user by username
let user: Option<DbUser> = sqlx::query_as(
r#"
SELECT id, username, display_name, role, auth_provider,
password_hash, totp_secret, mfa_enabled, is_active
FROM users
WHERE username = $1 AND auth_provider = 'local'
"#,
)
.bind(&req.username)
.fetch_optional(pool)
.await?;
// Use constant-time comparison approach: always run Argon2 even on miss
let user = match user {
Some(u) => u,
None => {
// Prevent timing-based username enumeration
let _ = password::hash_password("dummy-timing-fill");
return Err(SessionError::InvalidCredentials);
}
};
// 2. Verify password
let hash = user.password_hash.as_deref().unwrap_or("");
let valid = password::verify_password(&req.password, hash)
.unwrap_or(false);
if !valid {
tracing::warn!(username = %req.username, "Login failed: invalid password");
return Err(SessionError::InvalidCredentials);
}
// 3. Check account state
if !user.is_active {
tracing::warn!(username = %req.username, "Login failed: account disabled");
return Err(SessionError::AccountDisabled);
}
// 4. MFA check
if user.mfa_enabled {
let code = req.totp_code.as_deref().ok_or(SessionError::MfaRequired)?;
let secret = user.totp_secret.as_deref().unwrap_or("");
let mfa_ok = mfa_totp::verify_code(&user.username, secret, code)
.unwrap_or(false);
if !mfa_ok {
tracing::warn!(username = %req.username, "Login failed: invalid MFA code");
return Err(SessionError::InvalidMfaCode);
}
}
// 5. Issue tokens
let access_token = jwt::issue_access_token(
user.id,
&user.username,
&user.role,
access_ttl_secs,
signing_key_pem,
)?;
let raw_refresh = refresh::issue(pool, user.id, user_agent, ip_address).await?;
// 6. Update last_login_at
sqlx::query("UPDATE users SET last_login_at = $1 WHERE id = $2")
.bind(Utc::now())
.bind(user.id)
.execute(pool)
.await?;
tracing::info!(user_id = %user.id, username = %user.username, "Login successful");
Ok(LoginResponse {
access_token,
refresh_token: raw_refresh.0,
token_type: "Bearer".to_string(),
expires_in: access_ttl_secs,
user: SessionUser {
id: user.id.to_string(),
username: user.username,
display_name: user.display_name,
role: user.role,
mfa_enabled: user.mfa_enabled,
},
})
}
/// Refresh an access token using a valid refresh token.
///
/// The old refresh token is revoked and a new one issued (rotation).
pub async fn refresh_session(
pool: &PgPool,
raw_refresh_token: &str,
signing_key_pem: &str,
access_ttl_secs: i64,
user_agent: Option<&str>,
ip_address: Option<&str>,
) -> Result<LoginResponse, SessionError> {
let (new_refresh, user_id) =
refresh::rotate(pool, raw_refresh_token, user_agent, ip_address).await?;
// Fetch user for token claims
let user: DbUser = sqlx::query_as(
r#"
SELECT id, username, display_name, role, auth_provider,
password_hash, totp_secret, mfa_enabled, is_active
FROM users WHERE id = $1
"#,
)
.bind(user_id)
.fetch_one(pool)
.await?;
if !user.is_active {
// Revoke all tokens and deny
let _ = refresh::revoke_all_for_user(pool, user_id).await;
return Err(SessionError::AccountDisabled);
}
let access_token = jwt::issue_access_token(
user.id,
&user.username,
&user.role,
access_ttl_secs,
signing_key_pem,
)?;
Ok(LoginResponse {
access_token,
refresh_token: new_refresh.0,
token_type: "Bearer".to_string(),
expires_in: access_ttl_secs,
user: SessionUser {
id: user.id.to_string(),
username: user.username,
display_name: user.display_name,
role: user.role,
mfa_enabled: user.mfa_enabled,
},
})
}
/// Logout: revoke the current refresh token.
pub async fn logout(
pool: &PgPool,
raw_refresh_token: &str,
) -> Result<(), SessionError> {
refresh::revoke(pool, raw_refresh_token).await?;
Ok(())
}
/// Force-logout: revoke all refresh tokens for a user.
pub async fn force_logout(
pool: &PgPool,
user_id: Uuid,
) -> Result<u64, SessionError> {
let count = refresh::revoke_all_for_user(pool, user_id).await?;
Ok(count)
}