better_auth_api/plugins/password_management/
mod.rs1use async_trait::async_trait;
2use std::sync::Arc;
3
4use better_auth_core::AuthSession;
5use better_auth_core::{AuthContext, AuthPlugin, AuthRoute};
6use better_auth_core::{AuthError, AuthResult};
7use better_auth_core::{AuthRequest, AuthResponse, HttpMethod};
8
9use better_auth_core::RequestMeta;
10use better_auth_core::utils::password::PasswordHasher;
11
12use super::StatusResponse;
13
14mod callbacks;
15pub use callbacks::{PasswordManagementCallbacks, PasswordResetEmail};
16pub(super) mod handlers;
17mod request;
18pub(super) mod types;
19
20#[cfg(test)]
21mod duration_tests;
22#[cfg(test)]
23mod tests;
24
25use handlers::*;
26use types::*;
27
28pub use better_auth_core::utils::password::{OnPasswordResetCallback, PasswordResetEvent};
30
31#[async_trait]
37pub trait SendResetPassword: Send + Sync {
38 async fn send(&self, user: &serde_json::Value, url: &str, token: &str) -> AuthResult<()>;
44
45 async fn send_with_request(
46 &self,
47 user: &serde_json::Value,
48 url: &str,
49 token: &str,
50 _request: Option<&AuthRequest>,
51 ) -> AuthResult<()> {
52 self.send(user, url, token).await
53 }
54}
55
56pub struct PasswordManagementPlugin {
58 config: PasswordManagementConfig,
59}
60
61#[derive(Clone, better_auth_core::PluginConfig)]
62#[plugin(name = "PasswordManagementPlugin")]
63pub struct PasswordManagementConfig {
64 #[config(default = None)]
66 pub reset_password_token_expires_in: Option<f64>,
67 #[config(default = true)]
68 pub require_current_password: bool,
69 #[config(default = true)]
70 pub send_email_notifications: bool,
71 #[config(default = false)]
73 pub revoke_sessions_on_password_reset: bool,
74 #[config(default = None)]
77 pub send_reset_password: Option<Arc<dyn SendResetPassword>>,
78 #[config(default = None)]
81 pub on_password_reset: Option<Arc<OnPasswordResetCallback>>,
82 #[config(default = None)]
84 pub password_hasher: Option<Arc<dyn PasswordHasher>>,
85}
86
87impl PasswordManagementConfig {
88 pub fn reset_password_token_expires_in(&self) -> f64 {
90 match self.reset_password_token_expires_in {
91 Some(seconds) if seconds != 0.0 && !seconds.is_nan() => seconds,
92 _ => 3600.0,
93 }
94 }
95
96 fn reset_token_expires_at(
97 &self,
98 now: chrono::DateTime<chrono::Utc>,
99 ) -> AuthResult<chrono::DateTime<chrono::Utc>> {
100 better_auth_core::utils::date::from_milliseconds(
101 now.timestamp_millis() as f64 + self.reset_password_token_expires_in() * 1000.0,
102 )
103 .ok_or_else(|| AuthError::config("Reset token expiry is out of range"))
104 }
105}
106
107impl std::fmt::Debug for PasswordManagementConfig {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 f.debug_struct("PasswordManagementConfig")
110 .field(
111 "reset_password_token_expires_in",
112 &self.reset_password_token_expires_in,
113 )
114 .field("require_current_password", &self.require_current_password)
115 .field("send_email_notifications", &self.send_email_notifications)
116 .field(
117 "revoke_sessions_on_password_reset",
118 &self.revoke_sessions_on_password_reset,
119 )
120 .field(
121 "send_reset_password",
122 &self.send_reset_password.as_ref().map(|_| "custom"),
123 )
124 .field(
125 "on_password_reset",
126 &self.on_password_reset.as_ref().map(|_| "custom"),
127 )
128 .field(
129 "password_hasher",
130 &self.password_hasher.as_ref().map(|_| "custom"),
131 )
132 .finish()
133 }
134}
135
136#[async_trait]
137impl<S: better_auth_core::AuthSchema> AuthPlugin<S> for PasswordManagementPlugin {
138 fn telemetry(&self, options: &mut better_auth_core::observability::telemetry::PluginTelemetry) {
139 let options = &mut options.email_and_password;
140 options.reset_password_token_expires_in = self.config.reset_password_token_expires_in;
141 options.send_reset_password = self.config.send_reset_password.is_some();
142 options.on_password_reset = self.config.on_password_reset.is_some();
143 options.revoke_sessions_on_password_reset = self.config.revoke_sessions_on_password_reset;
144 options.password.hash |= self.config.password_hasher.is_some();
145 options.password.verify |= self.config.password_hasher.is_some();
146 }
147
148 fn password_hasher(&self) -> Option<Arc<dyn PasswordHasher>> {
149 self.config.password_hasher.clone()
150 }
151
152 async fn on_init(&self, ctx: &mut better_auth_core::AuthInitContext<S>) -> AuthResult<()> {
153 ctx.password_policy.on_password_reset = self.config.on_password_reset.clone();
154 ctx.password_policy.revoke_sessions_on_password_reset =
155 self.config.revoke_sessions_on_password_reset;
156 Ok(())
157 }
158
159 fn name(&self) -> &'static str {
160 "password-management"
161 }
162
163 fn telemetry_plugin_id(&self) -> Option<&'static str> {
164 None
165 }
166
167 fn routes(&self) -> Vec<AuthRoute> {
168 vec![
169 AuthRoute::post("/request-password-reset", "requestPasswordReset")
170 .body_validator(request::request_reset_body),
171 AuthRoute::post("/reset-password", "resetPassword")
172 .body_validator(request::reset_body)
173 .query_validator(crate::plugins::query_input::reset_password),
174 AuthRoute::get("/reset-password/{token}", "resetPasswordCallback")
175 .query_validator(crate::plugins::query_input::reset_password_token),
176 AuthRoute::post("/change-password", "changePassword")
177 .body_validator(request::change_body),
178 AuthRoute::post("/verify-password", "verifyPassword")
179 .body_validator(request::verify_body),
180 ]
181 }
182
183 async fn on_request(
184 &self,
185 req: &AuthRequest,
186 ctx: &AuthContext<S>,
187 ) -> AuthResult<Option<AuthResponse>> {
188 match (req.method(), req.path()) {
189 (HttpMethod::Post, "/request-password-reset") => {
190 Ok(Some(self.handle_request_password_reset(req, ctx).await?))
191 }
192 (HttpMethod::Post, "/reset-password") => {
193 Ok(Some(self.handle_reset_password(req, ctx).await?))
194 }
195 (HttpMethod::Post, "/change-password") => {
196 Ok(Some(self.handle_change_password(req, ctx).await?))
197 }
198 (HttpMethod::Post, "/verify-password") => {
199 Ok(Some(self.handle_verify_password(req, ctx).await?))
200 }
201 (HttpMethod::Get, path) if path.starts_with("/reset-password/") => {
202 let token = path.get(16..).unwrap_or(""); Ok(Some(
204 self.handle_reset_password_token(token, req, ctx).await?,
205 ))
206 }
207 _ => Ok(None),
208 }
209 }
210}
211
212impl PasswordManagementPlugin {
214 async fn handle_request_password_reset(
215 &self,
216 req: &AuthRequest,
217 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
218 ) -> AuthResult<AuthResponse> {
219 let body = request::request_reset(req)?;
220 let response = request_password_reset_core(&body, &self.config, req, ctx).await?;
221 Ok(AuthResponse::json(200, &response)?)
222 }
223
224 async fn handle_reset_password(
225 &self,
226 req: &AuthRequest,
227 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
228 ) -> AuthResult<AuthResponse> {
229 let mut body = request::reset(req)?;
230 if body.token.as_deref().is_none_or(str::is_empty) {
231 body.token = req.query_string("token")?.map(str::to_owned);
232 }
233 let response = reset_password_core(&body, req, ctx).await?;
234 Ok(AuthResponse::json(200, &response)?)
235 }
236
237 async fn handle_change_password(
238 &self,
239 req: &AuthRequest,
240 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
241 ) -> AuthResult<AuthResponse> {
242 let body = request::change(req)?;
243
244 let user = self
246 .get_current_user(req, ctx)
247 .await?
248 .ok_or(AuthError::Unauthenticated)?;
249 let meta = RequestMeta::from_request_with_config(req, &ctx.config.advanced.ip_address);
250
251 let (response, new_token) =
252 change_password_core(&body, &user, &self.config, &meta, ctx).await?;
253
254 let auth_response = AuthResponse::json(200, &response)?;
255
256 if let Some(data) = new_token {
258 ctx.session_manager()
259 .set_session_cookie(req, data, None)
260 .await?;
261 }
262 Ok(auth_response)
263 }
264
265 async fn handle_verify_password(
266 &self,
267 req: &AuthRequest,
268 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
269 ) -> AuthResult<AuthResponse> {
270 let body = request::verify(req)?;
271
272 let user = self.get_current_user(req, ctx).await?;
273 let Some(user) = user else {
274 return Ok(
277 AuthError::AuthenticationFailed("Unauthorized".to_string()).to_auth_response()
278 );
279 };
280 let response = verify_password_core(&body, &user, ctx).await?;
281 Ok(AuthResponse::json(200, &response)?)
282 }
283
284 async fn handle_reset_password_token(
285 &self,
286 token: &str,
287 req: &AuthRequest,
288 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
289 ) -> AuthResult<AuthResponse> {
290 let query = ResetPasswordTokenQuery {
291 callback_url: req.query_string("callbackURL")?.map(str::to_owned),
292 };
293 match reset_password_token_core(token, &query, ctx).await? {
294 ResetPasswordTokenResult::Redirect(url) => {
295 let mut headers = better_auth_core::Headers::new();
296 let _ = headers.insert("Location".to_string(), url);
297 let _ = headers.insert("content-type".to_string(), "application/json".to_string());
298 let mut response = AuthResponse::new(302);
299 response.headers = headers;
300 Ok(response)
301 }
302 }
303 }
304
305 async fn get_current_user<S: better_auth_core::AuthSchema>(
306 &self,
307 req: &AuthRequest,
308 ctx: &AuthContext<S>,
309 ) -> AuthResult<Option<better_auth_core::wire::UserView>> {
310 let session_manager = ctx.session_manager();
311
312 if let Some(token) = session_manager.extract_session_token(req)
313 && let Some(session) = session_manager.get_session(&token).await?
314 {
315 return ctx
316 .database
317 .get_user_by_id(session.user_id().typed()?)
318 .await;
319 }
320
321 Ok(None)
322 }
323}
324
325#[cfg(test)]
326impl PasswordManagementPlugin {
327 fn validate_password(
328 &self,
329 password: &str,
330 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
331 ) -> AuthResult<()> {
332 better_auth_core::utils::password::validate_password(
333 password,
334 ctx.config.password.min_length,
335 ctx.config.password.max_length,
336 ctx,
337 )
338 }
339
340 async fn hash_password(&self, password: &str) -> AuthResult<String> {
341 better_auth_core::utils::password::hash_password(
342 self.config.password_hasher.as_ref(),
343 password,
344 )
345 .await
346 }
347
348 async fn verify_password(&self, password: &str, hash: &str) -> AuthResult<()> {
349 better_auth_core::utils::password::verify_password(
350 self.config.password_hasher.as_ref(),
351 password,
352 hash,
353 )
354 .await
355 }
356}