Skip to main content

rusmes_auth/
lib.rs

1//! # rusmes-auth
2//!
3//! Pluggable authentication backends for the RusMES mail server.
4//!
5//! ## Overview
6//!
7//! `rusmes-auth` provides a unified [`AuthBackend`] trait that abstracts over multiple
8//! authentication strategies. All backends implement the same async interface, allowing
9//! them to be composed, swapped, or wrapped by middleware such as the brute-force
10//! protector found in the [`security`] module.
11//!
12//! ## Backends
13//!
14//! | Backend | Module | Notes |
15//! |---------|--------|-------|
16//! | File (htpasswd-style) | [`mod@file`] | bcrypt hashes, atomic writes |
17//! | LDAP / Active Directory | [`backends::ldap`] | connection pooling, group filtering |
18//! | SQL (SQLite / Postgres / MySQL) | [`backends::sql`] | bcrypt + Argon2 + SCRAM-SHA-256 |
19//! | OAuth2 / OIDC | [`backends::oauth2`] | JWT validation, XOAUTH2 SASL |
20//! | System (Unix) | [`backends::system`] | Pure Rust `/etc/shadow` auth |
21//!
22//! ## SASL Mechanisms
23//!
24//! The [`sasl`] module implements RFC-compliant SASL mechanisms on top of any `AuthBackend`:
25//!
26//! - `PLAIN` (RFC 4616)
27//! - `LOGIN` (obsolete but widely supported)
28//! - `CRAM-MD5` (RFC 2195)
29//! - `SCRAM-SHA-256` (RFC 5802 / RFC 7677)
30//! - `XOAUTH2` (RFC 7628)
31//!
32//! ## Security
33//!
34//! The [`security`] module provides:
35//!
36//! - Brute-force / account-lockout protection (progressive lockout)
37//! - Per-IP rate limiting
38//! - Password strength validation (entropy, character class, banned list)
39//! - In-memory audit logging
40//!
41//! ## Example
42//!
43//! ```rust,no_run
44//! use rusmes_auth::file::FileAuthBackend;
45//! use rusmes_auth::AuthBackend;
46//! use rusmes_proto::Username;
47//!
48//! # async fn example() -> anyhow::Result<()> {
49//! let backend = FileAuthBackend::new("/etc/rusmes/passwd").await?;
50//! let user = Username::new("alice".to_string())?;
51//! let ok = backend.authenticate(&user, "s3cr3t").await?;
52//! println!("authenticated: {ok}");
53//! # Ok(())
54//! # }
55//! ```
56
57use async_trait::async_trait;
58use rusmes_proto::Username;
59use std::sync::Arc;
60
61pub mod backends;
62pub mod file;
63pub mod sasl;
64pub mod security;
65
66// ============================================================================
67// ScramCredentials — RFC 5802 credential bundle
68// ============================================================================
69
70/// SCRAM-SHA-256 credential bundle as defined by RFC 5802.
71///
72/// These values are derived from a user's password via PBKDF2-SHA-256 and stored
73/// so that the server never needs to see the raw password for SCRAM authentication.
74///
75/// Derivation (per RFC 5802 §3):
76/// ```text
77/// SaltedPassword = PBKDF2-SHA-256(password, salt, i)
78/// ClientKey      = HMAC-SHA-256(SaltedPassword, "Client Key")
79/// StoredKey      = SHA-256(ClientKey)        // stored here
80/// ServerKey      = HMAC-SHA-256(SaltedPassword, "Server Key")  // stored here
81/// ```
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct ScramCredentials {
84    /// Random salt bytes used in the PBKDF2 derivation.
85    pub salt: Vec<u8>,
86    /// Number of PBKDF2 iterations (≥ 4096 recommended by RFC 7677).
87    pub iteration_count: u32,
88    /// `SHA-256(ClientKey)` — used to verify the client proof without storing the key.
89    pub stored_key: Vec<u8>,
90    /// `HMAC-SHA-256(SaltedPassword, "Server Key")` — used to produce the server signature.
91    pub server_key: Vec<u8>,
92}
93
94// ============================================================================
95// AuthBackendKind — config-driven factory enum
96// ============================================================================
97
98/// Discriminated-union config type for all supported authentication backends.
99///
100/// Call [`AuthBackendKind::build`] to construct an `Arc<dyn AuthBackend>` from
101/// whichever variant was selected in the server configuration.
102pub enum AuthBackendKind {
103    /// File-based backend (bcrypt htpasswd format with optional SCRAM extension).
104    File(FileBackendConfig),
105    /// SQL database backend (PostgreSQL / MySQL / SQLite via sqlx).
106    Sql(backends::sql::SqlConfig),
107    /// LDAP / Active Directory backend.
108    Ldap(backends::ldap::LdapConfig),
109    /// OAuth2 / OIDC backend (JWT introspection, XOAUTH2 SASL).
110    OAuth2(backends::oauth2::OAuth2Config),
111}
112
113/// Configuration for the file-based authentication backend.
114#[derive(Debug, Clone, Default)]
115pub struct FileBackendConfig {
116    /// Path to the passwd file.
117    pub path: String,
118    /// Password-hashing algorithm used for *new* password writes.
119    ///
120    /// Existing hashes (whichever algorithm produced them) continue to verify
121    /// regardless of this setting. Defaults to bcrypt for backwards compatibility
122    /// with deployments that pre-date argon2 support.
123    pub hash_algorithm: file::HashAlgorithm,
124}
125
126impl AuthBackendKind {
127    /// Construct an `Arc<dyn AuthBackend>` from this configuration variant.
128    ///
129    /// The returned backend is immediately ready for use; SQL backends have their
130    /// connection pool opened, LDAP backends hold a lazily-allocated pool, and the
131    /// file backend has already read its passwd file into memory.
132    pub async fn build(self) -> anyhow::Result<Arc<dyn AuthBackend>> {
133        match self {
134            AuthBackendKind::File(cfg) => {
135                let backend =
136                    file::FileAuthBackend::with_algorithm(&cfg.path, cfg.hash_algorithm).await?;
137                Ok(Arc::new(backend))
138            }
139            AuthBackendKind::Sql(cfg) => {
140                let backend = backends::sql::SqlBackend::new(cfg).await?;
141                Ok(Arc::new(backend))
142            }
143            AuthBackendKind::Ldap(cfg) => {
144                let backend = backends::ldap::LdapBackend::new(cfg);
145                Ok(Arc::new(backend))
146            }
147            AuthBackendKind::OAuth2(cfg) => {
148                let backend = backends::oauth2::OAuth2Backend::new(cfg);
149                Ok(Arc::new(backend))
150            }
151        }
152    }
153}
154
155#[async_trait]
156pub trait AuthBackend: Send + Sync {
157    /// Authenticate a user with username and password
158    async fn authenticate(&self, username: &Username, password: &str) -> anyhow::Result<bool>;
159
160    /// Verify if a username maps to a valid identity
161    async fn verify_identity(&self, username: &Username) -> anyhow::Result<bool>;
162
163    /// List all users (for admin CLI)
164    async fn list_users(&self) -> anyhow::Result<Vec<Username>>;
165
166    /// Create a new user with the given password
167    async fn create_user(&self, username: &Username, password: &str) -> anyhow::Result<()>;
168
169    /// Delete a user
170    async fn delete_user(&self, username: &Username) -> anyhow::Result<()>;
171
172    /// Change a user's password
173    async fn change_password(&self, username: &Username, new_password: &str) -> anyhow::Result<()>;
174
175    // ========================================================================
176    // SCRAM-SHA-256 Support (Optional)
177    // ========================================================================
178    // IMPORTANT: SCRAM-SHA-256 requires different credential storage than
179    // bcrypt. The following methods provide SCRAM-specific credential access.
180    // Default implementations return errors to maintain backward compatibility.
181
182    /// Fetch the full RFC 5802 SCRAM-SHA-256 credential bundle for a user.
183    ///
184    /// Returns `Ok(Some(creds))` when pre-computed SCRAM credentials exist,
185    /// `Ok(None)` when this backend does not store SCRAM credentials for the
186    /// user (SCRAM-SHA-256 should then be declined; PLAIN / LOGIN remain available),
187    /// and `Err(...)` only on I/O or parse failures.
188    ///
189    /// The default implementation returns `Ok(None)`, meaning SQL / LDAP / OAuth2
190    /// backends gracefully degrade without requiring any code changes in those backends.
191    /// Only the file backend overrides this with a real implementation.
192    async fn fetch_scram_credentials(
193        &self,
194        _user: &str,
195    ) -> anyhow::Result<Option<ScramCredentials>> {
196        Ok(None)
197    }
198
199    /// Get SCRAM-SHA-256 parameters (salt, iteration count) for a user
200    ///
201    /// Returns (salt, iterations) if SCRAM credentials are stored.
202    /// Default implementation returns an error indicating SCRAM is not supported.
203    async fn get_scram_params(&self, _username: &str) -> anyhow::Result<(Vec<u8>, u32)> {
204        Err(anyhow::anyhow!(
205            "SCRAM-SHA-256 credential storage not implemented in this AuthBackend"
206        ))
207    }
208
209    /// Get SCRAM-SHA-256 StoredKey for a user
210    ///
211    /// StoredKey = SHA256(ClientKey) where ClientKey = HMAC(SaltedPassword, "Client Key")
212    /// Default implementation returns an error indicating SCRAM is not supported.
213    async fn get_scram_stored_key(&self, _username: &str) -> anyhow::Result<Vec<u8>> {
214        Err(anyhow::anyhow!(
215            "SCRAM-SHA-256 credential storage not implemented in this AuthBackend"
216        ))
217    }
218
219    /// Get SCRAM-SHA-256 ServerKey for a user
220    ///
221    /// ServerKey = HMAC(SaltedPassword, "Server Key")
222    /// Default implementation returns an error indicating SCRAM is not supported.
223    async fn get_scram_server_key(&self, _username: &str) -> anyhow::Result<Vec<u8>> {
224        Err(anyhow::anyhow!(
225            "SCRAM-SHA-256 credential storage not implemented in this AuthBackend"
226        ))
227    }
228
229    /// Store SCRAM-SHA-256 credentials for a user
230    ///
231    /// This should store: salt, iterations, StoredKey, and ServerKey
232    /// Default implementation returns an error indicating SCRAM is not supported.
233    async fn store_scram_credentials(
234        &self,
235        _username: &Username,
236        _salt: Vec<u8>,
237        _iterations: u32,
238        _stored_key: Vec<u8>,
239        _server_key: Vec<u8>,
240    ) -> anyhow::Result<()> {
241        Err(anyhow::anyhow!(
242            "SCRAM-SHA-256 credential storage not implemented in this AuthBackend"
243        ))
244    }
245
246    // ========================================================================
247    // APOP (MD5 Digest) Support (Optional)
248    // ========================================================================
249    // APOP requires access to plaintext password to compute MD5 digest.
250    // This is incompatible with bcrypt and most secure password storage.
251    // Backends can optionally support APOP by storing plaintext passwords
252    // separately (not recommended for production).
253
254    /// Get plaintext password for APOP authentication
255    ///
256    /// Returns the plaintext password if available.
257    /// Default implementation returns an error indicating APOP is not supported.
258    ///
259    /// WARNING: This method exposes plaintext passwords and should only be used
260    /// for APOP authentication. Consider disabling APOP in production environments.
261    async fn get_apop_secret(&self, _username: &Username) -> anyhow::Result<String> {
262        Err(anyhow::anyhow!(
263            "APOP authentication not supported by this AuthBackend"
264        ))
265    }
266
267    // ========================================================================
268    // Bearer Token / OAuth2 Support (Optional)
269    // ========================================================================
270
271    /// Verify a Bearer token and return the authenticated username.
272    ///
273    /// This is the entry point for HTTP Bearer authentication (e.g. in JMAP).
274    /// The default implementation rejects all tokens unconditionally so that
275    /// backends without OAuth2 support never silently accept Bearer credentials.
276    ///
277    /// Backends that support Bearer / JWT verification (e.g. [`backends::oauth2`])
278    /// override this method to perform real token introspection or JWT validation.
279    ///
280    /// # Errors
281    /// Returns an `anyhow::Error` wrapping a rejected-token message if the
282    /// token is invalid, expired, or this backend does not support Bearer auth.
283    /// Callers that want a typed rejection should map the error to their own
284    /// error type (e.g. `AuthError::Unauthorized`).
285    async fn verify_bearer_token(&self, token: &str) -> anyhow::Result<Username> {
286        // Suppress unused-variable lint without doing anything with the token.
287        let _ = token;
288        Err(anyhow::anyhow!(
289            "Bearer token authentication is not supported by this AuthBackend"
290        ))
291    }
292}