use std::collections::HashMap;
use std::sync::{Arc, RwLock};
#[derive(Clone, Debug)]
pub struct AuthConfig {
users: HashMap<String, String>,
allow_default: bool,
disabled: bool,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
users: HashMap::new(),
allow_default: true,
disabled: false,
}
}
}
impl AuthConfig {
pub fn from_env() -> Self {
if auth_disabled_from_env() {
return Self::disabled();
}
match std::env::var("ELEKTROMAIL_USERS") {
Ok(value) => Self::from_env_value(&value),
Err(_) => Self::default(),
}
}
pub(crate) fn from_env_value(value: &str) -> Self {
let users = parse_users(value);
let allow_default = users.is_empty();
Self {
users,
allow_default,
disabled: false,
}
}
pub fn from_users(value: &str) -> Self {
Self::from_env_value(value)
}
pub fn disabled() -> Self {
Self {
users: HashMap::new(),
allow_default: true,
disabled: true,
}
}
pub(crate) fn into_store(self) -> AuthStore {
AuthStore::from_config(self)
}
pub fn authenticate(&self, user: &str, pass: &str) -> bool {
if self.disabled {
return true;
}
if !self.users.is_empty() {
return self.users.get(user).is_some_and(|stored| stored == pass);
}
self.allow_default && user == "user" && pass == "pass"
}
pub fn user_count(&self) -> usize {
self.users.len()
}
}
pub trait UserStore: Send + Sync {
fn authenticate(&self, user: &str, pass: &str) -> bool;
fn add_user(&self, user: &str, pass: &str, email: Option<&str>) -> bool;
fn list_users(&self) -> Vec<String>;
fn reset_to_seed(&self);
fn auth_disabled(&self) -> bool;
fn user_count(&self) -> usize;
fn email_for(&self, user: &str) -> Option<String>;
}
pub type SharedUserStore = Arc<dyn UserStore>;
#[derive(Clone, Debug)]
pub struct AuthStore {
inner: Arc<RwLock<AuthState>>,
}
#[derive(Debug)]
struct AuthState {
users: HashMap<String, String>,
seed_users: HashMap<String, String>,
emails: HashMap<String, String>,
seed_emails: HashMap<String, String>,
allow_default: bool,
disabled: bool,
}
impl AuthStore {
fn from_config(config: AuthConfig) -> Self {
let state = AuthState {
seed_users: config.users.clone(),
users: config.users,
emails: HashMap::new(),
seed_emails: HashMap::new(),
allow_default: config.allow_default,
disabled: config.disabled,
};
Self {
inner: Arc::new(RwLock::new(state)),
}
}
pub fn authenticate(&self, user: &str, pass: &str) -> bool {
let state = self.inner.read().expect("auth store lock poisoned");
if state.disabled {
return true;
}
if !state.users.is_empty() {
return state.users.get(user).is_some_and(|stored| stored == pass);
}
state.allow_default && user == "user" && pass == "pass"
}
pub fn add_user(&self, user: &str, pass: &str, email: Option<&str>) -> bool {
if user.is_empty() || pass.is_empty() {
return false;
}
let mut state = self.inner.write().expect("auth store lock poisoned");
state.users.insert(user.to_string(), pass.to_string());
if let Some(email) = email {
if !email.trim().is_empty() {
state.emails.insert(user.to_string(), email.to_string());
}
}
true
}
pub fn list_users(&self) -> Vec<String> {
let mut users = {
let state = self.inner.read().expect("auth store lock poisoned");
let mut users: Vec<String> = state.users.keys().cloned().collect();
if users.is_empty() && state.allow_default {
users.push("user".to_string());
}
drop(state);
users
};
users.sort();
users
}
pub fn reset_to_seed(&self) {
let mut state = self.inner.write().expect("auth store lock poisoned");
let seed_users = state.seed_users.clone();
let seed_emails = state.seed_emails.clone();
state.users = seed_users;
state.emails = seed_emails;
}
pub fn auth_disabled(&self) -> bool {
let state = self.inner.read().expect("auth store lock poisoned");
state.disabled
}
pub fn user_count(&self) -> usize {
let state = self.inner.read().expect("auth store lock poisoned");
if state.users.is_empty() && state.allow_default {
1
} else {
state.users.len()
}
}
pub fn email_for(&self, user: &str) -> Option<String> {
let state = self.inner.read().expect("auth store lock poisoned");
state.emails.get(user).cloned()
}
}
impl UserStore for AuthStore {
fn authenticate(&self, user: &str, pass: &str) -> bool {
AuthStore::authenticate(self, user, pass)
}
fn add_user(&self, user: &str, pass: &str, email: Option<&str>) -> bool {
AuthStore::add_user(self, user, pass, email)
}
fn list_users(&self) -> Vec<String> {
AuthStore::list_users(self)
}
fn reset_to_seed(&self) {
AuthStore::reset_to_seed(self);
}
fn auth_disabled(&self) -> bool {
AuthStore::auth_disabled(self)
}
fn user_count(&self) -> usize {
AuthStore::user_count(self)
}
fn email_for(&self, user: &str) -> Option<String> {
AuthStore::email_for(self, user)
}
}
fn parse_users(value: &str) -> HashMap<String, String> {
let mut users = HashMap::new();
for entry in value.split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let mut parts = entry.split(':').map(str::trim).filter(|p| !p.is_empty());
let user = parts.next();
let second = parts.next();
let third = parts.next();
let (user, pass) = match (user, second, third) {
(Some(user), Some(pass), None) => (user, pass),
(Some(user), Some(_email), Some(pass)) => (user, pass),
_ => continue,
};
if !user.is_empty() && !pass.is_empty() {
users.insert(user.to_string(), pass.to_string());
}
}
users
}
fn auth_disabled_from_env() -> bool {
match std::env::var("ELEKTROMAIL_AUTH_DISABLED") {
Ok(value) => matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"),
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::AuthConfig;
#[test]
fn auth_store_add_and_reset() {
let store = AuthConfig::from_users("seed:seedpass").into_store();
assert!(store.authenticate("seed", "seedpass"));
assert!(!store.authenticate("new", "newpass"));
store.add_user("new", "newpass", Some("new@example.com"));
assert!(store.authenticate("new", "newpass"));
assert_eq!(store.email_for("new"), Some("new@example.com".to_string()));
assert!(store.list_users().contains(&"new".to_string()));
store.reset_to_seed();
assert!(!store.authenticate("new", "newpass"));
assert!(store.authenticate("seed", "seedpass"));
}
#[test]
fn auth_disabled_accepts_any_credentials() {
let store = AuthConfig::disabled().into_store();
assert!(store.authenticate("any", "thing"));
assert!(store.auth_disabled());
}
#[test]
fn auth_store_is_thread_safe() {
let store = AuthConfig::from_users("alpha:pass").into_store();
store.add_user("beta", "pass", None);
let mut handles = Vec::new();
for _ in 0..8 {
let store = store.clone();
handles.push(std::thread::spawn(move || {
for _ in 0..1000 {
assert!(store.authenticate("alpha", "pass"));
assert!(store.authenticate("beta", "pass"));
}
}));
}
for handle in handles {
handle.join().expect("auth thread failed");
}
}
#[test]
fn env_value_parses_single_user() {
let config = AuthConfig::from_env_value("demo:demo");
assert!(config.authenticate("demo", "demo"));
assert!(!config.authenticate("user", "pass"));
}
#[test]
fn env_value_parses_multiple_users() {
let config = AuthConfig::from_env_value("demo:demopass,test:testpass");
assert!(config.authenticate("demo", "demopass"));
assert!(config.authenticate("test", "testpass"));
assert!(!config.authenticate("demo", "wrong"));
}
#[test]
fn env_value_parses_three_part_entries() {
let config = AuthConfig::from_env_value("demo:demo@localhost:demopass");
assert!(config.authenticate("demo", "demopass"));
}
#[test]
fn empty_env_value_falls_back_to_default() {
let config = AuthConfig::from_env_value("");
assert!(config.authenticate("user", "pass"));
}
}