1pub(super) mod handlers;
2mod set_password;
3pub(super) mod types;
4
5use super::StatusResponse;
6use alibi_core::utils::password::PasswordHasher;
7use alibi_core::{
8 AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
9 HttpMethod, RequestMeta,
10};
11use async_trait::async_trait;
12use handlers::{
13 change_password_core, request_password_reset_core, reset_password_core,
14 reset_password_token_core, verify_password_core,
15};
16pub use set_password::set_password;
17use std::future::Future;
18use std::pin::Pin;
19use std::sync::Arc;
20use types::{
21 ChangePasswordRequest, RequestPasswordResetRequest, ResetPasswordRequest,
22 ResetPasswordTokenQuery, VerifyPasswordRequest,
23};
24
25const RESET_TOKEN_ROUTE_PREFIX: &str = "/reset-password/";
26
27pub type OnPasswordResetCallback =
29 dyn Fn(serde_json::Value) -> Pin<Box<dyn Future<Output = AuthResult<()>> + Send>> + Send + Sync;
30
31#[async_trait]
37pub trait SendResetPassword: Send + Sync {
38 async fn send(&self, user: &serde_json::Value, url: &str, token: &str) -> AuthResult<()>;
44}
45
46pub struct PasswordManagementPlugin {
48 config: PasswordManagementConfig,
49}
50
51impl std::fmt::Debug for PasswordManagementPlugin {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 f.debug_struct("PasswordManagementPlugin")
54 .finish_non_exhaustive()
55 }
56}
57
58#[derive(Clone, alibi_core::PluginConfig)]
59#[plugin(name = "PasswordManagementPlugin")]
60pub struct PasswordManagementConfig {
61 #[config(default = 1)]
62 pub reset_token_expiry_hours: i64,
63 #[config(default = None)]
66 pub reset_token_expiry: Option<chrono::Duration>,
67 #[config(default = true)]
68 pub require_current_password: bool,
69 #[config(default = true)]
70 pub send_email_notifications: bool,
71 #[config(default = false)]
73 pub revoke_sessions_on_password_reset: bool,
74 #[config(default = None)]
77 pub send_reset_password: Option<Arc<dyn SendResetPassword>>,
78 #[config(default = None)]
82 pub on_password_reset: Option<Arc<OnPasswordResetCallback>>,
83 #[config(default = None)]
86 pub password_hasher: Option<Arc<dyn PasswordHasher>>,
87}
88
89impl std::fmt::Debug for PasswordManagementConfig {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 f.debug_struct("PasswordManagementConfig")
92 .field("reset_token_expiry_hours", &self.reset_token_expiry_hours)
93 .field("reset_token_expiry", &self.reset_token_expiry)
94 .field("require_current_password", &self.require_current_password)
95 .field("send_email_notifications", &self.send_email_notifications)
96 .field(
97 "revoke_sessions_on_password_reset",
98 &self.revoke_sessions_on_password_reset,
99 )
100 .field(
101 "send_reset_password",
102 &self.send_reset_password.as_ref().map(|_| "custom"),
103 )
104 .field(
105 "on_password_reset",
106 &self.on_password_reset.as_ref().map(|_| "custom"),
107 )
108 .field(
109 "password_hasher",
110 &self.password_hasher.as_ref().map(|_| "custom"),
111 )
112 .finish()
113 }
114}
115
116#[async_trait]
117impl<S: alibi_core::AuthSchema> AuthPlugin<S> for PasswordManagementPlugin {
118 route_openapi_metadata!(S);
119
120 fn name(&self) -> &'static str {
121 "password-management"
122 }
123
124 async fn on_init(&self, ctx: &mut alibi_core::AuthInitContext<S>) -> AuthResult<()> {
125 ctx.extensions.insert(self.config.clone());
126 Ok(())
127 }
128
129 fn routes(&self) -> Vec<AuthRoute> {
130 vec![
131 AuthRoute::post("/request-password-reset", "request_password_reset"),
132 AuthRoute::post("/reset-password", "reset_password"),
133 AuthRoute::get("/reset-password/{token}", "reset_password_token"),
134 AuthRoute::post("/change-password", "change_password"),
135 AuthRoute::post("/verify-password", "verify_password"),
136 ]
137 }
138
139 async fn on_request(
140 &self,
141 req: &AuthRequest,
142 ctx: &AuthContext<S>,
143 ) -> AuthResult<Option<AuthResponse>> {
144 let response = match (req.method(), req.path()) {
145 (HttpMethod::Post, "/request-password-reset") => {
146 self.handle_request_password_reset(req, ctx).await?
147 }
148 (HttpMethod::Post, "/reset-password") => self.handle_reset_password(req, ctx).await?,
149 (HttpMethod::Post, "/change-password") => self.handle_change_password(req, ctx).await?,
150 (HttpMethod::Post, "/verify-password") => self.handle_verify_password(req, ctx).await?,
151 (HttpMethod::Get, path) if path.starts_with(RESET_TOKEN_ROUTE_PREFIX) => {
152 let token = path
153 .get(RESET_TOKEN_ROUTE_PREFIX.len()..)
154 .unwrap_or_default();
155 self.handle_reset_password_token(token, req, ctx).await?
156 }
157 _ => return Ok(None),
158 };
159 Ok(Some(response))
160 }
161}
162
163impl PasswordManagementPlugin {
164 async fn handle_request_password_reset(
165 &self,
166 req: &AuthRequest,
167 ctx: &AuthContext<impl alibi_core::AuthSchema>,
168 ) -> AuthResult<AuthResponse> {
169 let body: RequestPasswordResetRequest = match alibi_core::validate_request_body(req) {
170 Ok(v) => v,
171 Err(resp) => return Ok(resp),
172 };
173 let response = request_password_reset_core(&body, &self.config, ctx).await?;
174 Ok(AuthResponse::json(200, &response)?)
175 }
176
177 async fn handle_reset_password(
178 &self,
179 req: &AuthRequest,
180 ctx: &AuthContext<impl alibi_core::AuthSchema>,
181 ) -> AuthResult<AuthResponse> {
182 let mut body: ResetPasswordRequest = match crate::authentication_helpers::parse_body(req) {
183 Ok(v) => v,
184 Err(resp) => return Ok(resp),
185 };
186 if body.token.as_ref().is_none_or(String::is_empty) {
187 body.token = req.query.get("token").cloned();
188 }
189 let response = reset_password_core(&body, &self.config, ctx).await?;
190 Ok(AuthResponse::json(200, &response)?)
191 }
192
193 async fn handle_change_password(
194 &self,
195 req: &AuthRequest,
196 ctx: &AuthContext<impl alibi_core::AuthSchema>,
197 ) -> AuthResult<AuthResponse> {
198 let body: ChangePasswordRequest = match crate::authentication_helpers::parse_body(req) {
199 Ok(v) => v,
200 Err(resp) => return Ok(resp),
201 };
202
203 let (user, _session) = ctx
204 .require_authoritative_session_record(req)
205 .await
206 .map_err(crate::helpers::unauthorized_if_session_missing)?;
207 let meta = RequestMeta::from_request(req);
208
209 let (response, new_token) =
210 change_password_core(&body, &user, &self.config, &meta, ctx).await?;
211
212 let auth_response = AuthResponse::json(200, &response)?;
213 let Some(token) = new_token else {
214 return Ok(auth_response);
215 };
216 let dont_remember =
217 crate::authentication_helpers::dont_remember_preference(req, &ctx.config);
218 crate::authentication_helpers::with_session_cookies(
219 auth_response,
220 &ctx.config,
221 &token,
222 dont_remember,
223 )
224 }
225
226 async fn handle_verify_password(
227 &self,
228 req: &AuthRequest,
229 ctx: &AuthContext<impl alibi_core::AuthSchema>,
230 ) -> AuthResult<AuthResponse> {
231 let body: VerifyPasswordRequest = match alibi_core::validate_request_body(req) {
232 Ok(v) => v,
233 Err(resp) => return Ok(resp),
234 };
235
236 let Some(user) = Self::get_current_user(req, ctx).await? else {
237 return Ok(
240 AuthError::AuthenticationFailed("Unauthorized".to_owned()).to_auth_response()
241 );
242 };
243 let response = verify_password_core(&body, &user, &self.config, ctx).await?;
244 Ok(AuthResponse::json(200, &response)?)
245 }
246
247 async fn handle_reset_password_token(
248 &self,
249 token: &str,
250 req: &AuthRequest,
251 ctx: &AuthContext<impl alibi_core::AuthSchema>,
252 ) -> AuthResult<AuthResponse> {
253 let query = ResetPasswordTokenQuery {
254 callback_url: req.query.get("callbackURL").cloned(),
255 };
256 let url = reset_password_token_core(token, &query, ctx).await?;
257 let mut headers = alibi_core::Headers::new();
258 _ = headers.insert("Location", url);
259 _ = headers.insert("content-type", "application/json");
260 Ok(AuthResponse {
261 status: 302,
262 headers,
263 body: Vec::new(),
264 })
265 }
266
267 async fn get_current_user<S: alibi_core::AuthSchema>(
268 req: &AuthRequest,
269 ctx: &AuthContext<S>,
270 ) -> AuthResult<Option<S::User>> {
271 let session_manager = ctx.session_manager();
272
273 if let Some(token) = session_manager.extract_session_token(req)
274 && let Some(session) = session_manager.get_session(&token).await?
275 {
276 return ctx.session_user(&session).await;
277 }
278
279 Ok(None)
280 }
281}
282
283#[cfg(test)]
285mod tests {
286 use super::types::RequestPasswordResetResponse;
287 use super::*;
288 use crate::test_helpers;
289 use alibi_core::AuthContext;
290 use alibi_core::config::{AuthConfig, PasswordConfig};
291 use alibi_core::utils::password::{hash_password, verify_password};
292 use alibi_core::wire::{SessionView, UserView};
293 use alibi_core::{CreateAccount, CreateUser, CreateVerification};
294 use chrono::{Duration, Utc};
295 use std::collections::HashMap;
296 use std::sync::Arc;
297
298 type TestSchema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
299
300 const PASSWORD_RESET_SUCCESS_MESSAGE: &str =
301 "If this email exists in our system, check your email for the reset link";
302
303 struct NoopResetSender;
304
305 #[async_trait::async_trait]
306 impl SendResetPassword for NoopResetSender {
307 async fn send(
308 &self,
309 _user: &serde_json::Value,
310 _url: &str,
311 _token: &str,
312 ) -> AuthResult<()> {
313 Ok(())
314 }
315 }
316
317 fn plugin_with_reset_sender() -> PasswordManagementPlugin {
318 PasswordManagementPlugin::new().send_reset_password(Arc::new(NoopResetSender))
319 }
320
321 async fn create_test_context_with_user() -> (AuthContext<TestSchema>, UserView, SessionView) {
322 let mut config = AuthConfig::new("test-secret-key-at-least-32-chars-long");
323 config.password = PasswordConfig {
324 require_uppercase: true,
325 require_lowercase: true,
326 require_numbers: true,
327 require_special: true,
328 };
329
330 let ctx = test_helpers::create_test_context_with_config(config).await;
331
332 let password_hash = hash_password(None, "Password123!").await.unwrap();
334
335 let create_user = CreateUser::new()
336 .with_email("test@example.com")
337 .with_name("Test User");
338 let user = test_helpers::create_user(&ctx, create_user).await;
339 _ = ctx
340 .database
341 .create_account(CreateAccount {
342 additional_fields: alibi_core::field_policy::FieldValues::default(),
343 user_id: user.id.clone(),
344 account_id: user.id.clone(),
345 provider_id: "credential".to_owned(),
346 access_token: None,
347 refresh_token: None,
348 id_token: None,
349 access_token_expires_at: None,
350 refresh_token_expires_at: None,
351 scope: None,
352 password: Some(password_hash),
353 })
354 .await
355 .unwrap();
356 let session =
357 test_helpers::create_session(&ctx, user.id.clone(), Duration::hours(24)).await;
358
359 (ctx, user, session)
360 }
361
362 async fn create_test_context_with_oauth_only_user()
363 -> (AuthContext<TestSchema>, UserView, SessionView) {
364 let (ctx, user, session) = create_test_context_with_user().await;
365
366 let existing_accounts = ctx.database.get_user_accounts(&user.id).await.unwrap();
367 for account in existing_accounts {
368 ctx.database.delete_account(&account.id).await.unwrap();
369 }
370
371 _ = ctx
372 .database
373 .create_account(CreateAccount {
374 additional_fields: alibi_core::field_policy::FieldValues::default(),
375 user_id: user.id.clone(),
376 account_id: "google-account-id".to_owned(),
377 provider_id: "google".to_owned(),
378 access_token: Some("oauth-access-token".to_owned()),
379 refresh_token: Some("oauth-refresh-token".to_owned()),
380 id_token: None,
381 access_token_expires_at: None,
382 refresh_token_expires_at: None,
383 scope: Some("email profile".to_owned()),
384 password: None,
385 })
386 .await
387 .unwrap();
388
389 (ctx, user, session)
390 }
391
392 async fn create_reset_token(
395 ctx: &AuthContext<impl alibi_core::AuthSchema>,
396 user_id: &str,
397 ) -> String {
398 let reset_token = uuid::Uuid::new_v4().simple().to_string();
399 let create_verification = CreateVerification {
400 identifier: format!("reset-password:{reset_token}"),
401 value: user_id.to_owned(),
402 expires_at: Utc::now() + Duration::hours(24),
403 };
404 ctx.database
405 .create_verification(create_verification)
406 .await
407 .unwrap();
408 reset_token
409 }
410
411 #[tokio::test]
413 async fn test_request_password_reset_success() {
414 let plugin = plugin_with_reset_sender();
415 let (ctx, _user, _session) = create_test_context_with_user().await;
416
417 let body = serde_json::json!({
418 "email": "test@example.com",
419 "redirectTo": "http://localhost:3000/reset"
420 });
421
422 let req = test_helpers::create_auth_request_no_query(
423 HttpMethod::Post,
424 "/request-password-reset",
425 None,
426 Some(body.to_string().into_bytes()),
427 );
428
429 let response = plugin
430 .handle_request_password_reset(&req, &ctx)
431 .await
432 .unwrap();
433 assert_eq!(response.status, 200);
434
435 let body_str = String::from_utf8(response.body).unwrap();
436 let response_data: RequestPasswordResetResponse = serde_json::from_str(&body_str).unwrap();
437 assert!(response_data.status);
438 assert_eq!(response_data.message, PASSWORD_RESET_SUCCESS_MESSAGE);
439 }
440
441 #[tokio::test]
443 async fn test_request_password_reset_unknown_email() {
444 let plugin = plugin_with_reset_sender();
445 let (ctx, _user, _session) = create_test_context_with_user().await;
446
447 let body = serde_json::json!({
448 "email": "unknown@example.com"
449 });
450
451 let req = test_helpers::create_auth_request_no_query(
452 HttpMethod::Post,
453 "/request-password-reset",
454 None,
455 Some(body.to_string().into_bytes()),
456 );
457
458 let response = plugin
459 .handle_request_password_reset(&req, &ctx)
460 .await
461 .unwrap();
462 assert_eq!(response.status, 200);
463
464 let body_str = String::from_utf8(response.body).unwrap();
465 let response_data: RequestPasswordResetResponse = serde_json::from_str(&body_str).unwrap();
466 assert!(response_data.status);
467 assert_eq!(response_data.message, PASSWORD_RESET_SUCCESS_MESSAGE);
468 }
469
470 #[tokio::test]
472 async fn test_reset_password_success() {
473 let plugin = PasswordManagementPlugin::new();
474 let (ctx, user, _session) = create_test_context_with_user().await;
475
476 let reset_token = create_reset_token(&ctx, &user.id).await;
477
478 let body = serde_json::json!({
479 "newPassword": "NewPassword123!",
480 "token": reset_token
481 });
482
483 let req = test_helpers::create_auth_request_no_query(
484 HttpMethod::Post,
485 "/reset-password",
486 None,
487 Some(body.to_string().into_bytes()),
488 );
489
490 let response = plugin.handle_reset_password(&req, &ctx).await.unwrap();
491 assert_eq!(response.status, 200);
492
493 let body_str = String::from_utf8(response.body).unwrap();
494 let response_data: StatusResponse = serde_json::from_str(&body_str).unwrap();
495 assert!(response_data.status);
496
497 let accounts = ctx.database.get_user_accounts(&user.id).await.unwrap();
499 let stored_hash = accounts
500 .iter()
501 .find(|account| account.provider_id == "credential")
502 .and_then(|account| account.password.as_deref())
503 .unwrap();
504 assert!(
505 verify_password(None, "NewPassword123!", stored_hash)
506 .await
507 .is_ok()
508 );
509
510 let verification_check = ctx
511 .database
512 .get_verification_by_identifier(&format!("reset-password:{reset_token}"))
513 .await
514 .unwrap();
515 assert!(verification_check.is_none());
516 }
517
518 #[tokio::test]
520 async fn test_reset_password_invalid_token() {
521 let plugin = PasswordManagementPlugin::new();
522 let (ctx, _user, _session) = create_test_context_with_user().await;
523
524 let body = serde_json::json!({
525 "newPassword": "NewPassword123!",
526 "token": "invalid_token"
527 });
528
529 let req = test_helpers::create_auth_request_no_query(
530 HttpMethod::Post,
531 "/reset-password",
532 None,
533 Some(body.to_string().into_bytes()),
534 );
535
536 let err = plugin.handle_reset_password(&req, &ctx).await.unwrap_err();
537 assert_eq!(err.status_code(), 400);
538 }
539
540 #[tokio::test]
542 async fn test_reset_password_weak_password() {
543 let plugin = PasswordManagementPlugin::new();
544 let (ctx, user, _session) = create_test_context_with_user().await;
545
546 let reset_token = create_reset_token(&ctx, &user.id).await;
547
548 let body = serde_json::json!({
549 "newPassword": "weak",
550 "token": reset_token
551 });
552
553 let req = test_helpers::create_auth_request_no_query(
554 HttpMethod::Post,
555 "/reset-password",
556 None,
557 Some(body.to_string().into_bytes()),
558 );
559
560 let err = plugin.handle_reset_password(&req, &ctx).await.unwrap_err();
561 assert_eq!(err.status_code(), 400);
562 }
563
564 #[tokio::test]
566 async fn test_change_password_success() {
567 let plugin = PasswordManagementPlugin::new();
568 let (ctx, _user, session) = create_test_context_with_user().await;
569
570 let body = serde_json::json!({
571 "currentPassword": "Password123!",
572 "newPassword": "NewPassword123!",
573 "revokeOtherSessions": false
574 });
575
576 let req = test_helpers::create_auth_request_no_query(
577 HttpMethod::Post,
578 "/change-password",
579 Some(&session.token),
580 Some(body.to_string().into_bytes()),
581 );
582
583 let response = plugin.handle_change_password(&req, &ctx).await.unwrap();
584 assert_eq!(response.status, 200);
585
586 let body_str = String::from_utf8(response.body).unwrap();
587 let response_data: serde_json::Value = serde_json::from_str(&body_str).unwrap();
588 assert!(
589 (*(response_data)
590 .get("token")
591 .unwrap_or(&serde_json::Value::Null))
592 .is_null()
593 ); let user_id = (*(*(response_data)
597 .get("user")
598 .unwrap_or(&serde_json::Value::Null))
599 .get("id")
600 .unwrap_or(&serde_json::Value::Null))
601 .as_str()
602 .unwrap();
603 let accounts = ctx.database.get_user_accounts(user_id).await.unwrap();
604 let stored_hash = accounts
605 .iter()
606 .find(|account| account.provider_id == "credential")
607 .and_then(|account| account.password.as_deref())
608 .unwrap();
609 assert!(
610 verify_password(None, "NewPassword123!", stored_hash)
611 .await
612 .is_ok()
613 );
614 }
615
616 #[tokio::test]
618 async fn test_change_password_with_session_revocation() {
619 let plugin = PasswordManagementPlugin::new();
620 let (ctx, _user, session) = create_test_context_with_user().await;
621
622 let body = serde_json::json!({
623 "currentPassword": "Password123!",
624 "newPassword": "NewPassword123!",
625 "revokeOtherSessions": true
626 });
627
628 let req = test_helpers::create_auth_request_no_query(
629 HttpMethod::Post,
630 "/change-password",
631 Some(&session.token),
632 Some(body.to_string().into_bytes()),
633 );
634
635 let response = plugin.handle_change_password(&req, &ctx).await.unwrap();
636 assert_eq!(response.status, 200);
637
638 let body_str = String::from_utf8(response.body).unwrap();
639 let response_data: serde_json::Value = serde_json::from_str(&body_str).unwrap();
640 assert!(
641 (*(response_data)
642 .get("token")
643 .unwrap_or(&serde_json::Value::Null))
644 .is_string()
645 ); }
647
648 #[tokio::test]
650 async fn test_change_password_sets_cookie_on_session_revocation() {
651 let plugin = PasswordManagementPlugin::new();
652 let (ctx, _user, session) = create_test_context_with_user().await;
653
654 let body = serde_json::json!({
655 "currentPassword": "Password123!",
656 "newPassword": "NewPassword123!",
657 "revokeOtherSessions": true
658 });
659
660 let req = test_helpers::create_auth_request_no_query(
661 HttpMethod::Post,
662 "/change-password",
663 Some(&session.token),
664 Some(body.to_string().into_bytes()),
665 );
666
667 let response = plugin.handle_change_password(&req, &ctx).await.unwrap();
668 assert_eq!(response.status, 200);
669
670 let set_cookie = response.headers.get("Set-Cookie");
672 assert!(
673 set_cookie.is_some(),
674 "Set-Cookie header must be set when revokeOtherSessions is true"
675 );
676
677 let cookie_value = set_cookie.unwrap();
678 assert!(
679 cookie_value.contains(&ctx.config.session.cookie_name),
680 "Cookie must contain the session cookie name"
681 );
682 assert!(
683 cookie_value.contains("Path=/"),
684 "Cookie must contain Path=/"
685 );
686 assert!(
687 cookie_value.contains("Max-Age=604800"),
688 "Cookie must retain the configured session lifetime"
689 );
690 assert!(
691 !cookie_value.contains("Expires="),
692 "Source does not synthesize Expires from Max-Age"
693 );
694 }
695
696 #[tokio::test]
698 async fn test_change_password_no_cookie_without_revocation() {
699 let plugin = PasswordManagementPlugin::new();
700 let (ctx, _user, session) = create_test_context_with_user().await;
701
702 let body = serde_json::json!({
703 "currentPassword": "Password123!",
704 "newPassword": "NewPassword123!"
705 });
706
707 let req = test_helpers::create_auth_request_no_query(
708 HttpMethod::Post,
709 "/change-password",
710 Some(&session.token),
711 Some(body.to_string().into_bytes()),
712 );
713
714 let response = plugin.handle_change_password(&req, &ctx).await.unwrap();
715 assert_eq!(response.status, 200);
716
717 let set_cookie = response.headers.get("Set-Cookie");
719 assert!(
720 set_cookie.is_none(),
721 "Set-Cookie header must not be set when revokeOtherSessions is not true"
722 );
723 }
724
725 #[tokio::test]
727 async fn test_change_password_revoke_with_boolean() {
728 let plugin = PasswordManagementPlugin::new();
729 let (ctx, _user, session) = create_test_context_with_user().await;
730
731 let body = serde_json::json!({
733 "currentPassword": "Password123!",
734 "newPassword": "NewPassword123!",
735 "revokeOtherSessions": true
736 });
737
738 let req = test_helpers::create_auth_request_no_query(
739 HttpMethod::Post,
740 "/change-password",
741 Some(&session.token),
742 Some(body.to_string().into_bytes()),
743 );
744
745 let response = plugin.handle_change_password(&req, &ctx).await.unwrap();
746 assert_eq!(response.status, 200);
747
748 let body_str = String::from_utf8(response.body).unwrap();
749 let response_data: serde_json::Value = serde_json::from_str(&body_str).unwrap();
750 assert!(
751 (*(response_data)
752 .get("token")
753 .unwrap_or(&serde_json::Value::Null))
754 .is_string(),
755 "New token must be returned when revokeOtherSessions is boolean true"
756 );
757 }
758
759 #[tokio::test]
761 async fn test_change_password_wrong_current_password() {
762 let plugin = PasswordManagementPlugin::new();
763 let (ctx, _user, session) = create_test_context_with_user().await;
764
765 let body = serde_json::json!({
766 "currentPassword": "WrongPassword123!",
767 "newPassword": "NewPassword123!"
768 });
769
770 let req = test_helpers::create_auth_request_no_query(
771 HttpMethod::Post,
772 "/change-password",
773 Some(&session.token),
774 Some(body.to_string().into_bytes()),
775 );
776
777 let err = plugin.handle_change_password(&req, &ctx).await.unwrap_err();
778 assert_eq!(err.status_code(), 400);
779 }
780
781 #[tokio::test]
783 async fn test_change_password_unauthorized() {
784 let plugin = PasswordManagementPlugin::new();
785 let (ctx, _user, _session) = create_test_context_with_user().await;
786
787 let body = serde_json::json!({
788 "currentPassword": "Password123!",
789 "newPassword": "NewPassword123!"
790 });
791
792 let req = test_helpers::create_auth_request_no_query(
793 HttpMethod::Post,
794 "/change-password",
795 None,
796 Some(body.to_string().into_bytes()),
797 );
798
799 let err = plugin.handle_change_password(&req, &ctx).await.unwrap_err();
800 assert_eq!(err.status_code(), 401);
801 }
802
803 #[tokio::test]
806 async fn test_verify_password_success() {
807 let plugin = PasswordManagementPlugin::new();
808 let (ctx, _user, session) = create_test_context_with_user().await;
809
810 let body = serde_json::json!({
811 "password": "Password123!"
812 });
813
814 let req = test_helpers::create_auth_request_no_query(
815 HttpMethod::Post,
816 "/verify-password",
817 Some(&session.token),
818 Some(body.to_string().into_bytes()),
819 );
820
821 let response = plugin.handle_verify_password(&req, &ctx).await.unwrap();
822 assert_eq!(response.status, 200);
823
824 let body_str = String::from_utf8(response.body).unwrap();
825 let response_data: StatusResponse = serde_json::from_str(&body_str).unwrap();
826 assert!(response_data.status);
827 }
828
829 #[tokio::test]
832 async fn test_verify_password_invalid_password() {
833 let plugin = PasswordManagementPlugin::new();
834 let (ctx, _user, session) = create_test_context_with_user().await;
835
836 let body = serde_json::json!({
837 "password": "wrong-password"
838 });
839
840 let req = test_helpers::create_auth_request_no_query(
841 HttpMethod::Post,
842 "/verify-password",
843 Some(&session.token),
844 Some(body.to_string().into_bytes()),
845 );
846
847 let err = plugin.handle_verify_password(&req, &ctx).await.unwrap_err();
848 assert_eq!(err.status_code(), 400);
849 assert_eq!(err.to_string(), "Invalid password");
850 }
851
852 #[tokio::test]
855 async fn test_verify_password_oauth_only_user_returns_invalid_password() {
856 let plugin = PasswordManagementPlugin::new();
857 let (ctx, _user, session) = create_test_context_with_oauth_only_user().await;
858
859 let body = serde_json::json!({
860 "password": "Password123!"
861 });
862
863 let req = test_helpers::create_auth_request_no_query(
864 HttpMethod::Post,
865 "/verify-password",
866 Some(&session.token),
867 Some(body.to_string().into_bytes()),
868 );
869
870 let err = plugin.handle_verify_password(&req, &ctx).await.unwrap_err();
871 assert_eq!(err.status_code(), 400);
872 assert_eq!(err.to_string(), "Invalid password");
873 }
874
875 #[tokio::test]
878 async fn test_verify_password_requires_session() {
879 let plugin = PasswordManagementPlugin::new();
880 let (ctx, _user, _session) = create_test_context_with_user().await;
881
882 let body = serde_json::json!({
883 "password": "Password123!"
884 });
885
886 let req = test_helpers::create_auth_request_no_query(
887 HttpMethod::Post,
888 "/verify-password",
889 None,
890 Some(body.to_string().into_bytes()),
891 );
892
893 let response = plugin.handle_verify_password(&req, &ctx).await.unwrap();
894 assert_eq!(response.status, 401);
895 let body_2: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
897 assert_eq!(
898 (*(body_2).get("code").unwrap_or(&serde_json::Value::Null)),
899 "UNAUTHORIZED"
900 );
901 assert_eq!(
902 (*(body_2).get("message").unwrap_or(&serde_json::Value::Null)),
903 "Unauthorized"
904 );
905 }
906
907 #[tokio::test]
909 async fn test_reset_password_token_endpoint_redirects_with_callback_token() {
910 let plugin = PasswordManagementPlugin::new();
911 let (ctx, user, _session) = create_test_context_with_user().await;
912
913 let reset_token = create_reset_token(&ctx, &user.id).await;
914
915 let mut query = HashMap::new();
916 query.insert(
917 "callbackURL".to_owned(),
918 "http://localhost:3000/reset".to_owned(),
919 );
920
921 let req = AuthRequest::from_parts(
922 HttpMethod::Get,
923 "/reset-password/token".to_owned(),
924 HashMap::new(),
925 None,
926 query,
927 );
928
929 let response = plugin
930 .handle_reset_password_token(&reset_token, &req, &ctx)
931 .await
932 .unwrap();
933 assert_eq!(response.status, 302);
934 assert!(
935 (*(response.headers)
936 .get("Location")
937 .expect("fixture contains the requested index"))
938 .contains("http://localhost:3000/reset"),
939 "redirect must preserve the callback URL"
940 );
941 assert!(
942 (*(response.headers)
943 .get("Location")
944 .expect("fixture contains the requested index"))
945 .contains(&format!("token={reset_token}")),
946 "redirect must contain the reset token"
947 );
948 }
949
950 #[tokio::test]
952 async fn test_reset_password_token_endpoint_with_callback() {
953 let plugin = PasswordManagementPlugin::new();
954 let (ctx, user, _session) = create_test_context_with_user().await;
955
956 let reset_token = create_reset_token(&ctx, &user.id).await;
957
958 let mut query = HashMap::new();
959 query.insert(
960 "callbackURL".to_owned(),
961 "http://localhost:3000/reset".to_owned(),
962 );
963
964 let req = AuthRequest::from_parts(
965 HttpMethod::Get,
966 "/reset-password/token".to_owned(),
967 HashMap::new(),
968 None,
969 query,
970 );
971
972 let response = plugin
973 .handle_reset_password_token(&reset_token, &req, &ctx)
974 .await
975 .unwrap();
976 assert_eq!(response.status, 302);
977
978 let location_header = response
980 .headers
981 .iter()
982 .find(|(key, _)| *key == "Location")
983 .map(|(_, value)| value);
984 assert!(location_header.is_some());
985 assert!(
986 location_header
987 .unwrap()
988 .contains("http://localhost:3000/reset")
989 );
990 assert!(location_header.unwrap().contains(&reset_token));
991 }
992
993 #[tokio::test]
995 async fn test_reset_password_token_endpoint_invalid_token() {
996 let plugin = PasswordManagementPlugin::new();
997 let (ctx, _user, _session) = create_test_context_with_user().await;
998
999 let mut query = HashMap::new();
1000 query.insert(
1001 "callbackURL".to_owned(),
1002 "http://localhost:3000/reset".to_owned(),
1003 );
1004 let req = AuthRequest::from_parts(
1005 HttpMethod::Get,
1006 "/reset-password/token".to_owned(),
1007 HashMap::new(),
1008 None,
1009 query,
1010 );
1011
1012 let response = plugin
1013 .handle_reset_password_token("invalid_token", &req, &ctx)
1014 .await
1015 .unwrap();
1016 assert_eq!(response.status, 302);
1017 assert!(
1018 (*(response.headers)
1019 .get("Location")
1020 .expect("fixture contains the requested index"))
1021 .contains("error=INVALID_TOKEN")
1022 );
1023 }
1024
1025 #[tokio::test]
1027 async fn test_plugin_routes() {
1028 let plugin = PasswordManagementPlugin::new();
1029 let routes = AuthPlugin::<
1030 alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema,
1031 >::routes(&plugin);
1032
1033 assert_eq!(routes.len(), 5);
1034 assert!(
1035 routes
1036 .iter()
1037 .any(|r| r.path == "/request-password-reset" && r.method == HttpMethod::Post)
1038 );
1039 assert!(
1040 routes
1041 .iter()
1042 .any(|r| r.path == "/reset-password" && r.method == HttpMethod::Post)
1043 );
1044 assert!(
1045 routes
1046 .iter()
1047 .any(|r| r.path == "/reset-password/{token}" && r.method == HttpMethod::Get)
1048 );
1049 assert!(
1050 routes
1051 .iter()
1052 .any(|r| r.path == "/verify-password" && r.method == HttpMethod::Post)
1053 );
1054 assert!(
1055 routes
1056 .iter()
1057 .any(|r| r.path == "/change-password" && r.method == HttpMethod::Post)
1058 );
1059 }
1060
1061 #[tokio::test]
1063 async fn test_plugin_on_request_routing() {
1064 let plugin = plugin_with_reset_sender();
1065 let (ctx, _user, session) = create_test_context_with_user().await;
1066
1067 let body = serde_json::json!({"email": "test@example.com"});
1068 let req = test_helpers::create_auth_request_no_query(
1069 HttpMethod::Post,
1070 "/request-password-reset",
1071 None,
1072 Some(body.to_string().into_bytes()),
1073 );
1074 let response = plugin.on_request(&req, &ctx).await.unwrap();
1075 assert!(response.is_some());
1076 assert_eq!(response.unwrap().status, 200);
1077
1078 let body_2 = serde_json::json!({
1080 "currentPassword": "Password123!",
1081 "newPassword": "NewPassword123!"
1082 });
1083 let req_2 = test_helpers::create_auth_request_no_query(
1084 HttpMethod::Post,
1085 "/change-password",
1086 Some(&session.token),
1087 Some(body_2.to_string().into_bytes()),
1088 );
1089 let response_2 = plugin.on_request(&req_2, &ctx).await.unwrap();
1090 assert!(response_2.is_some());
1091 assert_eq!(response_2.unwrap().status, 200);
1092
1093 let req_3 = test_helpers::create_auth_request_no_query(
1095 HttpMethod::Get,
1096 "/invalid-route",
1097 None,
1098 None,
1099 );
1100 let response_3 = plugin.on_request(&req_3, &ctx).await.unwrap();
1101 assert!(response_3.is_none());
1102 }
1103
1104 #[tokio::test]
1106 async fn test_configuration() {
1107 let config = PasswordManagementConfig {
1108 reset_token_expiry_hours: 48,
1109 require_current_password: false,
1110 send_email_notifications: false,
1111 ..Default::default()
1112 };
1113
1114 let plugin = PasswordManagementPlugin::with_config(config);
1115 assert_eq!(plugin.config.reset_token_expiry_hours, 48);
1116 assert!(!plugin.config.require_current_password);
1117 assert!(!plugin.config.send_email_notifications);
1118 }
1119
1120 #[tokio::test]
1122 async fn test_send_reset_password_custom_sender() {
1123 use alibi_core::{BackgroundTaskCompletion, BackgroundTaskHandler};
1124 use std::sync::{
1125 Mutex,
1126 atomic::{AtomicU32, Ordering},
1127 };
1128
1129 struct Sender {
1130 calls: Arc<AtomicU32>,
1131 fail: bool,
1132 token: Arc<Mutex<Option<String>>>,
1133 }
1134 #[async_trait::async_trait]
1135 impl SendResetPassword for Sender {
1136 async fn send(&self, _: &serde_json::Value, _: &str, token: &str) -> AuthResult<()> {
1137 self.calls.fetch_add(1, Ordering::SeqCst);
1138 *self.token.lock().unwrap() = Some(token.to_owned());
1139 if self.fail {
1140 Err(AuthError::internal("Email queue unavailable"))
1141 } else {
1142 Ok(())
1143 }
1144 }
1145 }
1146 #[derive(Default)]
1147 struct Observer(Mutex<Option<BackgroundTaskCompletion>>);
1148 impl BackgroundTaskHandler for Observer {
1149 fn handle(&self, completion: BackgroundTaskCompletion) -> AuthResult<()> {
1150 *self.0.lock().unwrap() = Some(completion);
1151 Ok(())
1152 }
1153 }
1154 for (fail, background) in [(false, false), (true, false), (true, true)] {
1155 let calls = Arc::new(AtomicU32::new(0));
1156 let token = Arc::new(Mutex::new(None));
1157 let plugin = PasswordManagementPlugin::new().send_reset_password(Arc::new(Sender {
1158 calls: Arc::clone(&calls),
1159 fail,
1160 token: Arc::clone(&token),
1161 }));
1162 let (mut ctx, user, _) = create_test_context_with_user().await;
1163 let observer = Arc::new(Observer::default());
1164 if background {
1165 Arc::make_mut(&mut ctx.config).background_tasks = Some(observer.clone());
1166 }
1167 let req = test_helpers::create_auth_json_request_no_query(
1168 HttpMethod::Post,
1169 "/request-password-reset",
1170 None,
1171 Some(serde_json::json!({"email":"test@example.com"})),
1172 );
1173 let result = plugin.handle_request_password_reset(&req, &ctx).await;
1174 if fail && !background {
1175 assert_eq!(result.unwrap_err().status_code(), 500);
1176 } else {
1177 assert_eq!(result.unwrap().status, 200);
1178 }
1179 if background {
1180 let completion = observer.0.lock().unwrap().take().unwrap();
1181 completion.await.unwrap();
1182 }
1183 assert_eq!(calls.load(Ordering::SeqCst), 1);
1184 let token = token.lock().unwrap().take().unwrap();
1185 let proof = ctx
1186 .database
1187 .get_verification_by_identifier(&format!("reset-password:{token}"))
1188 .await
1189 .unwrap()
1190 .unwrap();
1191 assert_eq!(proof.value, user.id);
1192 }
1193 }
1194
1195 #[tokio::test]
1197 async fn test_on_password_reset_callback() {
1198 use std::sync::atomic::{AtomicBool, Ordering};
1199
1200 let callback_called = Arc::new(AtomicBool::new(false));
1201 let called_clone = std::sync::Arc::clone(&callback_called);
1202
1203 let callback: Arc<OnPasswordResetCallback> = Arc::new(move |_user_value| {
1204 let called = std::sync::Arc::clone(&called_clone);
1205 Box::pin(async move {
1206 called.store(true, Ordering::SeqCst);
1207 Ok(())
1208 })
1209 });
1210
1211 let plugin = PasswordManagementPlugin::new().on_password_reset(callback);
1212 let (ctx, user, _session) = create_test_context_with_user().await;
1213
1214 let reset_token = create_reset_token(&ctx, &user.id).await;
1215
1216 let body = serde_json::json!({
1217 "newPassword": "NewPassword123!",
1218 "token": reset_token
1219 });
1220 let req = test_helpers::create_auth_request_no_query(
1221 HttpMethod::Post,
1222 "/reset-password",
1223 None,
1224 Some(body.to_string().into_bytes()),
1225 );
1226
1227 let response = plugin.handle_reset_password(&req, &ctx).await.unwrap();
1228 assert_eq!(response.status, 200);
1229
1230 assert!(
1232 callback_called.load(Ordering::SeqCst),
1233 "on_password_reset callback should be invoked after password reset"
1234 );
1235 }
1236
1237 #[tokio::test]
1239 async fn test_revoke_sessions_on_password_reset_false() {
1240 let plugin = PasswordManagementPlugin::new().revoke_sessions_on_password_reset(false);
1241 let (ctx, user, session) = create_test_context_with_user().await;
1242
1243 let reset_token = create_reset_token(&ctx, &user.id).await;
1244
1245 let body = serde_json::json!({
1246 "newPassword": "NewPassword123!",
1247 "token": reset_token
1248 });
1249 let req = test_helpers::create_auth_request_no_query(
1250 HttpMethod::Post,
1251 "/reset-password",
1252 None,
1253 Some(body.to_string().into_bytes()),
1254 );
1255
1256 let response = plugin.handle_reset_password(&req, &ctx).await.unwrap();
1257 assert_eq!(response.status, 200);
1258
1259 let sessions = ctx.database.get_user_sessions(&user.id).await.unwrap();
1261 assert!(
1262 !sessions.is_empty(),
1263 "Sessions should remain when revoke_sessions_on_password_reset=false"
1264 );
1265 assert!(
1266 sessions.iter().any(|s| s.token == session.token),
1267 "The original session should still exist"
1268 );
1269 }
1270
1271 #[tokio::test]
1273 async fn test_revoke_sessions_on_password_reset_true() {
1274 let plugin = PasswordManagementPlugin::new().revoke_sessions_on_password_reset(true);
1275 let (ctx, user, _session) = create_test_context_with_user().await;
1276
1277 let reset_token = create_reset_token(&ctx, &user.id).await;
1278
1279 let body = serde_json::json!({
1280 "newPassword": "NewPassword123!",
1281 "token": reset_token
1282 });
1283 let req = test_helpers::create_auth_request_no_query(
1284 HttpMethod::Post,
1285 "/reset-password",
1286 None,
1287 Some(body.to_string().into_bytes()),
1288 );
1289
1290 let response = plugin.handle_reset_password(&req, &ctx).await.unwrap();
1291 assert_eq!(response.status, 200);
1292
1293 let sessions = ctx.database.get_user_sessions(&user.id).await.unwrap();
1295 assert!(
1296 sessions.is_empty(),
1297 "Sessions should be revoked when revoke_sessions_on_password_reset=true"
1298 );
1299 }
1300}
1301