gateryx 0.1.6

Secure HTTP gateway for IoT and Industrial applications
Documentation
use std::{
    path::{Path, PathBuf},
    sync::Arc,
    time::Duration,
};

use bma_ts::{Monotonic, Timestamp};
use rand::Rng;
use serde::{Deserialize, Serialize};
use tracing::warn;
use zeroize::{Zeroize, ZeroizeOnDrop};

use crate::{ConfigCheckIssue, Error, Result, bp, eapi, passkeys, storage::Storage, tokens};

pub mod db;
pub mod eva;
pub mod htpasswd;
pub mod ldap;

/// Context from the master process passed to authenticators that need it (e.g. EVA).
#[derive(Clone)]
pub struct AuthMasterContext {
    pub eapi_bus: Option<Arc<eapi::EAPIBus>>,
}

#[derive(Default, Deserialize, Clone)]
pub struct UserAgentList(Vec<String>);

impl UserAgentList {
    pub fn matches(&self, user_agent: &str) -> bool {
        for pattern in &self.0 {
            if let Some(id) = pattern.strip_suffix('*') {
                if user_agent.starts_with(id) {
                    return true;
                }
            } else if user_agent == pattern {
                return true;
            }
        }
        false
    }
}

fn default_401_agent_list() -> UserAgentList {
    UserAgentList(vec![
        "curl/*".to_string(),
        "Wget/*".to_string(),
        "git/*".to_string(),
    ])
}

#[derive(Deserialize, Zeroize, ZeroizeOnDrop)]
#[serde(deny_unknown_fields)]
pub struct Config {
    #[serde(default)]
    pub authenticator: AuthenticatorConfig,
    pub breakin_protection: bp::Config,
    #[zeroize(skip)]
    pub www_root: PathBuf,
    pub tokens: tokens::Config,
    #[serde(default)]
    pub passkeys: Option<passkeys::Config>,
    #[serde(default = "default_401_agent_list")]
    #[zeroize(skip)]
    pub reply_401_to_user_agents: UserAgentList,
}

impl Config {
    pub fn canonicalize_path(&mut self, work_dir: &Path) {
        self.authenticator.canonicalize_path(work_dir);
        self.tokens.canonicalize_path(work_dir);
    }
    pub fn check(&self, config_dir: &Path) -> Vec<ConfigCheckIssue> {
        let mut issues = Vec::new();
        issues.extend(self.authenticator.check(config_dir));
        issues.extend(self.tokens.check(config_dir));
        if !self.www_root.exists() {
            issues.push(ConfigCheckIssue::Warning(format!(
                "Auth www root path does not exist: {}",
                self.www_root.display()
            )));
        }
        issues
    }
}

fn default_min_length() -> usize {
    8
}

#[derive(Serialize, Deserialize, Debug, Copy, Clone, strum::Display)]
#[strum(serialize_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum UserKind {
    Reg,
    Svc,
}

#[derive(Serialize, Deserialize, Debug)]
pub struct UserInfo {
    pub login: String,
    pub active: u8,
    pub kind: UserKind,
    pub created: Timestamp,
    pub last_login: Timestamp,
    pub groups: Vec<String>,
}

#[derive(Serialize, Deserialize, Debug)]
pub struct GroupInfo {
    pub name: String,
    pub users: Vec<String>,
}

#[allow(clippy::struct_excessive_bools)]
#[derive(Deserialize, Clone, Zeroize, ZeroizeOnDrop)]
pub struct PasswordPolicy {
    #[serde(default = "default_min_length")]
    pub min_length: usize,
    #[serde(default)]
    pub require_uppercase: bool,
    #[serde(default)]
    pub require_lowercase: bool,
    #[serde(default)]
    pub require_digit: bool,
    #[serde(default)]
    pub require_special: bool,
}

