Skip to main content

klick_domain/authentication/
password.rs

1use std::{fmt, str::FromStr};
2
3use pwhash::bcrypt;
4use thiserror::Error;
5
6pub struct Password(String);
7
8impl fmt::Display for Password {
9    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
10        write!(f, "***")
11    }
12}
13
14impl fmt::Debug for Password {
15    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
16        write!(f, "Password(***)")
17    }
18}
19
20#[derive(Debug, Clone)]
21pub struct HashedPassword(String);
22
23#[derive(Debug, Error)]
24#[cfg_attr(test, derive(PartialEq))]
25pub enum ParseError {
26    #[error("password is too short (min. {0}")]
27    TooShort(usize),
28    #[error("password is too long (max. {0}")]
29    TooLong(usize),
30    #[error("password does not contain a digit")]
31    NoDigit,
32    #[error("invalid password")]
33    Invalid,
34}
35
36const MIN_LEN: usize = 3;
37const MAX_LEN: usize = 255;
38
39impl Password {
40    pub const MIN_LEN: usize = MIN_LEN;
41    pub const MAX_LEN: usize = MAX_LEN;
42
43    #[must_use]
44    #[allow(clippy::missing_panics_doc)] // Should never panic
45    pub fn to_hashed(&self) -> HashedPassword {
46        debug_assert!(validate(&self.0, Self::MIN_LEN, Self::MAX_LEN).is_ok());
47        // Hashing with bcrypt only fails if the system number generator is
48        // not available. A failure is never caused by the input.
49        let hash = bcrypt::hash(&self.0).expect("bcrypt hash should never fail");
50        HashedPassword::from_hash(hash)
51    }
52}
53
54impl FromStr for Password {
55    type Err = ParseError;
56
57    fn from_str(password: &str) -> Result<Self, Self::Err> {
58        validate(password, Self::MIN_LEN, Self::MAX_LEN)?;
59        Ok(Self(password.to_string()))
60    }
61}
62
63impl HashedPassword {
64    #[must_use]
65    pub const fn from_hash(hashed: String) -> Self {
66        Self(hashed)
67    }
68
69    #[must_use]
70    pub fn verify(&self, password: &Password) -> bool {
71        pwhash::bcrypt::verify(&password.0, &self.0)
72    }
73
74    #[must_use]
75    pub fn as_str(&self) -> &str {
76        &self.0
77    }
78}
79
80impl fmt::Display for HashedPassword {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
82        self.0.fmt(f)
83    }
84}
85
86// TODO: Require digits and maybe more
87const fn validate(password: &str, min_len: usize, max_len: usize) -> Result<(), ParseError> {
88    if password.len() < min_len {
89        return Err(ParseError::TooShort(min_len));
90    }
91    if password.len() > max_len {
92        return Err(ParseError::TooLong(max_len));
93    }
94    Ok(())
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn min_length() {
103        let password = ["0"; MIN_LEN - 1].join("");
104        let result = password.parse::<Password>();
105        let err = result.err().unwrap();
106        assert_eq!(err, ParseError::TooShort(MIN_LEN));
107
108        let password = ["0"; MIN_LEN].join("");
109        let result = password.parse::<Password>();
110        assert!(result.is_ok());
111    }
112
113    #[test]
114    fn max_length() {
115        let password = ["x"; MAX_LEN + 1].join("");
116        let result = password.parse::<Password>();
117        let err = result.err().unwrap();
118        assert_eq!(err, ParseError::TooLong(MAX_LEN));
119
120        let password = ["0"; MAX_LEN].join("");
121        let result = password.parse::<Password>();
122        assert!(result.is_ok());
123    }
124}