alibi_plugins/
haveibeenpwned.rs1use 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#[derive(Clone, Debug, Default)]
29pub struct PwnedPasswordClient {
30 client: reqwest::Client,
31 range_api: Option<url::Url>,
32}
33
34impl PwnedPasswordClient {
35 #[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 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 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 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
156pub 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#[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#[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}