Skip to main content

better_auth_api/plugins/password_management/
mod.rs

1use async_trait::async_trait;
2use std::future::Future;
3use std::pin::Pin;
4use std::sync::Arc;
5
6use better_auth_core::AuthSession;
7use better_auth_core::{AuthContext, AuthPlugin, AuthRoute};
8use better_auth_core::{AuthError, AuthResult};
9use better_auth_core::{AuthRequest, AuthResponse, HttpMethod};
10
11use better_auth_core::RequestMeta;
12use better_auth_core::utils::password::PasswordHasher;
13
14use super::StatusResponse;
15
16pub(super) mod handlers;
17pub(super) mod types;
18
19#[cfg(test)]
20mod tests;
21
22use handlers::*;
23use types::*;
24
25/// Type alias for the async password-reset callback to keep Clippy happy.
26pub type OnPasswordResetCallback =
27    dyn Fn(serde_json::Value) -> Pin<Box<dyn Future<Output = AuthResult<()>> + Send>> + Send + Sync;
28
29/// Trait for sending password reset emails.
30///
31/// This callback powers `POST /request-password-reset` and is required to
32/// enable that route. The user is provided as a serialized `serde_json::Value`
33/// since `AuthUser` is not object-safe.
34#[async_trait]
35pub trait SendResetPassword: Send + Sync {
36    /// Send a password reset notification.
37    ///
38    /// * `user` - The user as a serialized JSON value (from `serde_json::to_value`)
39    /// * `url` - The full reset URL including the token
40    /// * `token` - The raw reset token
41    async fn send(&self, user: &serde_json::Value, url: &str, token: &str) -> AuthResult<()>;
42}
43
44/// Password management plugin for password reset and change functionality
45pub struct PasswordManagementPlugin {
46    config: PasswordManagementConfig,
47}
48
49#[derive(Clone, better_auth_core::PluginConfig)]
50#[plugin(name = "PasswordManagementPlugin")]
51pub struct PasswordManagementConfig {
52    #[config(default = 24)]
53    pub reset_token_expiry_hours: i64,
54    #[config(default = true)]
55    pub require_current_password: bool,
56    #[config(default = true)]
57    pub send_email_notifications: bool,
58    /// When true, all existing sessions are revoked on password reset (default: false).
59    #[config(default = false)]
60    pub revoke_sessions_on_password_reset: bool,
61    /// Password reset email sender for `POST /request-password-reset`.
62    /// This route is disabled when no sender is configured.
63    #[config(default = None)]
64    pub send_reset_password: Option<Arc<dyn SendResetPassword>>,
65    /// Callback invoked after a password is successfully reset.
66    /// The user is provided as a serialized `serde_json::Value`.
67    #[config(default = None)]
68    pub on_password_reset: Option<Arc<OnPasswordResetCallback>>,
69    /// Custom password hasher. When `None`, the default Argon2 hasher is used.
70    #[config(default = None)]
71    pub password_hasher: Option<Arc<dyn PasswordHasher>>,
72}
73
74impl std::fmt::Debug for PasswordManagementConfig {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.debug_struct("PasswordManagementConfig")
77            .field("reset_token_expiry_hours", &self.reset_token_expiry_hours)
78            .field("require_current_password", &self.require_current_password)
79            .field("send_email_notifications", &self.send_email_notifications)
80            .field(
81                "revoke_sessions_on_password_reset",
82                &self.revoke_sessions_on_password_reset,
83            )
84            .field(
85                "send_reset_password",
86                &self.send_reset_password.as_ref().map(|_| "custom"),
87            )
88            .field(
89                "on_password_reset",
90                &self.on_password_reset.as_ref().map(|_| "custom"),
91            )
92            .field(
93                "password_hasher",
94                &self.password_hasher.as_ref().map(|_| "custom"),
95            )
96            .finish()
97    }
98}
99
100#[async_trait]
101impl<S: better_auth_core::AuthSchema> AuthPlugin<S> for PasswordManagementPlugin {
102    fn name(&self) -> &'static str {
103        "password-management"
104    }
105
106    fn routes(&self) -> Vec<AuthRoute> {
107        vec![
108            AuthRoute::post("/request-password-reset", "request_password_reset"),
109            AuthRoute::post("/reset-password", "reset_password"),
110            AuthRoute::get("/reset-password/{token}", "reset_password_token"),
111            AuthRoute::post("/change-password", "change_password"),
112            AuthRoute::post("/verify-password", "verify_password"),
113        ]
114    }
115
116    async fn on_request(
117        &self,
118        req: &AuthRequest,
119        ctx: &AuthContext<S>,
120    ) -> AuthResult<Option<AuthResponse>> {
121        match (req.method(), req.path()) {
122            (HttpMethod::Post, "/request-password-reset") => {
123                Ok(Some(self.handle_request_password_reset(req, ctx).await?))
124            }
125            (HttpMethod::Post, "/reset-password") => {
126                Ok(Some(self.handle_reset_password(req, ctx).await?))
127            }
128            (HttpMethod::Post, "/change-password") => {
129                Ok(Some(self.handle_change_password(req, ctx).await?))
130            }
131            (HttpMethod::Post, "/verify-password") => {
132                Ok(Some(self.handle_verify_password(req, ctx).await?))
133            }
134            (HttpMethod::Get, path) if path.starts_with("/reset-password/") => {
135                let token = path.get(16..).unwrap_or(""); // Remove "/reset-password/" prefix
136                Ok(Some(
137                    self.handle_reset_password_token(token, req, ctx).await?,
138                ))
139            }
140            _ => Ok(None),
141        }
142    }
143}
144
145// Implementation methods outside the trait
146impl PasswordManagementPlugin {
147    async fn handle_request_password_reset(
148        &self,
149        req: &AuthRequest,
150        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
151    ) -> AuthResult<AuthResponse> {
152        let body: RequestPasswordResetRequest = match better_auth_core::validate_request_body(req) {
153            Ok(v) => v,
154            Err(resp) => return Ok(resp),
155        };
156        let response = request_password_reset_core(&body, &self.config, ctx).await?;
157        Ok(AuthResponse::json(200, &response)?)
158    }
159
160    async fn handle_reset_password(
161        &self,
162        req: &AuthRequest,
163        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
164    ) -> AuthResult<AuthResponse> {
165        let mut body: ResetPasswordRequest = match better_auth_core::validate_request_body(req) {
166            Ok(v) => v,
167            Err(resp) => return Ok(resp),
168        };
169        if body.token.is_none() {
170            body.token = req.query.get("token").cloned();
171        }
172        let response = reset_password_core(&body, &self.config, ctx).await?;
173        Ok(AuthResponse::json(200, &response)?)
174    }
175
176    async fn handle_change_password(
177        &self,
178        req: &AuthRequest,
179        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
180    ) -> AuthResult<AuthResponse> {
181        let body: ChangePasswordRequest = match better_auth_core::validate_request_body(req) {
182            Ok(v) => v,
183            Err(resp) => return Ok(resp),
184        };
185
186        // Get current user from session
187        let user = self
188            .get_current_user(req, ctx)
189            .await?
190            .ok_or(AuthError::Unauthenticated)?;
191        let meta = RequestMeta::from_request(req);
192
193        let (response, new_token) =
194            change_password_core(&body, &user, &self.config, &meta, ctx).await?;
195
196        let auth_response = AuthResponse::json(200, &response)?;
197
198        // Set session cookie if a new session was created
199        if let Some(token) = new_token {
200            let cookie_header =
201                better_auth_core::utils::cookie_utils::create_session_cookie(&token, &ctx.config);
202            Ok(auth_response.with_header("Set-Cookie", cookie_header))
203        } else {
204            Ok(auth_response)
205        }
206    }
207
208    async fn handle_verify_password(
209        &self,
210        req: &AuthRequest,
211        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
212    ) -> AuthResult<AuthResponse> {
213        let body: VerifyPasswordRequest = match better_auth_core::validate_request_body(req) {
214            Ok(v) => v,
215            Err(resp) => return Ok(resp),
216        };
217
218        let user = self.get_current_user(req, ctx).await?;
219        let Some(user) = user else {
220            return Ok(AuthResponse::new(401).with_header("content-type", "application/json"));
221        };
222        let response = verify_password_core(&body, &user, &self.config, ctx).await?;
223        Ok(AuthResponse::json(200, &response)?)
224    }
225
226    async fn handle_reset_password_token(
227        &self,
228        token: &str,
229        req: &AuthRequest,
230        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
231    ) -> AuthResult<AuthResponse> {
232        let query = ResetPasswordTokenQuery {
233            callback_url: req.query.get("callbackURL").cloned(),
234        };
235        match reset_password_token_core(token, &query, ctx).await? {
236            ResetPasswordTokenResult::Redirect(url) => {
237                let mut headers = better_auth_core::Headers::new();
238                let _ = headers.insert("Location".to_string(), url);
239                let _ = headers.insert("content-type".to_string(), "application/json".to_string());
240                Ok(AuthResponse {
241                    status: 302,
242                    headers,
243                    body: Vec::new(),
244                })
245            }
246        }
247    }
248
249    async fn get_current_user<S: better_auth_core::AuthSchema>(
250        &self,
251        req: &AuthRequest,
252        ctx: &AuthContext<S>,
253    ) -> AuthResult<Option<S::User>> {
254        let session_manager = ctx.session_manager();
255
256        if let Some(token) = session_manager.extract_session_token(req)
257            && let Some(session) = session_manager.get_session(&token).await?
258        {
259            return ctx.database.get_user_by_id(&session.user_id()).await;
260        }
261
262        Ok(None)
263    }
264}
265
266#[cfg(test)]
267impl PasswordManagementPlugin {
268    fn validate_password(
269        &self,
270        password: &str,
271        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
272    ) -> AuthResult<()> {
273        better_auth_core::utils::password::validate_password(
274            password,
275            ctx.config.password.min_length,
276            usize::MAX,
277            ctx,
278        )
279    }
280
281    async fn hash_password(&self, password: &str) -> AuthResult<String> {
282        better_auth_core::utils::password::hash_password(
283            self.config.password_hasher.as_ref(),
284            password,
285        )
286        .await
287    }
288
289    async fn verify_password(&self, password: &str, hash: &str) -> AuthResult<()> {
290        better_auth_core::utils::password::verify_password(
291            self.config.password_hasher.as_ref(),
292            password,
293            hash,
294        )
295        .await
296    }
297}