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