Skip to main content

wisegate_core/auth/
mod.rs

1//! Authentication module for HTTP Basic Authentication (RFC 7617).
2//!
3//! Provides credential storage, verification, and HTTP header parsing for
4//! implementing Basic Authentication in WiseGate.
5
6pub mod hash;
7
8use base64::{Engine, engine::general_purpose::STANDARD};
9
10/// A single credential entry (username and password/hash).
11#[derive(Debug, Clone)]
12pub struct Credential {
13    username: String,
14    password: String,
15}
16
17impl Credential {
18    /// Creates a new credential from username and password/hash.
19    pub fn new(username: String, password: String) -> Self {
20        Self { username, password }
21    }
22
23    /// Parses a credential string in the format "username:password".
24    ///
25    /// # Errors
26    ///
27    /// Returns `None` if the string doesn't contain a colon or has an empty username.
28    pub fn parse(value: &str) -> Option<Self> {
29        let (user, pass) = value.split_once(':')?;
30        if user.is_empty() {
31            return None;
32        }
33        Some(Self::new(user.to_string(), pass.to_string()))
34    }
35
36    /// Returns the username.
37    pub fn username(&self) -> &str {
38        &self.username
39    }
40
41    /// Returns the password/hash.
42    pub fn password(&self) -> &str {
43        &self.password
44    }
45}
46
47/// Stores credentials for authentication.
48#[derive(Debug, Clone)]
49pub struct Credentials {
50    entries: Vec<Credential>,
51}
52
53impl Credentials {
54    /// Creates a new empty credentials store.
55    pub fn new() -> Self {
56        Self {
57            entries: Vec::new(),
58        }
59    }
60
61    /// Creates credentials from a slice of credential entries.
62    pub fn from_entries(entries: Vec<Credential>) -> Self {
63        Self { entries }
64    }
65
66    /// Returns true if no credentials are stored.
67    pub fn is_empty(&self) -> bool {
68        self.entries.is_empty()
69    }
70
71    /// Returns the number of stored credentials.
72    pub fn len(&self) -> usize {
73        self.entries.len()
74    }
75
76    /// Returns an iterator over the credentials.
77    pub fn iter(&self) -> impl Iterator<Item = &Credential> {
78        self.entries.iter()
79    }
80
81    /// Verifies credentials from an HTTP Authorization header.
82    ///
83    /// Expects the header value in the format `Basic {base64-encoded-credentials}`
84    /// per RFC 7617.
85    pub fn verify(&self, auth_header: &str) -> bool {
86        let Some(encoded) = auth_header.strip_prefix("Basic ") else {
87            return false;
88        };
89
90        let Ok(decoded) = STANDARD.decode(encoded.trim()) else {
91            return false;
92        };
93
94        let Ok(decoded_str) = String::from_utf8(decoded) else {
95            return false;
96        };
97
98        // RFC 7617: user-id cannot contain colons, password may contain colons
99        let Some((user, password)) = decoded_str.split_once(':') else {
100            return false;
101        };
102
103        self.check(user, password)
104    }
105
106    /// Checks if the given username and password match any stored credential.
107    /// Uses constant-time comparison to prevent timing attacks.
108    fn check(&self, username: &str, password: &str) -> bool {
109        let mut found = false;
110        for cred in &self.entries {
111            // Use constant-time comparison for username to prevent enumeration
112            let user_match = hash::constant_time_eq(cred.username.as_bytes(), username.as_bytes());
113            // Always verify password to prevent timing leaks
114            let pass_match = hash::verify(password, &cred.password);
115            if user_match && pass_match {
116                found = true;
117            }
118        }
119        found
120    }
121}
122
123impl Default for Credentials {
124    fn default() -> Self {
125        Self::new()
126    }
127}
128
129/// Checks if the request has valid authentication.
130///
131/// Returns `true` if authentication is successful or not required.
132/// Returns `false` if authentication is required but failed.
133///
134/// # Arguments
135///
136/// * `auth_header` - The value of the Authorization header, if present.
137/// * `credentials` - The credentials to check against.
138pub fn check_basic_auth(auth_header: Option<&str>, credentials: &Credentials) -> bool {
139    if credentials.is_empty() {
140        // No credentials configured, authentication not required
141        return true;
142    }
143
144    match auth_header {
145        Some(header) => credentials.verify(header),
146        None => false,
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn test_credential_parse_valid() {
156        let cred = Credential::parse("admin:secret").unwrap();
157        assert_eq!(cred.username(), "admin");
158        assert_eq!(cred.password(), "secret");
159    }
160
161    #[test]
162    fn test_credential_parse_with_colon_in_password() {
163        let cred = Credential::parse("admin:sec:ret").unwrap();
164        assert_eq!(cred.username(), "admin");
165        assert_eq!(cred.password(), "sec:ret");
166    }
167
168    #[test]
169    fn test_credential_parse_no_colon() {
170        assert!(Credential::parse("invalid").is_none());
171    }
172
173    #[test]
174    fn test_credential_parse_empty_user() {
175        assert!(Credential::parse(":password").is_none());
176    }
177
178    #[test]
179    fn test_credentials_empty() {
180        let creds = Credentials::new();
181        assert!(creds.is_empty());
182        assert_eq!(creds.len(), 0);
183    }
184
185    #[test]
186    fn test_credentials_from_entries() {
187        let entries = vec![
188            Credential::new("admin".to_string(), "secret".to_string()),
189            Credential::new("user".to_string(), "pass".to_string()),
190        ];
191        let creds = Credentials::from_entries(entries);
192        assert!(!creds.is_empty());
193        assert_eq!(creds.len(), 2);
194    }
195
196    #[test]
197    fn test_verify_plain_text() {
198        let creds = Credentials::from_entries(vec![Credential::new(
199            "admin".to_string(),
200            "secret".to_string(),
201        )]);
202
203        let header = format!("Basic {}", STANDARD.encode("admin:secret"));
204        assert!(creds.verify(&header));
205    }
206
207    #[test]
208    fn test_verify_wrong_password() {
209        let creds = Credentials::from_entries(vec![Credential::new(
210            "admin".to_string(),
211            "secret".to_string(),
212        )]);
213
214        let header = format!("Basic {}", STANDARD.encode("admin:wrong"));
215        assert!(!creds.verify(&header));
216    }
217
218    #[test]
219    fn test_verify_wrong_user() {
220        let creds = Credentials::from_entries(vec![Credential::new(
221            "admin".to_string(),
222            "secret".to_string(),
223        )]);
224
225        let header = format!("Basic {}", STANDARD.encode("wrong:secret"));
226        assert!(!creds.verify(&header));
227    }
228
229    #[test]
230    fn test_verify_bcrypt() {
231        // bcrypt hash for "password"
232        let hash = "$2y$05$bvIG6Nmid91Mu9RcmmWZfO5HJIMCT8riNW0hEp8f6/FuA2/mHZFpe";
233        let creds =
234            Credentials::from_entries(vec![Credential::new("user".to_string(), hash.to_string())]);
235
236        let header = format!("Basic {}", STANDARD.encode("user:password"));
237        assert!(creds.verify(&header));
238    }
239
240    #[test]
241    fn test_verify_sha1() {
242        // SHA1 hash for "password"
243        let hash = "{SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g=";
244        let creds =
245            Credentials::from_entries(vec![Credential::new("user".to_string(), hash.to_string())]);
246
247        let header = format!("Basic {}", STANDARD.encode("user:password"));
248        assert!(creds.verify(&header));
249    }
250
251    #[test]
252    fn test_verify_apr1() {
253        // APR1 hash for "password"
254        let hash = "$apr1$lZL6V/ci$eIMz/iKDkbtys/uU7LEK00";
255        let creds =
256            Credentials::from_entries(vec![Credential::new("user".to_string(), hash.to_string())]);
257
258        let header = format!("Basic {}", STANDARD.encode("user:password"));
259        assert!(creds.verify(&header));
260    }
261
262    #[test]
263    fn test_verify_multiple_credentials() {
264        let creds = Credentials::from_entries(vec![
265            Credential::new("admin".to_string(), "admin123".to_string()),
266            Credential::new("user1".to_string(), "pass1".to_string()),
267            Credential::new("user2".to_string(), "pass2".to_string()),
268        ]);
269
270        assert!(creds.verify(&format!("Basic {}", STANDARD.encode("admin:admin123"))));
271        assert!(creds.verify(&format!("Basic {}", STANDARD.encode("user1:pass1"))));
272        assert!(creds.verify(&format!("Basic {}", STANDARD.encode("user2:pass2"))));
273        assert!(!creds.verify(&format!("Basic {}", STANDARD.encode("unknown:pass"))));
274    }
275
276    #[test]
277    fn test_verify_invalid_base64() {
278        let creds = Credentials::from_entries(vec![Credential::new(
279            "admin".to_string(),
280            "secret".to_string(),
281        )]);
282        assert!(!creds.verify("Basic not-valid-base64!!!"));
283    }
284
285    #[test]
286    fn test_verify_non_basic_auth() {
287        let creds = Credentials::from_entries(vec![Credential::new(
288            "admin".to_string(),
289            "secret".to_string(),
290        )]);
291        assert!(!creds.verify("Bearer some-token"));
292    }
293
294    #[test]
295    fn test_verify_missing_colon_in_decoded() {
296        let creds = Credentials::from_entries(vec![Credential::new(
297            "admin".to_string(),
298            "secret".to_string(),
299        )]);
300        let header = format!("Basic {}", STANDARD.encode("no-colon-here"));
301        assert!(!creds.verify(&header));
302    }
303
304    #[test]
305    fn test_check_basic_auth_no_credentials() {
306        let creds = Credentials::new();
307        // No credentials = authentication not required
308        assert!(check_basic_auth(None, &creds));
309        assert!(check_basic_auth(Some("Basic anything"), &creds));
310    }
311
312    #[test]
313    fn test_check_basic_auth_with_credentials() {
314        let creds = Credentials::from_entries(vec![Credential::new(
315            "admin".to_string(),
316            "secret".to_string(),
317        )]);
318
319        // No header = fail
320        assert!(!check_basic_auth(None, &creds));
321
322        // Valid header = success
323        let valid_header = format!("Basic {}", STANDARD.encode("admin:secret"));
324        assert!(check_basic_auth(Some(&valid_header), &creds));
325
326        // Invalid header = fail
327        let invalid_header = format!("Basic {}", STANDARD.encode("admin:wrong"));
328        assert!(!check_basic_auth(Some(&invalid_header), &creds));
329    }
330}