impl PasswordPolicy {
    pub fn validate(&self, password: &str) -> Result<()> {
        if password.len() < self.min_length {
            return Err(Error::failed(format!(
                "password must be at least {} characters long",
                self.min_length
            )));
        }
        if self.require_uppercase && !password.chars().any(char::is_uppercase) {
            return Err(Error::failed(
                "password must contain at least one uppercase letter",
            ));
        }
        if self.require_lowercase && !password.chars().any(char::is_lowercase) {
            return Err(Error::failed(
                "password must contain at least one lowercase letter",
            ));
        }
        if self.require_digit && !password.chars().any(|c| c.is_ascii_digit()) {
            return Err(Error::failed("password must contain at least one digit"));
        }
        if self.require_special
            && !password
                .chars()
                .any(|c| !c.is_alphanumeric() && !c.is_whitespace())
        {
            return Err(Error::failed(
                "password must contain at least one special character",
            ));
        }
        Ok(())
    }
}

struct DummyAuthenticator;

#[async_trait::async_trait]
impl Authenticator for DummyAuthenticator {
    async fn present(&self, _login: &str) -> bool {
        false
    }
    async fn verify(&self, _login: &str, _password: &str, _otp: Option<&str>) -> AuthResult {
        AuthResult::Failure
    }
    async fn user_groups(&self, _login: &str) -> Result<Vec<String>> {
        Ok(vec![])
    }
}

#[derive(Deserialize, Default, Zeroize, ZeroizeOnDrop)]
#[serde(deny_unknown_fields, tag = "kind", content = "params")]
#[serde(rename_all = "lowercase")]
pub enum AuthenticatorConfig {
    #[default]
    None,
    Htpasswd {
        #[zeroize(skip)]
        path: PathBuf,
    },
    Db {
        policy: Option<PasswordPolicy>,
    },
    Ldap(ldap::Config),
    Eva(eva::Config),
}

impl AuthenticatorConfig {
    pub fn check(&self, work_dir: &Path) -> Vec<ConfigCheckIssue> {
        let mut issues = Vec::new();
        match self {
            AuthenticatorConfig::None
            | AuthenticatorConfig::Db { .. }
            | AuthenticatorConfig::Eva { .. } => {}
            AuthenticatorConfig::Htpasswd { path } => {
                let full_path = if path.is_absolute() {
                    path.clone()
                } else {
                    work_dir.join(path)
                };
                if !full_path.exists() {
                    issues.push(ConfigCheckIssue::Error(format!(
                        "Htpasswd file does not exist at path: {}",
                        full_path.display()
                    )));
                }
            }
            AuthenticatorConfig::Ldap(config) => {
                if let Some(ref ca) = config.ca {
                    let full_path = if ca.is_absolute() {
                        ca.clone()
                    } else {
                        work_dir.join(ca)
                    };
                    if !full_path.exists() {
                        issues.push(ConfigCheckIssue::Error(format!(
                            "LDAP CA certificate file does not exist at path: {}",
                            full_path.display()
                        )));
                    }
                }
            }
        }
        issues
    }
}

impl AuthenticatorConfig {
    pub fn canonicalize_path(&mut self, work_dir: &Path) {
        match self {
            AuthenticatorConfig::None
            | AuthenticatorConfig::Db { .. }
            | AuthenticatorConfig::Eva { .. } => {}
            AuthenticatorConfig::Htpasswd { path } => {
                if !path.is_absolute() {
                    *path = work_dir.join(&*path);
                }
            }
            AuthenticatorConfig::Ldap(config) => {
                if let Some(ref mut ca) = config.ca
                    && !ca.is_absolute()
                {
                    *ca = work_dir.join(&*ca);
                }
            }
        }
    }
}

pub enum AuthResult {
    Success {
        groups: Vec<String>,
    },
    Failure,
    Error,
    /// User must enter OTP code (no setup needed).
    OtpRequested,
    /// User must set up TOTP (show secret/QR) then enter code.
    OtpSetup {
        secret: String,
    },
    /// OTP was wrong; password was correct.
    OtpInvalid,
}

