use std::{ collections::VecDeque, net::IpAddr, sync::Arc, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use dashmap::DashMap; use data_encoding::BASE32_NOPAD; use hmac::{Hmac, Mac}; use rand::RngCore; use sha1::Sha1; use subtle::ConstantTimeEq; use crate::{ config::AppConfig, error::{AppError, AppResult}, models::{AccountKind, Role}, }; #[derive(Clone)] pub struct Session { pub username: String, pub kind: AccountKind, pub role: Role, pub allowed_domains: Arc<[String]>, pub csrf: String, mail_password: Option>, expires_at: Instant, } impl Session { pub fn can_manage_domain(&self, domain: &str) -> bool { self.role.is_global() || self .allowed_domains .iter() .any(|d| d == domain || d == "ALL") } pub fn require_admin(&self) -> AppResult<()> { if self.role.is_admin() { Ok(()) } else { Err(AppError::forbidden("Administrator role required")) } } pub fn require_global(&self) -> AppResult<()> { if self.role.is_global() { Ok(()) } else { Err(AppError::forbidden("Global administrator role required")) } } pub fn require_domain(&self, domain: &str) -> AppResult<()> { self.require_admin()?; if self.can_manage_domain(domain) { Ok(()) } else { Err(AppError::forbidden( "Domain is outside this administrator's scope", )) } } pub fn mail_password(&self) -> Option<&str> { self.mail_password.as_deref() } } #[derive(Clone)] pub struct PendingLogin { pub username: String, pub kind: AccountKind, pub role: Role, pub allowed_domains: Arc<[String]>, pub ip: IpAddr, pub mail_password: Option>, pub expires_at: Instant, } #[derive(Clone)] pub struct AuthStore { sessions: Arc>, pending: Arc>, attempts: Arc>>, timeout: Duration, login_window: Duration, max_attempts: usize, } impl AuthStore { pub fn new(config: &AppConfig) -> Self { Self { sessions: Arc::new(DashMap::new()), pending: Arc::new(DashMap::new()), attempts: Arc::new(DashMap::new()), timeout: Duration::from_secs( config.security.session_timeout_minutes.saturating_mul(60), ), login_window: Duration::from_secs(config.security.login_window_seconds), max_attempts: config.security.login_attempts, } } pub fn create_session( &self, username: String, kind: AccountKind, role: Role, allowed_domains: Arc<[String]>, mail_password: Option>, ) -> (String, Session) { let token = random_token(32); let session = Session { username, kind, role, allowed_domains, csrf: random_token(32), mail_password, expires_at: Instant::now() + self.timeout, }; self.sessions.insert(token.clone(), session.clone()); (token, session) } pub fn get_session(&self, token: &str) -> Option { let item = self.sessions.get(token)?; if Instant::now() >= item.expires_at { drop(item); self.sessions.remove(token); return None; } Some(item.clone()) } pub fn revoke(&self, token: &str) { self.sessions.remove(token); } pub fn update_mail_password(&self, token: &str, password: String) { if let Some(mut session) = self.sessions.get_mut(token) && session.kind == AccountKind::User { session.mail_password = Some(Arc::::from(password)); } } pub fn create_pending( &self, username: String, kind: AccountKind, role: Role, allowed_domains: Arc<[String]>, ip: IpAddr, mail_password: Option>, ) -> String { let token = random_token(32); self.pending.insert( token.clone(), PendingLogin { username, kind, role, allowed_domains, ip, mail_password, expires_at: Instant::now() + Duration::from_secs(300), }, ); token } pub fn take_pending(&self, token: &str, ip: IpAddr) -> Option { let (_, pending) = self.pending.remove(token)?; if pending.ip != ip || Instant::now() >= pending.expires_at { return None; } Some(pending) } pub fn login_allowed(&self, ip: IpAddr, username: &str) -> bool { let now = Instant::now(); let key = format!("{ip}|{}", username.to_ascii_lowercase()); let mut entry = self.attempts.entry(key).or_default(); while entry .front() .is_some_and(|t| now.duration_since(*t) > self.login_window) { entry.pop_front(); } if entry.len() >= self.max_attempts { return false; } entry.push_back(now); true } pub fn clear_attempts(&self, ip: IpAddr, username: &str) { self.attempts .remove(&format!("{ip}|{}", username.to_ascii_lowercase())); } } pub fn verify_csrf(session: &Session, provided: Option<&str>) -> AppResult<()> { let Some(provided) = provided else { return Err(AppError::forbidden("Missing CSRF token")); }; let a = session.csrf.as_bytes(); let b = provided.as_bytes(); if a.len() == b.len() && bool::from(a.ct_eq(b)) { Ok(()) } else { Err(AppError::forbidden("Invalid CSRF token")) } } pub fn verify_totp(secret: &str, code: &str) -> bool { if code.len() != 6 || !code.bytes().all(|b| b.is_ascii_digit()) { return false; } let normalized = secret.trim().replace([' ', '-'], "").to_ascii_uppercase(); let Ok(key) = BASE32_NOPAD.decode(normalized.as_bytes()) else { return false; }; let now = match SystemTime::now().duration_since(UNIX_EPOCH) { Ok(v) => v.as_secs(), Err(_) => return false, }; let target = code.parse::().unwrap_or(u32::MAX); for offset in [-1i64, 0, 1] { let counter = (now / 30) as i64 + offset; if counter < 0 { continue; } if totp_code(&key, counter as u64) == target { return true; } } false } fn totp_code(key: &[u8], counter: u64) -> u32 { let mut mac = Hmac::::new_from_slice(key).expect("HMAC accepts arbitrary key size"); mac.update(&counter.to_be_bytes()); let digest = mac.finalize().into_bytes(); let offset = (digest[19] & 0x0f) as usize; let binary = ((u32::from(digest[offset]) & 0x7f) << 24) | (u32::from(digest[offset + 1]) << 16) | (u32::from(digest[offset + 2]) << 8) | u32::from(digest[offset + 3]); binary % 1_000_000 } pub fn random_token(bytes: usize) -> String { let mut buf = vec![0u8; bytes]; rand::rng().fill_bytes(&mut buf); URL_SAFE_NO_PAD.encode(buf) } #[cfg(test)] mod tests { use super::*; #[test] fn rfc6238_sha1_vector_internal() { let key = b"12345678901234567890"; assert_eq!(totp_code(key, 59 / 30), 287082); } }