feat: introduce application membership model and credential hardening

This commit is contained in:
thakares committed 2026-08-07 19:00:09 +05:30
1 parent 3d14061795
commit 526c4aa157
5 files changed
+122 -215

No files matched your search

+31 -68
View File
@@ -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
View File
@@ -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)
);
}
}
}