274 lines
7.5 KiB
Rust
274 lines
7.5 KiB
Rust
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<Arc<str>>,
|
|
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<Arc<str>>,
|
|
pub expires_at: Instant,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct AuthStore {
|
|
sessions: Arc<DashMap<String, Session>>,
|
|
pending: Arc<DashMap<String, PendingLogin>>,
|
|
attempts: Arc<DashMap<String, VecDeque<Instant>>>,
|
|
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<Arc<str>>,
|
|
) -> (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<Session> {
|
|
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::<str>::from(password));
|
|
}
|
|
}
|
|
|
|
pub fn create_pending(
|
|
&self,
|
|
username: String,
|
|
kind: AccountKind,
|
|
role: Role,
|
|
allowed_domains: Arc<[String]>,
|
|
ip: IpAddr,
|
|
mail_password: Option<Arc<str>>,
|
|
) -> 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<PendingLogin> {
|
|
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::<u32>().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::<Sha1>::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);
|
|
}
|
|
}
|