Skip to main content

alibi_plugins/
haveibeenpwned.rs

1//! Compromised-password policy at initialized password hashing.
2use alibi_core::{
3    AuthContext, AuthError, AuthInitContext, AuthPlugin, AuthRequest, AuthResponse, AuthResult,
4    AuthRoute, AuthSchema, PasswordHashContext, PasswordHashHook,
5};
6use async_trait::async_trait;
7use sha1::{Digest, Sha1};
8use std::fmt::Write as _;
9use std::sync::Arc;
10
11const DEFAULT_MESSAGE: &str =
12    "The password you entered has been compromised. Please choose a different password.";
13const RETRY_MESSAGE: &str = "Failed to check password. Please try again later.";
14const RANGE_API: &str = "https://api.pwnedpasswords.com/range/";
15const DEFAULT_PATHS: &[&str] = &[
16    "/sign-up/email",
17    "/change-password",
18    "/reset-password",
19    "/email-otp/reset-password",
20    "/phone-number/reset-password",
21    "/admin/create-user",
22    "/admin/set-user-password",
23];
24
25/// HTTP range service. Its endpoint is trusted application configuration;
26/// requests contain only five uppercase SHA-1 characters, never the password.
27#[derive(Clone, Debug, Default)]
28pub struct PwnedPasswordClient {
29    client: reqwest::Client,
30    range_api: Option<url::Url>,
31}
32
33impl PwnedPasswordClient {
34    /// Use an application-owned range service and HTTP client.
35    #[must_use]
36    pub fn new(client: reqwest::Client, mut range_api: url::Url) -> Self {
37        if !range_api.path().ends_with('/') {
38            range_api.set_path(&format!("{}/", range_api.path()));
39        }
40        Self {
41            client,
42            range_api: Some(range_api),
43        }
44    }
45
46    /// Check the original UTF-8 password before password-hash normalization.
47    ///
48    /// # Errors
49    /// Returns the published provider status or generic retry error when the
50    /// response cannot be checked, including a malformed matching count.
51    pub async fn is_password_compromised(&self, password: &str) -> AuthResult<bool> {
52        let digest =
53            Sha1::digest(password.as_bytes())
54                .iter()
55                .fold(String::new(), |mut digest, byte| {
56                    _ = write!(digest, "{byte:02X}");
57                    digest
58                });
59        let (prefix, suffix) = digest.split_at(5);
60        let endpoint = self
61            .range_api
62            .as_ref()
63            .map_or_else(
64                || url::Url::parse(&format!("{RANGE_API}{prefix}")),
65                |base| base.join(prefix),
66            )
67            .map_err(|_| retry_error())?;
68        let response = self
69            .client
70            .get(endpoint)
71            .header("Add-Padding", "true")
72            .header("User-Agent", "BetterAuth Password Checker")
73            .send()
74            .await
75            .map_err(|_| retry_error())?;
76        if !response.status().is_success() {
77            return Err(AuthError::Api {
78                status: 500,
79                code: None,
80                message: format!(
81                    "Failed to check password. Status: {}",
82                    response.status().as_u16()
83                ),
84            });
85        }
86        let content_type = response
87            .headers()
88            .get(reqwest::header::CONTENT_TYPE)
89            .and_then(|value| value.to_str().ok())
90            .unwrap_or_default()
91            .split(';')
92            .next()
93            .unwrap_or_default();
94        // betterFetch reads JSON and text media through its JSON-or-text
95        // parser. Other media becomes a blob and cannot supply suffix lines.
96        if !text_response_type(content_type) {
97            return Err(retry_error());
98        }
99        let text = response.text().await.map_err(|_| retry_error())?;
100        let text = response_text(text)?;
101        compromise_count(&text, suffix).map(|count| count > 0)
102    }
103}
104
105fn response_text(text: String) -> AuthResult<String> {
106    match alibi_core::utils::json::parse_value(&text) {
107        Ok(alibi_core::utils::json::JsValue::String(value)) => Ok(value),
108        Ok(_) => Err(retry_error()),
109        Err(_) if serde_json::from_str::<serde::de::IgnoredAny>(&text).is_ok() => {
110            // JavaScript permits escaped unpaired UTF-16 surrogates in JSON.
111            // They cannot match the ASCII suffix/count grammar, but preceding
112            // valid lines still own the result. Preserve those lines rather
113            // than treating the quoted JSON envelope as a raw range response.
114            let quoted = text.trim_matches([' ', '\t', '\r', '\n']);
115            if !quoted.starts_with('"') {
116                return Err(retry_error());
117            }
118            let mut units = Vec::new();
119            let mut chars = quoted[1..quoted.len() - 1].chars();
120            while let Some(character) = chars.next() {
121                let character = if character == '\\' {
122                    match chars.next().ok_or_else(retry_error)? {
123                        'u' => {
124                            let mut unit = 0u16;
125                            for _ in 0..4 {
126                                let digit = chars
127                                    .next()
128                                    .and_then(|digit| digit.to_digit(16))
129                                    .ok_or_else(retry_error)?;
130                                unit =
131                                    unit * 16 + u16::try_from(digit).map_err(|_| retry_error())?;
132                            }
133                            units.push(unit);
134                            continue;
135                        }
136                        'b' => '\u{8}',
137                        'f' => '\u{c}',
138                        'n' => '\n',
139                        'r' => '\r',
140                        't' => '\t',
141                        character @ ('"' | '\\' | '/') => character,
142                        _ => return Err(retry_error()),
143                    }
144                } else {
145                    character
146                };
147                units.extend(character.encode_utf16(&mut [0; 2]).iter().copied());
148            }
149            Ok(String::from_utf16_lossy(&units))
150        }
151        Err(_) => Ok(text),
152    }
153}
154
155/// Check against the published range service without installing a plugin.
156///
157/// # Errors
158/// Returns a provider/parsing error when the password cannot be checked.
159pub async fn is_password_compromised(password: &str) -> AuthResult<bool> {
160    PwnedPasswordClient::default()
161        .is_password_compromised(password)
162        .await
163}
164
165fn text_response_type(content_type: &str) -> bool {
166    let json = content_type
167        .split_once('/')
168        .filter(|(media, _)| media.eq_ignore_ascii_case("application"))
169        .is_some_and(|(_, subtype)| {
170            subtype.eq_ignore_ascii_case("json")
171                || subtype
172                    .to_ascii_lowercase()
173                    .strip_suffix("+json")
174                    .is_some_and(|prefix| {
175                        prefix.bytes().all(|byte| {
176                            byte.is_ascii_alphanumeric() || b"_!#$%&*.^`~-".contains(&byte)
177                        })
178                    })
179        });
180    content_type.is_empty()
181        || json
182        || content_type.starts_with("text/")
183        || matches!(
184            content_type,
185            "image/svg" | "application/xml" | "application/xhtml" | "application/html"
186        )
187}
188
189fn compromise_count(response: &str, suffix: &str) -> AuthResult<u64> {
190    let matching = format!("{suffix}:");
191    for line in response.split_inclusive('\n') {
192        let line = line
193            .strip_suffix("\r\n")
194            .or_else(|| line.strip_suffix('\n'))
195            .unwrap_or(line);
196        let Some(prefix) = line.get(..matching.len()) else {
197            continue;
198        };
199        if !prefix.eq_ignore_ascii_case(&matching) {
200            continue;
201        }
202        let count = &line[matching.len()..];
203        if count.is_empty()
204            || !count.bytes().all(|byte| byte.is_ascii_digit())
205            || count.len() > 1 && count.starts_with('0')
206        {
207            return Err(retry_error());
208        }
209        let count: u64 = count.parse().map_err(|_| retry_error())?;
210        if count > 9_007_199_254_740_991 {
211            return Err(retry_error());
212        }
213        return Ok(count);
214    }
215    Ok(0)
216}
217
218fn retry_error() -> AuthError {
219    AuthError::Api {
220        status: 500,
221        code: None,
222        message: RETRY_MESSAGE.into(),
223    }
224}
225
226/// Published enabled, exact-path and compromised-message settings.
227#[derive(Clone, Debug)]
228pub struct HaveIBeenPwnedConfig {
229    pub enabled: bool,
230    pub paths: Option<Vec<String>>,
231    pub custom_password_compromised_message: Option<String>,
232    pub client: PwnedPasswordClient,
233}
234
235impl Default for HaveIBeenPwnedConfig {
236    fn default() -> Self {
237        Self {
238            enabled: true,
239            paths: None,
240            custom_password_compromised_message: None,
241            client: PwnedPasswordClient::default(),
242        }
243    }
244}
245
246/// Installs a password hash policy; it contributes no HTTP routes.
247#[derive(Clone, Debug, Default)]
248pub struct HaveIBeenPwnedPlugin {
249    config: HaveIBeenPwnedConfig,
250}
251
252impl HaveIBeenPwnedPlugin {
253    #[must_use]
254    pub fn new() -> Self {
255        Self::default()
256    }
257
258    #[must_use]
259    pub const fn with_config(config: HaveIBeenPwnedConfig) -> Self {
260        Self { config }
261    }
262}
263
264#[async_trait]
265impl PasswordHashHook for HaveIBeenPwnedPlugin {
266    async fn before_hash(
267        &self,
268        password: &str,
269        context: Option<&PasswordHashContext>,
270    ) -> AuthResult<()> {
271        if !self.config.enabled {
272            return Ok(());
273        }
274        let context = context.ok_or_else(|| {
275            AuthError::internal("Password hashing requires an auth endpoint context")
276        })?;
277        let Some(path) = context.path.as_deref().filter(|path| !path.is_empty()) else {
278            return Ok(());
279        };
280        let included = self.config.paths.as_ref().map_or_else(
281            || DEFAULT_PATHS.contains(&path),
282            |paths| paths.iter().any(|included| included == path),
283        );
284        if included && self.config.client.is_password_compromised(password).await? {
285            return Err(AuthError::Api {
286                status: 400,
287                code: Some("PASSWORD_COMPROMISED".into()),
288                message: self
289                    .config
290                    .custom_password_compromised_message
291                    .as_deref()
292                    .filter(|message| !message.is_empty())
293                    .unwrap_or(DEFAULT_MESSAGE)
294                    .into(),
295            });
296        }
297        Ok(())
298    }
299}
300
301#[async_trait]
302impl<S: AuthSchema> AuthPlugin<S> for HaveIBeenPwnedPlugin {
303    route_openapi_metadata!(S);
304
305    fn name(&self) -> &'static str {
306        "have-i-been-pwned"
307    }
308
309    fn routes(&self) -> Vec<AuthRoute> {
310        Vec::new()
311    }
312
313    async fn on_init(&self, ctx: &mut AuthInitContext<S>) -> AuthResult<()> {
314        ctx.register_password_hash_hook(Arc::new(self.clone()));
315        Ok(())
316    }
317
318    async fn on_request(
319        &self,
320        _request: &AuthRequest,
321        _context: &AuthContext<S>,
322    ) -> AuthResult<Option<AuthResponse>> {
323        Ok(None)
324    }
325}