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}