1use async_trait::async_trait;
2use chrono::{Duration, Utc};
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5use validator::Validate;
6
7use better_auth_core::{AuthContext, AuthPlugin, AuthRoute};
8use better_auth_core::{AuthError, AuthResult};
9use better_auth_core::{
10 AuthRequest, AuthResponse, CreateVerification, HttpMethod, UpdateUser, User,
11};
12
13pub struct PasswordManagementPlugin {
15 config: PasswordManagementConfig,
16}
17
18#[derive(Debug, Clone)]
19pub struct PasswordManagementConfig {
20 pub reset_token_expiry_hours: i64,
21 pub require_current_password: bool,
22 pub send_email_notifications: bool,
23}
24
25#[derive(Debug, Deserialize, Validate)]
27struct ForgetPasswordRequest {
28 #[validate(email(message = "Invalid email address"))]
29 email: String,
30 #[serde(rename = "redirectTo")]
31 redirect_to: Option<String>,
32}
33
34#[derive(Debug, Deserialize, Validate)]
35struct ResetPasswordRequest {
36 #[serde(rename = "newPassword")]
37 #[validate(length(min = 1, message = "New password is required"))]
38 new_password: String,
39 token: Option<String>,
40}
41
42#[derive(Debug, Deserialize, Validate)]
43struct SetPasswordRequest {
44 #[serde(rename = "newPassword")]
45 #[validate(length(min = 1, message = "New password is required"))]
46 new_password: String,
47}
48
49#[derive(Debug, Deserialize, Validate)]
50struct ChangePasswordRequest {
51 #[serde(rename = "newPassword")]
52 #[validate(length(min = 1, message = "New password is required"))]
53 new_password: String,
54 #[serde(rename = "currentPassword")]
55 #[validate(length(min = 1, message = "Current password is required"))]
56 current_password: String,
57 #[serde(rename = "revokeOtherSessions")]
58 revoke_other_sessions: Option<String>,
59}
60
61#[derive(Debug, Serialize, Deserialize)]
63struct StatusResponse {
64 status: bool,
65}
66
67#[derive(Debug, Serialize, Deserialize)]
68struct ChangePasswordResponse {
69 token: Option<String>,
70 user: User,
71}
72
73#[derive(Debug, Serialize, Deserialize)]
74struct ResetPasswordTokenResponse {
75 token: String,
76}
77
78impl PasswordManagementPlugin {
79 pub fn new() -> Self {
80 Self {
81 config: PasswordManagementConfig::default(),
82 }
83 }
84
85 pub fn with_config(config: PasswordManagementConfig) -> Self {
86 Self { config }
87 }
88
89 pub fn reset_token_expiry_hours(mut self, hours: i64) -> Self {
90 self.config.reset_token_expiry_hours = hours;
91 self
92 }
93
94 pub fn require_current_password(mut self, require: bool) -> Self {
95 self.config.require_current_password = require;
96 self
97 }
98
99 pub fn send_email_notifications(mut self, send: bool) -> Self {
100 self.config.send_email_notifications = send;
101 self
102 }
103}
104
105impl Default for PasswordManagementPlugin {
106 fn default() -> Self {
107 Self::new()
108 }
109}
110
111impl Default for PasswordManagementConfig {
112 fn default() -> Self {
113 Self {
114 reset_token_expiry_hours: 24, require_current_password: true,
116 send_email_notifications: true,
117 }
118 }
119}
120
121#[async_trait]
122impl AuthPlugin for PasswordManagementPlugin {
123 fn name(&self) -> &'static str {
124 "password-management"
125 }
126
127 fn routes(&self) -> Vec<AuthRoute> {
128 vec![
129 AuthRoute::post("/forget-password", "forget_password"),
130 AuthRoute::post("/reset-password", "reset_password"),
131 AuthRoute::get("/reset-password/{token}", "reset_password_token"),
132 AuthRoute::post("/change-password", "change_password"),
133 AuthRoute::post("/set-password", "set_password"),
134 ]
135 }
136
137 async fn on_request(
138 &self,
139 req: &AuthRequest,
140 ctx: &AuthContext,
141 ) -> AuthResult<Option<AuthResponse>> {
142 match (req.method(), req.path()) {
143 (HttpMethod::Post, "/forget-password") => {
144 Ok(Some(self.handle_forget_password(req, ctx).await?))
145 }
146 (HttpMethod::Post, "/reset-password") => {
147 Ok(Some(self.handle_reset_password(req, ctx).await?))
148 }
149 (HttpMethod::Post, "/change-password") => {
150 Ok(Some(self.handle_change_password(req, ctx).await?))
151 }
152 (HttpMethod::Post, "/set-password") => {
153 Ok(Some(self.handle_set_password(req, ctx).await?))
154 }
155 (HttpMethod::Get, path) if path.starts_with("/reset-password/") => {
156 let token = &path[16..]; Ok(Some(
158 self.handle_reset_password_token(token, req, ctx).await?,
159 ))
160 }
161 _ => Ok(None),
162 }
163 }
164}
165
166impl PasswordManagementPlugin {
168 async fn handle_forget_password(
169 &self,
170 req: &AuthRequest,
171 ctx: &AuthContext,
172 ) -> AuthResult<AuthResponse> {
173 let forget_req: ForgetPasswordRequest = match better_auth_core::validate_request_body(req) {
174 Ok(v) => v,
175 Err(resp) => return Ok(resp),
176 };
177
178 let user = match ctx.database.get_user_by_email(&forget_req.email).await? {
180 Some(user) => user,
181 None => {
182 let response = StatusResponse { status: true };
184 return Ok(AuthResponse::json(200, &response)?);
185 }
186 };
187
188 let reset_token = format!("reset_{}", Uuid::new_v4());
190 let expires_at = Utc::now() + Duration::hours(self.config.reset_token_expiry_hours);
191
192 let create_verification = CreateVerification {
194 identifier: user.email.clone().unwrap_or_default(),
195 value: reset_token.clone(),
196 expires_at,
197 };
198
199 ctx.database
200 .create_verification(create_verification)
201 .await?;
202
203 if self.config.send_email_notifications {
205 let reset_url = if let Some(redirect_to) = &forget_req.redirect_to {
206 format!("{}?token={}", redirect_to, reset_token)
207 } else {
208 format!(
209 "{}/reset-password?token={}",
210 ctx.config.base_url, reset_token
211 )
212 };
213
214 if let Ok(provider) = ctx.email_provider() {
215 let subject = "Reset your password";
216 let html = format!(
217 "<p>Click the link below to reset your password:</p>\
218 <p><a href=\"{url}\">Reset Password</a></p>",
219 url = reset_url
220 );
221 let text = format!("Reset your password: {}", reset_url);
222
223 if let Err(e) = provider
224 .send(&forget_req.email, subject, &html, &text)
225 .await
226 {
227 eprintln!(
228 "[password-management] Failed to send reset email to {}: {}",
229 forget_req.email, e
230 );
231 }
232 } else {
233 eprintln!(
234 "[password-management] No email provider configured, skipping password reset email for {}",
235 forget_req.email
236 );
237 }
238 }
239
240 let response = StatusResponse { status: true };
241 Ok(AuthResponse::json(200, &response)?)
242 }
243
244 async fn handle_reset_password(
245 &self,
246 req: &AuthRequest,
247 ctx: &AuthContext,
248 ) -> AuthResult<AuthResponse> {
249 let reset_req: ResetPasswordRequest = match better_auth_core::validate_request_body(req) {
250 Ok(v) => v,
251 Err(resp) => return Ok(resp),
252 };
253
254 self.validate_password(&reset_req.new_password, ctx)?;
256
257 let token = reset_req.token.as_deref().unwrap_or("");
259 if token.is_empty() {
260 return Err(AuthError::bad_request("Reset token is required"));
261 }
262
263 let (user, verification) = self
264 .find_user_by_reset_token(token, ctx)
265 .await?
266 .ok_or_else(|| AuthError::bad_request("Invalid or expired reset token"))?;
267
268 let password_hash = self.hash_password(&reset_req.new_password)?;
270
271 let mut metadata = user.metadata.clone();
273 metadata.insert(
274 "password_hash".to_string(),
275 serde_json::Value::String(password_hash),
276 );
277
278 let update_user = UpdateUser {
279 email: None,
280 name: None,
281 image: None,
282 email_verified: None,
283 username: None,
284 display_username: None,
285 role: None,
286 banned: None,
287 ban_reason: None,
288 ban_expires: None,
289 two_factor_enabled: None,
290 metadata: Some(metadata),
291 };
292
293 ctx.database.update_user(&user.id, update_user).await?;
294
295 ctx.database.delete_verification(&verification.id).await?;
297
298 ctx.database.delete_user_sessions(&user.id).await?;
300
301 let response = StatusResponse { status: true };
302 Ok(AuthResponse::json(200, &response)?)
303 }
304
305 async fn handle_change_password(
306 &self,
307 req: &AuthRequest,
308 ctx: &AuthContext,
309 ) -> AuthResult<AuthResponse> {
310 let change_req: ChangePasswordRequest = match better_auth_core::validate_request_body(req) {
311 Ok(v) => v,
312 Err(resp) => return Ok(resp),
313 };
314
315 let user = self
317 .get_current_user(req, ctx)
318 .await?
319 .ok_or(AuthError::Unauthenticated)?;
320
321 if self.config.require_current_password {
323 let stored_hash = user
324 .metadata
325 .get("password_hash")
326 .and_then(|v| v.as_str())
327 .ok_or_else(|| AuthError::bad_request("No password set for this user"))?;
328
329 self.verify_password(&change_req.current_password, stored_hash)
330 .map_err(|_| AuthError::InvalidCredentials)?;
331 }
332
333 self.validate_password(&change_req.new_password, ctx)?;
335
336 let password_hash = self.hash_password(&change_req.new_password)?;
338
339 let mut metadata = user.metadata.clone();
341 metadata.insert(
342 "password_hash".to_string(),
343 serde_json::Value::String(password_hash),
344 );
345
346 let update_user = UpdateUser {
347 email: None,
348 name: None,
349 image: None,
350 email_verified: None,
351 username: None,
352 display_username: None,
353 role: None,
354 banned: None,
355 ban_reason: None,
356 ban_expires: None,
357 two_factor_enabled: None,
358 metadata: Some(metadata),
359 };
360
361 let updated_user = ctx.database.update_user(&user.id, update_user).await?;
362
363 let new_token = if change_req.revoke_other_sessions.as_deref() == Some("true") {
365 ctx.database.delete_user_sessions(&user.id).await?;
367
368 let session_manager =
370 better_auth_core::SessionManager::new(ctx.config.clone(), ctx.database.clone());
371 let session = session_manager
372 .create_session(&updated_user, None, None)
373 .await?;
374 Some(session.token)
375 } else {
376 None
377 };
378
379 let response = ChangePasswordResponse {
380 token: new_token,
381 user: updated_user,
382 };
383
384 Ok(AuthResponse::json(200, &response)?)
385 }
386
387 async fn handle_set_password(
388 &self,
389 req: &AuthRequest,
390 ctx: &AuthContext,
391 ) -> AuthResult<AuthResponse> {
392 let set_req: SetPasswordRequest = match better_auth_core::validate_request_body(req) {
393 Ok(v) => v,
394 Err(resp) => return Ok(resp),
395 };
396
397 let user = self
399 .get_current_user(req, ctx)
400 .await?
401 .ok_or(AuthError::Unauthenticated)?;
402
403 if user
405 .metadata
406 .get("password_hash")
407 .and_then(|v| v.as_str())
408 .is_some()
409 {
410 return Err(AuthError::bad_request(
411 "User already has a password. Use /change-password instead.",
412 ));
413 }
414
415 self.validate_password(&set_req.new_password, ctx)?;
417
418 let password_hash = self.hash_password(&set_req.new_password)?;
420
421 let mut metadata = user.metadata.clone();
422 metadata.insert(
423 "password_hash".to_string(),
424 serde_json::Value::String(password_hash),
425 );
426
427 let update_user = UpdateUser {
428 email: None,
429 name: None,
430 image: None,
431 email_verified: None,
432 username: None,
433 display_username: None,
434 role: None,
435 banned: None,
436 ban_reason: None,
437 ban_expires: None,
438 two_factor_enabled: None,
439 metadata: Some(metadata),
440 };
441
442 ctx.database.update_user(&user.id, update_user).await?;
443
444 let response = StatusResponse { status: true };
445 Ok(AuthResponse::json(200, &response)?)
446 }
447
448 async fn handle_reset_password_token(
449 &self,
450 token: &str,
451 _req: &AuthRequest,
452 ctx: &AuthContext,
453 ) -> AuthResult<AuthResponse> {
454 let callback_url = _req.query.get("callbackURL").cloned();
456
457 let (_user, _verification) = match self.find_user_by_reset_token(token, ctx).await? {
459 Some((user, verification)) => (user, verification),
460 None => {
461 if let Some(callback_url) = callback_url {
463 let redirect_url = format!("{}?error=INVALID_TOKEN", callback_url);
464 let mut headers = std::collections::HashMap::new();
465 headers.insert("Location".to_string(), redirect_url);
466 return Ok(AuthResponse {
467 status: 302,
468 headers,
469 body: Vec::new(),
470 });
471 }
472
473 return Err(AuthError::bad_request("Invalid or expired reset token"));
474 }
475 };
476
477 if let Some(callback_url) = callback_url {
479 let redirect_url = format!("{}?token={}", callback_url, token);
480 let mut headers = std::collections::HashMap::new();
481 headers.insert("Location".to_string(), redirect_url);
482 return Ok(AuthResponse {
483 status: 302,
484 headers,
485 body: Vec::new(),
486 });
487 }
488
489 let response = ResetPasswordTokenResponse {
491 token: token.to_string(),
492 };
493 Ok(AuthResponse::json(200, &response)?)
494 }
495
496 async fn find_user_by_reset_token(
497 &self,
498 token: &str,
499 ctx: &AuthContext,
500 ) -> AuthResult<Option<(User, better_auth_core::Verification)>> {
501 let verification = match ctx.database.get_verification_by_value(token).await? {
503 Some(verification) => verification,
504 None => return Ok(None),
505 };
506
507 let user = match ctx
509 .database
510 .get_user_by_email(&verification.identifier)
511 .await?
512 {
513 Some(user) => user,
514 None => return Ok(None),
515 };
516
517 Ok(Some((user, verification)))
518 }
519
520 async fn get_current_user(
521 &self,
522 req: &AuthRequest,
523 ctx: &AuthContext,
524 ) -> AuthResult<Option<User>> {
525 let session_manager =
526 better_auth_core::SessionManager::new(ctx.config.clone(), ctx.database.clone());
527
528 if let Some(token) = session_manager.extract_session_token(req)
529 && let Some(session) = session_manager.get_session(&token).await?
530 {
531 return ctx.database.get_user_by_id(&session.user_id).await;
532 }
533
534 Ok(None)
535 }
536
537 fn validate_password(&self, password: &str, ctx: &AuthContext) -> AuthResult<()> {
538 let config = &ctx.config.password;
539
540 if password.len() < config.min_length {
541 return Err(AuthError::bad_request(format!(
542 "Password must be at least {} characters long",
543 config.min_length
544 )));
545 }
546
547 if config.require_uppercase && !password.chars().any(|c| c.is_uppercase()) {
548 return Err(AuthError::bad_request(
549 "Password must contain at least one uppercase letter",
550 ));
551 }
552
553 if config.require_lowercase && !password.chars().any(|c| c.is_lowercase()) {
554 return Err(AuthError::bad_request(
555 "Password must contain at least one lowercase letter",
556 ));
557 }
558
559 if config.require_numbers && !password.chars().any(|c| c.is_ascii_digit()) {
560 return Err(AuthError::bad_request(
561 "Password must contain at least one number",
562 ));
563 }
564
565 if config.require_special
566 && !password
567 .chars()
568 .any(|c| "!@#$%^&*()_+-=[]{}|;:,.<>?".contains(c))
569 {
570 return Err(AuthError::bad_request(
571 "Password must contain at least one special character",
572 ));
573 }
574
575 Ok(())
576 }
577
578 fn hash_password(&self, password: &str) -> AuthResult<String> {
579 use argon2::password_hash::{SaltString, rand_core::OsRng};
580 use argon2::{Argon2, PasswordHasher};
581
582 let salt = SaltString::generate(&mut OsRng);
583 let argon2 = Argon2::default();
584
585 let password_hash = argon2
586 .hash_password(password.as_bytes(), &salt)
587 .map_err(|e| AuthError::PasswordHash(format!("Failed to hash password: {}", e)))?;
588
589 Ok(password_hash.to_string())
590 }
591
592 fn verify_password(&self, password: &str, hash: &str) -> AuthResult<()> {
593 use argon2::password_hash::PasswordHash;
594 use argon2::{Argon2, PasswordVerifier};
595
596 let parsed_hash = PasswordHash::new(hash)
597 .map_err(|e| AuthError::PasswordHash(format!("Invalid password hash: {}", e)))?;
598
599 let argon2 = Argon2::default();
600 argon2
601 .verify_password(password.as_bytes(), &parsed_hash)
602 .map_err(|_| AuthError::InvalidCredentials)?;
603
604 Ok(())
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 use better_auth_core::adapters::{DatabaseAdapter, MemoryDatabaseAdapter};
612 use better_auth_core::config::{Argon2Config, AuthConfig, PasswordConfig};
613 use better_auth_core::{CreateSession, CreateUser, CreateVerification, Session, User};
614 use chrono::{Duration, Utc};
615 use std::collections::HashMap;
616 use std::sync::Arc;
617
618 async fn create_test_context_with_user() -> (AuthContext, User, Session) {
619 let mut config = AuthConfig::new("test-secret-key-at-least-32-chars-long");
620 config.password = PasswordConfig {
621 min_length: 8,
622 require_uppercase: true,
623 require_lowercase: true,
624 require_numbers: true,
625 require_special: true,
626 argon2_config: Argon2Config::default(),
627 };
628
629 let config = Arc::new(config);
630 let database = Arc::new(MemoryDatabaseAdapter::new());
631 let ctx = AuthContext::new(config.clone(), database.clone());
632
633 let plugin = PasswordManagementPlugin::new();
635 let password_hash = plugin.hash_password("Password123!").unwrap();
636
637 let mut metadata = HashMap::new();
638 metadata.insert(
639 "password_hash".to_string(),
640 serde_json::Value::String(password_hash),
641 );
642
643 let create_user = CreateUser::new()
644 .with_email("test@example.com")
645 .with_name("Test User")
646 .with_metadata(metadata);
647 let user = database.create_user(create_user).await.unwrap();
648
649 let create_session = CreateSession {
651 user_id: user.id.clone(),
652 expires_at: Utc::now() + Duration::hours(24),
653 ip_address: Some("127.0.0.1".to_string()),
654 user_agent: Some("test-agent".to_string()),
655 impersonated_by: None,
656 active_organization_id: None,
657 };
658 let session = database.create_session(create_session).await.unwrap();
659
660 (ctx, user, session)
661 }
662
663 fn create_auth_request(
664 method: HttpMethod,
665 path: &str,
666 token: Option<&str>,
667 body: Option<Vec<u8>>,
668 ) -> AuthRequest {
669 let mut headers = HashMap::new();
670 if let Some(token) = token {
671 headers.insert("authorization".to_string(), format!("Bearer {}", token));
672 }
673
674 AuthRequest {
675 method,
676 path: path.to_string(),
677 headers,
678 body,
679 query: HashMap::new(),
680 }
681 }
682
683 #[tokio::test]
684 async fn test_forget_password_success() {
685 let plugin = PasswordManagementPlugin::new();
686 let (ctx, _user, _session) = create_test_context_with_user().await;
687
688 let body = serde_json::json!({
689 "email": "test@example.com",
690 "redirectTo": "http://localhost:3000/reset"
691 });
692
693 let req = create_auth_request(
694 HttpMethod::Post,
695 "/forget-password",
696 None,
697 Some(body.to_string().into_bytes()),
698 );
699
700 let response = plugin.handle_forget_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: StatusResponse = serde_json::from_str(&body_str).unwrap();
705 assert!(response_data.status);
706 }
707
708 #[tokio::test]
709 async fn test_forget_password_unknown_email() {
710 let plugin = PasswordManagementPlugin::new();
711 let (ctx, _user, _session) = create_test_context_with_user().await;
712
713 let body = serde_json::json!({
714 "email": "unknown@example.com"
715 });
716
717 let req = create_auth_request(
718 HttpMethod::Post,
719 "/forget-password",
720 None,
721 Some(body.to_string().into_bytes()),
722 );
723
724 let response = plugin.handle_forget_password(&req, &ctx).await.unwrap();
725 assert_eq!(response.status, 200);
726
727 let body_str = String::from_utf8(response.body).unwrap();
729 let response_data: StatusResponse = serde_json::from_str(&body_str).unwrap();
730 assert!(response_data.status);
731 }
732
733 #[tokio::test]
734 async fn test_reset_password_success() {
735 let plugin = PasswordManagementPlugin::new();
736 let (ctx, user, _session) = create_test_context_with_user().await;
737
738 let reset_token = format!("reset_{}", uuid::Uuid::new_v4());
740 let create_verification = CreateVerification {
741 identifier: user.email.clone().unwrap(),
742 value: reset_token.clone(),
743 expires_at: Utc::now() + Duration::hours(24),
744 };
745 ctx.database
746 .create_verification(create_verification)
747 .await
748 .unwrap();
749
750 let body = serde_json::json!({
751 "newPassword": "NewPassword123!",
752 "token": reset_token
753 });
754
755 let req = create_auth_request(
756 HttpMethod::Post,
757 "/reset-password",
758 None,
759 Some(body.to_string().into_bytes()),
760 );
761
762 let response = plugin.handle_reset_password(&req, &ctx).await.unwrap();
763 assert_eq!(response.status, 200);
764
765 let body_str = String::from_utf8(response.body).unwrap();
766 let response_data: StatusResponse = serde_json::from_str(&body_str).unwrap();
767 assert!(response_data.status);
768
769 let updated_user = ctx
771 .database
772 .get_user_by_id(&user.id)
773 .await
774 .unwrap()
775 .unwrap();
776 let stored_hash = updated_user
777 .metadata
778 .get("password_hash")
779 .unwrap()
780 .as_str()
781 .unwrap();
782 assert!(
783 plugin
784 .verify_password("NewPassword123!", stored_hash)
785 .is_ok()
786 );
787
788 let verification_check = ctx
790 .database
791 .get_verification_by_value(&reset_token)
792 .await
793 .unwrap();
794 assert!(verification_check.is_none());
795 }
796
797 #[tokio::test]
798 async fn test_reset_password_invalid_token() {
799 let plugin = PasswordManagementPlugin::new();
800 let (ctx, _user, _session) = create_test_context_with_user().await;
801
802 let body = serde_json::json!({
803 "newPassword": "NewPassword123!",
804 "token": "invalid_token"
805 });
806
807 let req = create_auth_request(
808 HttpMethod::Post,
809 "/reset-password",
810 None,
811 Some(body.to_string().into_bytes()),
812 );
813
814 let err = plugin.handle_reset_password(&req, &ctx).await.unwrap_err();
815 assert_eq!(err.status_code(), 400);
816 }
817
818 #[tokio::test]
819 async fn test_reset_password_weak_password() {
820 let plugin = PasswordManagementPlugin::new();
821 let (ctx, user, _session) = create_test_context_with_user().await;
822
823 let reset_token = format!("reset_{}", uuid::Uuid::new_v4());
825 let create_verification = CreateVerification {
826 identifier: user.email.clone().unwrap(),
827 value: reset_token.clone(),
828 expires_at: Utc::now() + Duration::hours(24),
829 };
830 ctx.database
831 .create_verification(create_verification)
832 .await
833 .unwrap();
834
835 let body = serde_json::json!({
836 "newPassword": "weak",
837 "token": reset_token
838 });
839
840 let req = create_auth_request(
841 HttpMethod::Post,
842 "/reset-password",
843 None,
844 Some(body.to_string().into_bytes()),
845 );
846
847 let err = plugin.handle_reset_password(&req, &ctx).await.unwrap_err();
848 assert_eq!(err.status_code(), 400);
849 }
850
851 #[tokio::test]
852 async fn test_change_password_success() {
853 let plugin = PasswordManagementPlugin::new();
854 let (ctx, _user, session) = create_test_context_with_user().await;
855
856 let body = serde_json::json!({
857 "currentPassword": "Password123!",
858 "newPassword": "NewPassword123!",
859 "revokeOtherSessions": "false"
860 });
861
862 let req = create_auth_request(
863 HttpMethod::Post,
864 "/change-password",
865 Some(&session.token),
866 Some(body.to_string().into_bytes()),
867 );
868
869 let response = plugin.handle_change_password(&req, &ctx).await.unwrap();
870 assert_eq!(response.status, 200);
871
872 let body_str = String::from_utf8(response.body).unwrap();
873 let response_data: ChangePasswordResponse = serde_json::from_str(&body_str).unwrap();
874 assert!(response_data.token.is_none()); let updated_user = ctx
878 .database
879 .get_user_by_id(&response_data.user.id)
880 .await
881 .unwrap()
882 .unwrap();
883 let stored_hash = updated_user
884 .metadata
885 .get("password_hash")
886 .unwrap()
887 .as_str()
888 .unwrap();
889 assert!(
890 plugin
891 .verify_password("NewPassword123!", stored_hash)
892 .is_ok()
893 );
894 }
895
896 #[tokio::test]
897 async fn test_change_password_with_session_revocation() {
898 let plugin = PasswordManagementPlugin::new();
899 let (ctx, _user, session) = create_test_context_with_user().await;
900
901 let body = serde_json::json!({
902 "currentPassword": "Password123!",
903 "newPassword": "NewPassword123!",
904 "revokeOtherSessions": "true"
905 });
906
907 let req = create_auth_request(
908 HttpMethod::Post,
909 "/change-password",
910 Some(&session.token),
911 Some(body.to_string().into_bytes()),
912 );
913
914 let response = plugin.handle_change_password(&req, &ctx).await.unwrap();
915 assert_eq!(response.status, 200);
916
917 let body_str = String::from_utf8(response.body).unwrap();
918 let response_data: ChangePasswordResponse = serde_json::from_str(&body_str).unwrap();
919 assert!(response_data.token.is_some()); }
921
922 #[tokio::test]
923 async fn test_change_password_wrong_current_password() {
924 let plugin = PasswordManagementPlugin::new();
925 let (ctx, _user, session) = create_test_context_with_user().await;
926
927 let body = serde_json::json!({
928 "currentPassword": "WrongPassword123!",
929 "newPassword": "NewPassword123!"
930 });
931
932 let req = create_auth_request(
933 HttpMethod::Post,
934 "/change-password",
935 Some(&session.token),
936 Some(body.to_string().into_bytes()),
937 );
938
939 let err = plugin.handle_change_password(&req, &ctx).await.unwrap_err();
940 assert_eq!(err.status_code(), 401);
941 }
942
943 #[tokio::test]
944 async fn test_change_password_unauthorized() {
945 let plugin = PasswordManagementPlugin::new();
946 let (ctx, _user, _session) = create_test_context_with_user().await;
947
948 let body = serde_json::json!({
949 "currentPassword": "Password123!",
950 "newPassword": "NewPassword123!"
951 });
952
953 let req = create_auth_request(
954 HttpMethod::Post,
955 "/change-password",
956 None,
957 Some(body.to_string().into_bytes()),
958 );
959
960 let err = plugin.handle_change_password(&req, &ctx).await.unwrap_err();
961 assert_eq!(err.status_code(), 401);
962 }
963
964 #[tokio::test]
965 async fn test_reset_password_token_endpoint_success() {
966 let plugin = PasswordManagementPlugin::new();
967 let (ctx, user, _session) = create_test_context_with_user().await;
968
969 let reset_token = format!("reset_{}", uuid::Uuid::new_v4());
971 let create_verification = CreateVerification {
972 identifier: user.email.clone().unwrap(),
973 value: reset_token.clone(),
974 expires_at: Utc::now() + Duration::hours(24),
975 };
976 ctx.database
977 .create_verification(create_verification)
978 .await
979 .unwrap();
980
981 let req = create_auth_request(HttpMethod::Get, "/reset-password/token", None, None);
982
983 let response = plugin
984 .handle_reset_password_token(&reset_token, &req, &ctx)
985 .await
986 .unwrap();
987 assert_eq!(response.status, 200);
988
989 let body_str = String::from_utf8(response.body).unwrap();
990 let response_data: ResetPasswordTokenResponse = serde_json::from_str(&body_str).unwrap();
991 assert_eq!(response_data.token, reset_token);
992 }
993
994 #[tokio::test]
995 async fn test_reset_password_token_endpoint_with_callback() {
996 let plugin = PasswordManagementPlugin::new();
997 let (ctx, user, _session) = create_test_context_with_user().await;
998
999 let reset_token = format!("reset_{}", uuid::Uuid::new_v4());
1001 let create_verification = CreateVerification {
1002 identifier: user.email.clone().unwrap(),
1003 value: reset_token.clone(),
1004 expires_at: Utc::now() + Duration::hours(24),
1005 };
1006 ctx.database
1007 .create_verification(create_verification)
1008 .await
1009 .unwrap();
1010
1011 let mut query = HashMap::new();
1012 query.insert(
1013 "callbackURL".to_string(),
1014 "http://localhost:3000/reset".to_string(),
1015 );
1016
1017 let req = AuthRequest {
1018 method: HttpMethod::Get,
1019 path: "/reset-password/token".to_string(),
1020 headers: HashMap::new(),
1021 body: None,
1022 query,
1023 };
1024
1025 let response = plugin
1026 .handle_reset_password_token(&reset_token, &req, &ctx)
1027 .await
1028 .unwrap();
1029 assert_eq!(response.status, 302);
1030
1031 let location_header = response
1033 .headers
1034 .iter()
1035 .find(|(key, _)| *key == "Location")
1036 .map(|(_, value)| value);
1037 assert!(location_header.is_some());
1038 assert!(
1039 location_header
1040 .unwrap()
1041 .contains("http://localhost:3000/reset")
1042 );
1043 assert!(location_header.unwrap().contains(&reset_token));
1044 }
1045
1046 #[tokio::test]
1047 async fn test_reset_password_token_endpoint_invalid_token() {
1048 let plugin = PasswordManagementPlugin::new();
1049 let (ctx, _user, _session) = create_test_context_with_user().await;
1050
1051 let req = create_auth_request(HttpMethod::Get, "/reset-password/token", None, None);
1052
1053 let err = plugin
1054 .handle_reset_password_token("invalid_token", &req, &ctx)
1055 .await
1056 .unwrap_err();
1057 assert_eq!(err.status_code(), 400);
1058 }
1059
1060 #[tokio::test]
1061 async fn test_password_validation() {
1062 let plugin = PasswordManagementPlugin::new();
1063 let mut config = AuthConfig::new("test-secret");
1064 config.password = PasswordConfig {
1065 min_length: 8,
1066 require_uppercase: true,
1067 require_lowercase: true,
1068 require_numbers: true,
1069 require_special: true,
1070 argon2_config: Argon2Config::default(),
1071 };
1072 let ctx = AuthContext::new(Arc::new(config), Arc::new(MemoryDatabaseAdapter::new()));
1073
1074 assert!(plugin.validate_password("Password123!", &ctx).is_ok());
1076
1077 assert!(plugin.validate_password("Pass1!", &ctx).is_err());
1079
1080 assert!(plugin.validate_password("password123!", &ctx).is_err());
1082
1083 assert!(plugin.validate_password("PASSWORD123!", &ctx).is_err());
1085
1086 assert!(plugin.validate_password("Password!", &ctx).is_err());
1088
1089 assert!(plugin.validate_password("Password123", &ctx).is_err());
1091 }
1092
1093 #[tokio::test]
1094 async fn test_password_hashing_and_verification() {
1095 let plugin = PasswordManagementPlugin::new();
1096
1097 let password = "TestPassword123!";
1098 let hash = plugin.hash_password(password).unwrap();
1099
1100 assert!(plugin.verify_password(password, &hash).is_ok());
1102
1103 assert!(plugin.verify_password("WrongPassword123!", &hash).is_err());
1105 }
1106
1107 #[tokio::test]
1108 async fn test_plugin_routes() {
1109 let plugin = PasswordManagementPlugin::new();
1110 let routes = plugin.routes();
1111
1112 assert_eq!(routes.len(), 5);
1113 assert!(
1114 routes
1115 .iter()
1116 .any(|r| r.path == "/forget-password" && r.method == HttpMethod::Post)
1117 );
1118 assert!(
1119 routes
1120 .iter()
1121 .any(|r| r.path == "/reset-password" && r.method == HttpMethod::Post)
1122 );
1123 assert!(
1124 routes
1125 .iter()
1126 .any(|r| r.path == "/reset-password/{token}" && r.method == HttpMethod::Get)
1127 );
1128 assert!(
1129 routes
1130 .iter()
1131 .any(|r| r.path == "/change-password" && r.method == HttpMethod::Post)
1132 );
1133 }
1134
1135 #[tokio::test]
1136 async fn test_plugin_on_request_routing() {
1137 let plugin = PasswordManagementPlugin::new();
1138 let (ctx, _user, session) = create_test_context_with_user().await;
1139
1140 let body = serde_json::json!({"email": "test@example.com"});
1142 let req = create_auth_request(
1143 HttpMethod::Post,
1144 "/forget-password",
1145 None,
1146 Some(body.to_string().into_bytes()),
1147 );
1148 let response = plugin.on_request(&req, &ctx).await.unwrap();
1149 assert!(response.is_some());
1150 assert_eq!(response.unwrap().status, 200);
1151
1152 let body = serde_json::json!({
1154 "currentPassword": "Password123!",
1155 "newPassword": "NewPassword123!"
1156 });
1157 let req = create_auth_request(
1158 HttpMethod::Post,
1159 "/change-password",
1160 Some(&session.token),
1161 Some(body.to_string().into_bytes()),
1162 );
1163 let response = plugin.on_request(&req, &ctx).await.unwrap();
1164 assert!(response.is_some());
1165 assert_eq!(response.unwrap().status, 200);
1166
1167 let req = create_auth_request(HttpMethod::Get, "/invalid-route", None, None);
1169 let response = plugin.on_request(&req, &ctx).await.unwrap();
1170 assert!(response.is_none());
1171 }
1172
1173 #[tokio::test]
1174 async fn test_configuration() {
1175 let config = PasswordManagementConfig {
1176 reset_token_expiry_hours: 48,
1177 require_current_password: false,
1178 send_email_notifications: false,
1179 };
1180
1181 let plugin = PasswordManagementPlugin::with_config(config);
1182 assert_eq!(plugin.config.reset_token_expiry_hours, 48);
1183 assert!(!plugin.config.require_current_password);
1184 assert!(!plugin.config.send_email_notifications);
1185 }
1186}