Skip to main content

better_auth_api/plugins/
password_management.rs

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
12/// Password management plugin for password reset and change functionality
13pub 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// Request structures for password endpoints
25#[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// Response structures
61#[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, // 24 hours default expiry
114            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..]; // Remove "/reset-password/" prefix
156                Ok(Some(
157                    self.handle_reset_password_token(token, req, ctx).await?,
158                ))
159            }
160            _ => Ok(None),
161        }
162    }
163}
164
165// Implementation methods outside the trait
166impl 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        // Check if user exists
178        let user = match ctx.database.get_user_by_email(&forget_req.email).await? {
179            Some(user) => user,
180            None => {
181                // Don't reveal whether email exists or not for security
182                let response = StatusResponse { status: true };
183                return Ok(AuthResponse::json(200, &response)?);
184            }
185        };
186
187        // Generate password reset token
188        let reset_token = format!("reset_{}", Uuid::new_v4());
189        let expires_at = Utc::now() + Duration::hours(self.config.reset_token_expiry_hours);
190
191        // Create verification token
192        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        // Send email with reset link
203        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        // Validate password
254        self.validate_password(&reset_req.new_password, ctx)?;
255
256        // Find user by reset token
257        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        // Hash new password
268        let password_hash = self.hash_password(&reset_req.new_password)?;
269
270        // Update user password
271        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        // Delete the used verification token
292        ctx.database.delete_verification(verification.id()).await?;
293
294        // Revoke all existing sessions for security
295        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        // Get current user from session
312        let user = self
313            .get_current_user(req, ctx)
314            .await?
315            .ok_or(AuthError::Unauthenticated)?;
316
317        // Verify current password
318        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        // Validate new password
330        self.validate_password(&change_req.new_password, ctx)?;
331
332        // Hash new password
333        let password_hash = self.hash_password(&change_req.new_password)?;
334
335        // Update user password
336        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        // Handle session revocation
357        let new_token = if change_req.revoke_other_sessions.as_deref() == Some("true") {
358            // Revoke all sessions except current one
359            ctx.database.delete_user_sessions(user.id()).await?;
360
361            // Create new session
362            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        // Authenticate user
391        let user = self
392            .get_current_user(req, ctx)
393            .await?
394            .ok_or(AuthError::Unauthenticated)?;
395
396        // Verify the user does NOT already have a password
397        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        // Validate new password
409        self.validate_password(&set_req.new_password, ctx)?;
410
411        // Hash and store the new password
412        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        // Check if token is valid and get callback URL from query parameters
445        let callback_url = _req.query.get("callbackURL").cloned();
446
447        // Validate the reset token exists and is not expired
448        let (_user, _verification) = match self.find_user_by_reset_token(token, ctx).await? {
449            Some((user, verification)) => (user, verification),
450            None => {
451                // Redirect to callback URL with error if provided
452                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 callback URL is provided, redirect with valid token
468        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        // Otherwise return the token directly
480        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        // Find verification token by value
492        let verification = match ctx.database.get_verification_by_value(token).await? {
493            Some(verification) => verification,
494            None => return Ok(None),
495        };
496
497        // Get user by email (stored in identifier field)
498        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        // Create test user with hashed password
629        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        // Create test session
643        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        // Should return success even for unknown emails (security)
721        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        // Create verification token
732        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        // Verify password was updated
763        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        // Verify token was deleted
782        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        // Create verification token
817        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()); // No new token when not revoking sessions
868
869        // Verify password was updated by checking the database directly
870        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()); // New token when revoking sessions
909    }
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        // Create verification token
959        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        // Create verification token
989        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        // Check redirect URL
1021        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        // Test valid password
1064        assert!(plugin.validate_password("Password123!", &ctx).is_ok());
1065
1066        // Test too short
1067        assert!(plugin.validate_password("Pass1!", &ctx).is_err());
1068
1069        // Test missing uppercase
1070        assert!(plugin.validate_password("password123!", &ctx).is_err());
1071
1072        // Test missing lowercase
1073        assert!(plugin.validate_password("PASSWORD123!", &ctx).is_err());
1074
1075        // Test missing number
1076        assert!(plugin.validate_password("Password!", &ctx).is_err());
1077
1078        // Test missing special character
1079        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        // Should verify correctly
1090        assert!(plugin.verify_password(password, &hash).is_ok());
1091
1092        // Should fail with wrong password
1093        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        // Test forget password
1130        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        // Test change password
1142        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        // Test invalid route
1157        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}