Skip to main content

authx_plugins/password_reset/
service.rs

1use 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    /// Token received from the reset link.
17    pub token: String,
18    /// The new password the user wants to set.
19    pub new_password: String,
20}
21
22/// Password reset service.
23///
24/// # Flow
25/// 1. App calls `request_reset(email)` → gets a raw token (send it in the
26///    reset email yourself — authx does not send email).
27/// 2. User clicks link → app calls `reset_password(token, new_password)`.
28///
29/// Tokens expire after `ttl` (default 30 minutes) and are single-use.
30pub 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    /// Issue a reset token for the given email.
56    ///
57    /// Always succeeds (even for unknown emails) to avoid user enumeration.
58    /// Returns `None` if the email is not registered — caller should silently
59    /// ignore and not reveal this to the client.
60    #[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    /// Consume the reset token and update the password.
76    #[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        // Verify the new password isn't the same as the current one.
91        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}