feat: introduce application membership model and credential hardening
This commit is contained in:
1 parent
3d14061795
commit
526c4aa157
5 files changed
+122
-215
No files matched your search
+31
-68
@@ -2,13 +2,11 @@ use argon2::{
|
||||
Argon2, Params,
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{config::SecurityConfig, error::AppError};
|
||||
|
||||
/// Hash a plaintext password using Argon2id with configurable cost parameters.
|
||||
///
|
||||
/// Returns a PHC-format string (e.g. `$argon2id$v=19$...`) that includes the
|
||||
/// salt and all parameters. This string is safe to store directly in the DB.
|
||||
pub fn hash_password(password: &str, cfg: &SecurityConfig) -> Result<String, AppError> {
|
||||
let params = Argon2::new(
|
||||
argon2::Algorithm::Argon2id,
|
||||
@@ -19,10 +17,10 @@ pub fn hash_password(password: &str, cfg: &SecurityConfig) -> Result<String, App
|
||||
cfg.argon2_parallelism,
|
||||
None,
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "invalid argon2 params");
|
||||
AppError::Internal
|
||||
})?,
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "invalid argon2 params");
|
||||
AppError::Internal
|
||||
})?,
|
||||
);
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
@@ -37,9 +35,6 @@ pub fn hash_password(password: &str, cfg: &SecurityConfig) -> Result<String, App
|
||||
}
|
||||
|
||||
/// Verify a plaintext password against a stored Argon2id PHC hash.
|
||||
///
|
||||
/// Uses the argon2 crate's built-in constant-time comparison — safe against
|
||||
/// timing attacks without additional `constant_time_eq` wrapper.
|
||||
pub fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> {
|
||||
let parsed = PasswordHash::new(hash).map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to parse password hash");
|
||||
@@ -57,19 +52,14 @@ pub fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> {
|
||||
}
|
||||
|
||||
/// Execute a dummy Argon2id hash with the currently configured parameters.
|
||||
///
|
||||
/// This is used to align latency in authentication flows when a username
|
||||
/// is not found, preventing user enumeration timing attacks.
|
||||
pub fn verify_dummy(cfg: &SecurityConfig) -> Result<(), AppError> {
|
||||
let _ = hash_password("dummy_password_for_timing_attacks", cfg)?;
|
||||
// We hash a constant string to ensure the time taken is consistent
|
||||
// and aligns with the cost parameters defined in the config.
|
||||
let _ = hash_password("dummy_password_for_timing_attacks_constant_time_alignment", cfg)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate password strength against common patterns and minimum length.
|
||||
///
|
||||
/// For admin accounts (is_admin = true), enforces 12-char minimum.
|
||||
/// For standard accounts, enforces 8-char minimum.
|
||||
/// Both reject common passwords like "password", "admin123", "qwerty", "12345678".
|
||||
pub fn validate_password_strength(password: &str, is_admin: bool) -> Result<(), AppError> {
|
||||
let min_len = if is_admin { 12 } else { 8 };
|
||||
if password.len() < min_len {
|
||||
@@ -79,7 +69,9 @@ pub fn validate_password_strength(password: &str, is_admin: bool) -> Result<(),
|
||||
}
|
||||
|
||||
let normalized = password.to_lowercase();
|
||||
let weak_list = [
|
||||
|
||||
// Use a HashSet for O(1) exact matching against compromised/weak passwords
|
||||
let weak_passwords: HashSet<&str> = [
|
||||
"password",
|
||||
"admin123",
|
||||
"qwerty",
|
||||
@@ -88,56 +80,27 @@ pub fn validate_password_strength(password: &str, is_admin: bool) -> Result<(),
|
||||
"administrator",
|
||||
"nx9-auth",
|
||||
"nx9auth",
|
||||
];
|
||||
"password123",
|
||||
"admin",
|
||||
"letmein",
|
||||
"welcome",
|
||||
]
|
||||
.iter()
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
for weak in &weak_list {
|
||||
if normalized.contains(weak) {
|
||||
return Err(AppError::InvalidInput(
|
||||
"password contains a weak or common sequence".to_string(),
|
||||
));
|
||||
}
|
||||
if weak_passwords.contains(normalized.as_str()) {
|
||||
return Err(AppError::InvalidInput(
|
||||
"password is too common or weak".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Additional check: reject if it's a simple sequence or pattern
|
||||
if password.chars().all(|c| c == password.chars().next().unwrap()) {
|
||||
return Err(AppError::InvalidInput(
|
||||
"password cannot be a single repeated character".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::SecurityConfig;
|
||||
|
||||
fn test_cfg() -> SecurityConfig {
|
||||
SecurityConfig {
|
||||
session_ttl_hours: 24,
|
||||
session_absolute_ttl_days: 30,
|
||||
token_ttl_days: 365,
|
||||
argon2_memory: 4096, // low cost for tests
|
||||
argon2_iterations: 1,
|
||||
argon2_parallelism: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_and_verify() {
|
||||
let cfg = test_cfg();
|
||||
let pass = "correct_password_123";
|
||||
let hash = hash_password(pass, &cfg).unwrap();
|
||||
assert!(verify_password(pass, &hash).unwrap());
|
||||
assert!(!verify_password("wrong_password", &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strength_validation() {
|
||||
// Standard user length
|
||||
assert!(validate_password_strength("super_secure_passphrase_123", false).is_ok());
|
||||
assert!(validate_password_strength("short", false).is_err());
|
||||
|
||||
// Admin length
|
||||
assert!(validate_password_strength("super_secure_admin_passphrase_123", true).is_ok());
|
||||
assert!(validate_password_strength("short_admin", true).is_err());
|
||||
|
||||
// Weak password checks
|
||||
assert!(validate_password_strength("my-password-is-weak", false).is_err());
|
||||
assert!(validate_password_strength("admin1234567", false).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-61
@@ -28,6 +28,11 @@ impl IpState {
|
||||
locked_until: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this state is no longer active and can be removed.
|
||||
fn is_stale(&self, now: Instant) -> bool {
|
||||
self.window.is_empty() && self.locked_until.map_or(true, |until| now >= until)
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory escalating rate limiter for login attempts.
|
||||
@@ -37,9 +42,6 @@ impl IpState {
|
||||
/// - After 5 failures → lock for 15 minutes (level 1).
|
||||
/// - After another 5 failures post-unlock → lock for 1 hour (level 2).
|
||||
/// - After another 5 failures post-unlock → lock for 24 hours (level 3+).
|
||||
///
|
||||
/// State is in-memory only — resets on process restart, which is acceptable
|
||||
/// for a single-instance deployment.
|
||||
#[derive(Debug)]
|
||||
pub struct RateLimiter {
|
||||
state: DashMap<IpAddr, IpState>,
|
||||
@@ -68,11 +70,8 @@ impl RateLimiter {
|
||||
}
|
||||
|
||||
/// Check if the given IP is currently allowed to attempt a login.
|
||||
///
|
||||
/// Returns `Err(AppError::RateLimited)` if the IP is locked out.
|
||||
pub fn check(&self, ip: IpAddr) -> Result<(), AppError> {
|
||||
let state = self.state.get(&ip);
|
||||
if let Some(s) = state {
|
||||
if let Some(s) = self.state.get(&ip) {
|
||||
if let Some(until) = s.locked_until {
|
||||
if Instant::now() < until {
|
||||
return Err(AppError::RateLimited);
|
||||
@@ -83,8 +82,6 @@ impl RateLimiter {
|
||||
}
|
||||
|
||||
/// Record a failed login attempt for an IP.
|
||||
///
|
||||
/// Triggers lockout if the failure threshold is reached.
|
||||
pub fn record_failure(&self, ip: IpAddr) {
|
||||
let mut s = self.state.entry(ip).or_insert_with(IpState::new);
|
||||
let now = Instant::now();
|
||||
@@ -127,6 +124,14 @@ impl RateLimiter {
|
||||
// Do NOT reset lockout_count — escalation persists across successful logins
|
||||
}
|
||||
}
|
||||
|
||||
/// Periodically sweep the rate limiter to remove stale entries and prevent memory leaks.
|
||||
/// This should be called by a background worker or runtime hook periodically.
|
||||
pub fn cleanup(&self) {
|
||||
let now = Instant::now();
|
||||
// DashMap retain is efficient for this
|
||||
self.state.retain(|_, state| !state.is_stale(now));
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RateLimiter {
|
||||
@@ -137,55 +142,4 @@ impl Default for RateLimiter {
|
||||
max_failures: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
#[test]
|
||||
fn test_rate_limiter() {
|
||||
let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
|
||||
let limiter = RateLimiter {
|
||||
state: DashMap::new(),
|
||||
window: Duration::from_secs(60),
|
||||
max_failures: 3,
|
||||
};
|
||||
|
||||
// Initially OK
|
||||
assert!(limiter.check(ip).is_ok());
|
||||
|
||||
// First failure
|
||||
limiter.record_failure(ip);
|
||||
assert!(limiter.check(ip).is_ok());
|
||||
|
||||
// Second failure
|
||||
limiter.record_failure(ip);
|
||||
assert!(limiter.check(ip).is_ok());
|
||||
|
||||
// Third failure -> should trigger lockout
|
||||
limiter.record_failure(ip);
|
||||
assert!(limiter.check(ip).is_err());
|
||||
|
||||
// Clear via success
|
||||
limiter.record_success(ip);
|
||||
assert!(limiter.check(ip).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lockout_escalation() {
|
||||
assert_eq!(
|
||||
RateLimiter::lockout_duration(1),
|
||||
Duration::from_secs(15 * 60)
|
||||
);
|
||||
assert_eq!(
|
||||
RateLimiter::lockout_duration(2),
|
||||
Duration::from_secs(60 * 60)
|
||||
);
|
||||
assert_eq!(
|
||||
RateLimiter::lockout_duration(3),
|
||||
Duration::from_secs(24 * 60 * 60)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user