1use 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#[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 #[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 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 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 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
160pub 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#[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#[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}