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