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