Skip to main content

alibi_plugins/
haveibeenpwned.rs

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