Skip to main content

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