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::{
10    AuthRequest, AuthResponse, CreateVerification, HttpMethod, UpdateUser, User,
11};
12
13/// Password management plugin for password reset and change functionality
14pub 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// Request structures for password endpoints
26#[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// Response structures
62#[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, // 24 hours default expiry
115            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..]; // Remove "/reset-password/" prefix
157                Ok(Some(
158                    self.handle_reset_password_token(token, req, ctx).await?,
159                ))
160            }
161            _ => Ok(None),
162        }
163    }
164}
165
166// Implementation methods outside the trait
167impl 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        // Check if user exists
179        let user = match ctx.database.get_user_by_email(&forget_req.email).await? {
180            Some(user) => user,
181            None => {
182                // Don't reveal whether email exists or not for security
183                let response = StatusResponse { status: true };
184                return Ok(AuthResponse::json(200, &response)?);
185            }
186        };
187
188        // Generate password reset token
189        let reset_token = format!("reset_{}", Uuid::new_v4());
190        let expires_at = Utc::now() + Duration::hours(self.config.reset_token_expiry_hours);
191
192        // Create verification token
193        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        // Send email with reset link
204        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        // Validate password
255        self.validate_password(&reset_req.new_password, ctx)?;
256
257        // Find user by reset token
258        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        // Hash new password
269        let password_hash = self.hash_password(&reset_req.new_password)?;
270
271        // Update user password
272        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        // Delete the used verification token
296        ctx.database.delete_verification(&verification.id).await?;
297
298        // Revoke all existing sessions for security
299        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        // Get current user from session
316        let user = self
317            .get_current_user(req, ctx)
318            .await?
319            .ok_or(AuthError::Unauthenticated)?;
320
321        // Verify current password
322        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        // Validate new password
334        self.validate_password(&change_req.new_password, ctx)?;
335
336        // Hash new password
337        let password_hash = self.hash_password(&change_req.new_password)?;
338
339        // Update user password
340        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        // Handle session revocation
364        let new_token = if change_req.revoke_other_sessions.as_deref() == Some("true") {
365            // Revoke all sessions except current one
366            ctx.database.delete_user_sessions(&user.id).await?;
367
368            // Create new session
369            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        // Authenticate user
398        let user = self
399            .get_current_user(req, ctx)
400            .await?
401            .ok_or(AuthError::Unauthenticated)?;
402
403        // Verify the user does NOT already have a password
404        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        // Validate new password
416        self.validate_password(&set_req.new_password, ctx)?;
417
418        // Hash and store the new password
419        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        // Check if token is valid and get callback URL from query parameters
455        let callback_url = _req.query.get("callbackURL").cloned();
456
457        // Validate the reset token exists and is not expired
458        let (_user, _verification) = match self.find_user_by_reset_token(token, ctx).await? {
459            Some((user, verification)) => (user, verification),
460            None => {
461                // Redirect to callback URL with error if provided
462                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 callback URL is provided, redirect with valid token
478        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        // Otherwise return the token directly
490        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        // Find verification token by value
502        let verification = match ctx.database.get_verification_by_value(token).await? {
503            Some(verification) => verification,
504            None => return Ok(None),
505        };
506
507        // Get user by email (stored in identifier field)
508        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        // Create test user with hashed password
634        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        // Create test session
650        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        // Should return success even for unknown emails (security)
728        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        // Create verification token
739        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        // Verify password was updated
770        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        // Verify token was deleted
789        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        // Create verification token
824        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()); // No new token when not revoking sessions
875
876        // Verify password was updated by checking the database directly
877        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()); // New token when revoking sessions
920    }
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        // Create verification token
970        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        // Create verification token
1000        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        // Check redirect URL
1032        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        // Test valid password
1075        assert!(plugin.validate_password("Password123!", &ctx).is_ok());
1076
1077        // Test too short
1078        assert!(plugin.validate_password("Pass1!", &ctx).is_err());
1079
1080        // Test missing uppercase
1081        assert!(plugin.validate_password("password123!", &ctx).is_err());
1082
1083        // Test missing lowercase
1084        assert!(plugin.validate_password("PASSWORD123!", &ctx).is_err());
1085
1086        // Test missing number
1087        assert!(plugin.validate_password("Password!", &ctx).is_err());
1088
1089        // Test missing special character
1090        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        // Should verify correctly
1101        assert!(plugin.verify_password(password, &hash).is_ok());
1102
1103        // Should fail with wrong password
1104        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        // Test forget password
1141        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        // Test change password
1153        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        // Test invalid route
1168        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}