authx_plugins/password_reset/
service.rs1use std::time::Duration;
2
3use tracing::instrument;
4
5use authx_core::{
6 crypto::{hash_password, verify_password},
7 error::{AuthError, Result},
8 events::{AuthEvent, EventBus},
9 models::{CreateCredential, CredentialKind},
10};
11use authx_storage::ports::{CredentialRepository, UserRepository};
12
13use crate::one_time_token::{OneTimeTokenStore, TokenKind};
14
15pub struct PasswordResetRequest {
16 pub token: String,
18 pub new_password: String,
20}
21
22pub struct PasswordResetService<S> {
31 storage: S,
32 events: EventBus,
33 token_store: OneTimeTokenStore,
34 min_pass_len: usize,
35}
36
37impl<S> PasswordResetService<S>
38where
39 S: UserRepository + CredentialRepository + Clone + Send + Sync + 'static,
40{
41 pub fn new(storage: S, events: EventBus) -> Self {
42 Self {
43 storage,
44 events,
45 token_store: OneTimeTokenStore::new(Duration::from_secs(30 * 60)),
46 min_pass_len: 8,
47 }
48 }
49
50 pub fn with_ttl(mut self, ttl: Duration) -> Self {
51 self.token_store = OneTimeTokenStore::new(ttl);
52 self
53 }
54
55 #[instrument(skip(self), fields(email = %email))]
61 pub async fn request_reset(&self, email: &str) -> Result<Option<String>> {
62 let user = match UserRepository::find_by_email(&self.storage, email).await? {
63 Some(u) => u,
64 None => {
65 tracing::debug!("password reset requested for unknown email");
66 return Ok(None);
67 }
68 };
69
70 let token = self.token_store.issue(user.id, TokenKind::PasswordReset);
71 tracing::info!(user_id = %user.id, "password reset token issued");
72 Ok(Some(token))
73 }
74
75 #[instrument(skip(self, req))]
77 pub async fn reset_password(&self, req: PasswordResetRequest) -> Result<()> {
78 if req.new_password.len() < self.min_pass_len {
79 return Err(AuthError::Internal(format!(
80 "password must be at least {} characters",
81 self.min_pass_len
82 )));
83 }
84
85 let user_id = self
86 .token_store
87 .consume(&req.token, TokenKind::PasswordReset)
88 .ok_or(AuthError::InvalidToken)?;
89
90 if let Some(old_hash) =
92 CredentialRepository::find_password_hash(&self.storage, user_id).await?
93 {
94 if verify_password(&old_hash, &req.new_password)? {
95 return Err(AuthError::Internal(
96 "new password must differ from current".into(),
97 ));
98 }
99 CredentialRepository::delete_by_user_and_kind(
100 &self.storage,
101 user_id,
102 CredentialKind::Password,
103 )
104 .await?;
105 }
106
107 let new_hash = hash_password(&req.new_password)?;
108 CredentialRepository::create(
109 &self.storage,
110 CreateCredential {
111 user_id,
112 kind: CredentialKind::Password,
113 credential_hash: new_hash,
114 metadata: None,
115 },
116 )
117 .await?;
118
119 self.events.emit(AuthEvent::PasswordChanged { user_id });
120 tracing::info!(user_id = %user_id, "password reset complete");
121 Ok(())
122 }
123}