argon2_creds/
config.rs

1// SPDX-FileCopyrightText: 2023 Aravinth Manivannan <realaravinth@batsense.net>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5//! Credential processor and configuration
6use derive_builder::Builder;
7use lazy_static::initialize;
8use validator::Validate;
9
10use crate::errors::*;
11use crate::filters::{beep, filter, forbidden};
12use crate::filters::{
13    blacklist::RE_BLACKLIST, profainity::RE_PROFAINITY, user_case_mapped::RE_USERNAME_CASE_MAPPED,
14};
15
16/// Credential management configuration
17#[derive(Clone, Builder)]
18pub struct Config {
19    /// activates profanity filter. Default `false`
20    #[builder(default = "false")]
21    profanity: bool,
22    /// activates blacklist filter. Default `true`
23    #[builder(default = "true")]
24    blacklist: bool,
25    /// activates username_case_mapped filter. Default `true`
26    #[builder(default = "true")]
27    username_case_mapped: bool,
28    /// activates profanity filter. Default `false`
29    #[builder(default = "PasswordPolicyBuilder::default().build().unwrap()")]
30    password_policy: PasswordPolicy,
31}
32
33impl PasswordPolicyBuilder {
34    fn validate(&self) -> Result<(), String> {
35        if self.min > self.max {
36            Err("Configuration error: Password max length shorter than min length".to_string())
37        } else {
38            Ok(())
39        }
40    }
41}
42
43#[derive(Clone, Builder)]
44#[builder(build_fn(validate = "Self::validate"))]
45pub struct PasswordPolicy {
46    /// See [argon2 config][argon2::Config]
47    #[builder(default = "argon2::Config::default()")]
48    argon2: argon2::Config<'static>,
49    /// minimum password length
50    #[builder(default = "8")]
51    min: usize,
52    /// maximum password length(to protect against DoS attacks)
53    #[builder(default = "64")]
54    max: usize,
55    /// salt length in password hashing
56    #[builder(default = "32")]
57    salt_length: usize,
58}
59
60impl Default for PasswordPolicy {
61    fn default() -> Self {
62        PasswordPolicyBuilder::default().build().unwrap()
63    }
64}
65
66#[derive(Validate)]
67struct Email<'a> {
68    #[validate(email)]
69    pub email: &'a str,
70}
71
72impl Default for Config {
73    fn default() -> Self {
74        ConfigBuilder::default().build().unwrap()
75    }
76}
77
78impl Config {
79    /// Normalises, converts to lowercase and applies filters to the username
80    pub fn username(&self, username: &str) -> CredsResult<String> {
81        use ammonia::clean;
82        use unicode_normalization::UnicodeNormalization;
83
84        let clean_username = clean(username)
85            .to_lowercase()
86            .nfc()
87            .collect::<String>()
88            .trim()
89            .to_owned();
90
91        self.validate_username(&clean_username)?;
92        Ok(clean_username)
93    }
94
95    /// Checks if input is an email
96    pub fn email(&self, email: &str) -> CredsResult<()> {
97        let email = Email {
98            email: email.trim(),
99        };
100        Ok(email.validate()?)
101    }
102
103    fn validate_username(&self, username: &str) -> CredsResult<()> {
104        if self.username_case_mapped {
105            filter(&username)?;
106        }
107        if self.blacklist {
108            forbidden(&username)?;
109        }
110        if self.profanity {
111            beep(&username)?;
112        }
113        Ok(())
114    }
115
116    /// Generate hash for passsword
117    pub fn password(&self, password: &str) -> CredsResult<String> {
118        use argon2::hash_encoded;
119        use rand::distributions::Alphanumeric;
120        use rand::{thread_rng, Rng};
121
122        let length = password.len();
123
124        if self.password_policy.min > length {
125            return Err(CredsError::PasswordTooShort);
126        }
127
128        if self.password_policy.max < length {
129            return Err(CredsError::PasswordTooLong);
130        }
131
132        let mut rng = thread_rng();
133        let salt: String = std::iter::repeat(())
134            .map(|()| rng.sample(Alphanumeric))
135            .map(char::from)
136            .take(self.password_policy.salt_length)
137            .collect();
138
139        Ok(hash_encoded(
140            password.as_bytes(),
141            salt.as_bytes(),
142            &self.password_policy.argon2,
143        )?)
144    }
145
146    /// Verify password against hash
147    pub fn verify(hash: &str, password: &str) -> CredsResult<bool> {
148        let status = argon2::verify_encoded(hash, password.as_bytes())?;
149        Ok(status)
150    }
151
152    /// Initialize filters according to configuration.
153    ///
154    /// Filters are lazy initialized so there's a slight delay during the very first use of
155    /// filter. By calling this method during the early stages of program execution,
156    /// that delay can be avoided.
157    pub fn init(&self) {
158        if self.username_case_mapped {
159            initialize(&RE_USERNAME_CASE_MAPPED);
160        }
161        if self.blacklist {
162            initialize(&RE_BLACKLIST);
163        }
164        if self.profanity {
165            initialize(&RE_PROFAINITY);
166        }
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn config_works() {
176        let config = Config::default();
177        assert!(!config.profanity);
178        assert!(config.blacklist);
179        assert!(config.username_case_mapped);
180        assert_eq!(config.password_policy.salt_length, 32);
181
182        let config = ConfigBuilder::default()
183            .username_case_mapped(false)
184            .profanity(true)
185            .blacklist(false)
186            .password_policy(PasswordPolicy::default())
187            .build()
188            .unwrap();
189
190        assert!(config.profanity);
191        assert!(!config.blacklist);
192        assert!(!config.username_case_mapped);
193    }
194
195    #[test]
196    fn creds_email_err() {
197        let config = ConfigBuilder::default()
198            .username_case_mapped(false)
199            .profanity(true)
200            .blacklist(false)
201            .password_policy(PasswordPolicy::default())
202            .build()
203            .unwrap();
204        config.init();
205
206        assert_eq!(config.email("sdfasdf"), Err(CredsError::NotAnEmail));
207    }
208
209    #[test]
210    fn utils_create_new_organisation() {
211        let password = "somepassword";
212        let config = Config::default();
213        config.init();
214
215        config.email("batman@we.net").unwrap();
216        let username = config.username("Realaravinth").unwrap();
217        let hash = config.password(password).unwrap();
218
219        assert_eq!(username, "realaravinth");
220
221        assert!(Config::verify(&hash, password).unwrap(), "verify hashing");
222    }
223
224    #[test]
225    fn username_case_mapped_org() {
226        let config = ConfigBuilder::default()
227            .username_case_mapped(true)
228            .profanity(true)
229            .blacklist(false)
230            .password_policy(PasswordPolicy::default())
231            .build()
232            .unwrap();
233        config.init();
234
235        let username_err = config.username("a@test.com");
236
237        assert_eq!(username_err, Err(CredsError::UsernameCaseMappedError));
238    }
239
240    #[test]
241    fn utils_create_new_profane_organisation() {
242        let config = ConfigBuilder::default()
243            .username_case_mapped(false)
244            .profanity(true)
245            .blacklist(false)
246            .password_policy(PasswordPolicy::default())
247            .build()
248            .unwrap();
249        config.init();
250
251        let username_err = config.username("fuck");
252
253        assert_eq!(username_err, Err(CredsError::ProfainityError));
254    }
255
256    #[test]
257    fn utils_create_new_forbidden_organisation() {
258        let config = Config::default();
259        config.init();
260        let forbidden_err = config.username("webmaster");
261
262        assert_eq!(forbidden_err, Err(CredsError::BlacklistError));
263    }
264
265    #[test]
266    fn password_length_check() {
267        let min_max_error = PasswordPolicyBuilder::default().min(50).max(10).build();
268
269        assert!(min_max_error.is_err());
270
271        let config = ConfigBuilder::default()
272            .password_policy(
273                PasswordPolicyBuilder::default()
274                    .min(5)
275                    .max(10)
276                    .build()
277                    .unwrap(),
278            )
279            .build()
280            .unwrap();
281        config.init();
282
283        let too_short_err = config.password("a");
284        let too_long_err = config.password("asdfasdfasdf");
285
286        assert_eq!(too_short_err, Err(CredsError::PasswordTooShort));
287        assert_eq!(too_long_err, Err(CredsError::PasswordTooLong));
288    }
289}