#[async_trait::async_trait]
pub trait Authenticator: Send + Sync {
    async fn present(&self, login: &str) -> bool;
    async fn user_groups(&self, login: &str) -> Result<Vec<String>>;
    async fn verify(&self, login: &str, password: &str, otp: Option<&str>) -> AuthResult;
    async fn add(&self, _login: &str, _password: &str) -> Result<()> {
        Err(Error::NotImplemented)
    }
    async fn spawn_secure_workers(&self) -> Result<()> {
        Ok(())
    }
    async fn delete(&self, _login: &str) -> Result<()> {
        Err(Error::NotImplemented)
    }
    async fn set_password_forced(&self, _login: &str, _password: &str) -> Result<()> {
        Err(Error::NotImplemented)
    }
    async fn set_password(
        &self,
        login: &str,
        old_password: &str,
        new_password: &str,
    ) -> Result<()> {
        if !matches!(
            self.verify(login, old_password, None).await,
            AuthResult::Success { .. }
        ) {
            return Err(Error::failed("authentication failed"));
        }
        self.set_password_forced(login, new_password).await
    }
    async fn list(&self) -> Result<Vec<UserInfo>> {
        Err(Error::NotImplemented)
    }
    async fn list_groups(&self) -> Result<Vec<GroupInfo>> {
        Err(Error::NotImplemented)
    }
    async fn add_group(&self, _name: &str) -> Result<()> {
        Err(Error::NotImplemented)
    }
    async fn delete_group(&self, _name: &str) -> Result<()> {
        Err(Error::NotImplemented)
    }
    async fn add_user_to_group(&self, _login: &str, _group: &str) -> Result<()> {
        Err(Error::NotImplemented)
    }
    async fn remove_user_from_group(&self, _login: &str, _group: &str) -> Result<()> {
        Err(Error::NotImplemented)
    }
}

pub async fn create_authenticator(
    config: &Config,
    db: Arc<dyn Storage>,
    auth_master_ctx: Option<Arc<AuthMasterContext>>,
) -> Result<Box<dyn Authenticator>> {
    match &config.authenticator {
        AuthenticatorConfig::None => {
            warn!("no authenticator configured");
            let auth = DummyAuthenticator;
            Ok(Box::new(auth))
        }
        AuthenticatorConfig::Htpasswd { path } => {
            let auth = htpasswd::HtpasswdAuthenticator::create(path).await?;
            Ok(Box::new(auth))
        }
        AuthenticatorConfig::Db { policy } => {
            let auth = db::DbAuth::new(db, policy.clone());
            Ok(Box::new(auth))
        }
        AuthenticatorConfig::Ldap(ldap_config) => {
            let auth = ldap::LdapAuthenticator::create(ldap_config).await?;
            Ok(Box::new(auth))
        }
        AuthenticatorConfig::Eva(eva_config) => {
            let ctx = auth_master_ctx.ok_or_else(|| {
                Error::failed(
                    "Eva authenticator requires [eapi] section with bus connection in config",
                )
            })?;
            let auth = eva::EvaAuthenticator::new(eva_config, ctx);
            Ok(Box::new(auth))
        }
    }
}

/// Used to prevent timing attacks by adding a random delay to authentication attempts.
pub struct RandomSleeper {
    start: Monotonic,
    ms_range: std::ops::Range<u64>,
}

impl RandomSleeper {
    pub fn new(ms_range: std::ops::Range<u64>) -> Self {
        Self {
            start: Monotonic::now(),
            ms_range,
        }
    }
    pub async fn sleep(&self) {
        let mut sleep_duration =
            Duration::from_millis(rand::thread_rng().gen_range(self.ms_range.clone()));
        let elapsed = self.start.elapsed();
        if elapsed > sleep_duration {
            return;
        }
        sleep_duration -= elapsed;
        tokio::time::sleep(sleep_duration).await;
    }
}