Skip to main content

allowthem_core/
password_reset.rs

1use base64ct::{Base64UrlUnpadded, Encoding};
2use chrono::{DateTime, Duration, Utc};
3use rand::TryRngCore;
4use rand::rngs::OsRng;
5use sha2::{Digest, Sha256};
6
7use crate::db::Db;
8use crate::email::{EmailMessage, EmailSender};
9use crate::error::AuthError;
10use crate::password::hash_password;
11use crate::types::{Email, ResetTokenId, UserId};
12
13const RESET_TTL_MINUTES: i64 = 30;
14
15/// Generate a cryptographically random reset token.
16///
17/// Fills 32 bytes from the OS random source and encodes as base64url without
18/// padding, producing a 43-character string suitable for inclusion in a URL.
19fn generate_reset_token() -> String {
20    let mut bytes = [0u8; 32];
21    OsRng
22        .try_fill_bytes(&mut bytes)
23        .expect("OS RNG unavailable");
24    Base64UrlUnpadded::encode_string(&bytes)
25}
26
27/// Hash a raw reset token with SHA-256.
28///
29/// Returns the hex-encoded digest. This is what is stored in the database.
30/// The raw token is only ever sent to the user via email.
31fn hash_reset_token(token: &str) -> String {
32    let digest = Sha256::digest(token.as_bytes());
33    format!("{digest:x}")
34}
35
36impl Db {
37    /// Create a password reset token for the user with the given email.
38    ///
39    /// Looks up the user by email. If found, inserts a new reset token record
40    /// (hashed) and returns the raw token for inclusion in the reset URL.
41    /// Returns `None` if no user exists for that email (caller should not
42    /// reveal this to prevent email enumeration).
43    pub async fn create_password_reset(&self, email: &Email) -> Result<Option<String>, AuthError> {
44        let user = match self.get_user_by_email(email).await {
45            Ok(u) => u,
46            Err(AuthError::NotFound) => return Ok(None),
47            Err(e) => return Err(e),
48        };
49
50        let raw_token = generate_reset_token();
51        let token_hash = hash_reset_token(&raw_token);
52        let id = ResetTokenId::new();
53        let expires_at = Utc::now() + Duration::minutes(RESET_TTL_MINUTES);
54        let expires_at_str = expires_at.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
55
56        sqlx::query(
57            "INSERT INTO allowthem_password_reset_tokens \
58             (id, user_id, token_hash, expires_at) \
59             VALUES (?, ?, ?, ?)",
60        )
61        .bind(id)
62        .bind(user.id)
63        .bind(&token_hash)
64        .bind(&expires_at_str)
65        .execute(self.pool())
66        .await
67        .map_err(AuthError::Database)?;
68
69        Ok(Some(raw_token))
70    }
71
72    /// Validate a raw reset token.
73    ///
74    /// Hashes the token and looks it up in the database. Returns the associated
75    /// `UserId` and token record ID if the token exists, has not expired, and has
76    /// not been used. Returns `None` if the token is invalid or expired.
77    pub async fn validate_reset_token(
78        &self,
79        raw_token: &str,
80    ) -> Result<Option<(ResetTokenId, UserId)>, AuthError> {
81        let token_hash = hash_reset_token(raw_token);
82        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
83
84        let row: Option<(ResetTokenId, UserId)> = sqlx::query_as(
85            "SELECT id, user_id FROM allowthem_password_reset_tokens \
86             WHERE token_hash = ? AND expires_at > ? AND used_at IS NULL",
87        )
88        .bind(&token_hash)
89        .bind(&now)
90        .fetch_optional(self.pool())
91        .await
92        .map_err(AuthError::Database)?;
93
94        Ok(row)
95    }
96
97    /// Execute a password reset: update the password and mark the token used.
98    ///
99    /// Runs atomically in a transaction:
100    /// 1. Validate the token (not expired, not used).
101    /// 2. Mark the token as used (`used_at = now`).
102    /// 3. Hash the new password.
103    /// 4. Update the user's `password_hash` and `updated_at`.
104    ///
105    /// Returns `Ok(true)` on success, `Ok(false)` if the token was invalid.
106    pub async fn execute_reset(
107        &self,
108        raw_token: &str,
109        new_password: &str,
110    ) -> Result<bool, AuthError> {
111        let mut tx = self.pool().begin().await.map_err(AuthError::Database)?;
112
113        let token_hash = hash_reset_token(raw_token);
114        let now = Utc::now();
115        let now_str = now.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
116
117        // Step 1 & 2: fetch and mark used atomically within the transaction.
118        let row: Option<(ResetTokenId, UserId)> = sqlx::query_as(
119            "SELECT id, user_id FROM allowthem_password_reset_tokens \
120             WHERE token_hash = ? AND expires_at > ? AND used_at IS NULL",
121        )
122        .bind(&token_hash)
123        .bind(&now_str)
124        .fetch_optional(&mut *tx)
125        .await
126        .map_err(AuthError::Database)?;
127
128        let (token_id, user_id) = match row {
129            None => return Ok(false),
130            Some(r) => r,
131        };
132
133        sqlx::query("UPDATE allowthem_password_reset_tokens SET used_at = ? WHERE id = ?")
134            .bind(&now_str)
135            .bind(token_id)
136            .execute(&mut *tx)
137            .await
138            .map_err(AuthError::Database)?;
139
140        // Step 3 & 4: hash password and update user.
141        let pw_hash = hash_password(new_password)?;
142
143        sqlx::query("UPDATE allowthem_users SET password_hash = ?, updated_at = ? WHERE id = ?")
144            .bind(pw_hash)
145            .bind(&now_str)
146            .bind(user_id)
147            .execute(&mut *tx)
148            .await
149            .map_err(AuthError::Database)?;
150
151        tx.commit().await.map_err(AuthError::Database)?;
152
153        Ok(true)
154    }
155
156    /// Send a password reset email for the given address.
157    ///
158    /// Calls `create_password_reset` to generate a token. If the email exists,
159    /// constructs a reset URL using `base_url` and delivers it via `sender`.
160    /// If the email does not exist, returns `Ok(())` silently (no enumeration).
161    pub async fn send_password_reset(
162        &self,
163        email: &Email,
164        base_url: &str,
165        sender: &dyn EmailSender,
166    ) -> Result<(), AuthError> {
167        let raw_token = match self.create_password_reset(email).await? {
168            None => return Ok(()),
169            Some(t) => t,
170        };
171
172        let reset_url = format!("{}/auth/reset-password?token={}", base_url, raw_token);
173        let body = format!(
174            "You requested a password reset. Click the link below to set a new password:\n\n{}\n\nThis link expires in {} minutes.",
175            reset_url, RESET_TTL_MINUTES,
176        );
177        let html = format!(
178            "<p>You requested a password reset. <a href=\"{}\">Click here to set a new password</a>.</p><p>This link expires in {} minutes.</p>",
179            reset_url, RESET_TTL_MINUTES,
180        );
181
182        let message = EmailMessage {
183            to: email.as_str(),
184            subject: "Reset your password",
185            body: &body,
186            html: Some(&html),
187        };
188
189        sender
190            .send(message)
191            .await
192            .map_err(|e| AuthError::Email(e.to_string()))
193    }
194}
195
196/// Expiry timestamp for a reset token, given a reference time.
197///
198/// Used in tests to avoid hardcoding durations.
199#[allow(dead_code)]
200pub fn reset_expires_at(from: DateTime<Utc>) -> DateTime<Utc> {
201    from + Duration::minutes(RESET_TTL_MINUTES)
202}
203
204#[cfg(test)]
205mod tests {
206    use crate::db::Db;
207    use crate::email::LogEmailSender;
208    use crate::types::Email;
209
210    async fn test_db() -> Db {
211        Db::connect("sqlite::memory:").await.expect("in-memory db")
212    }
213
214    async fn make_user(db: &Db) -> Email {
215        let email = Email::new("reset@example.com".to_string()).unwrap();
216        db.create_user(email.clone(), "initial-password", None)
217            .await
218            .expect("create user");
219        email
220    }
221
222    #[tokio::test]
223    async fn create_reset_returns_token_for_known_email() {
224        let db = test_db().await;
225        let email = make_user(&db).await;
226        let token = db
227            .create_password_reset(&email)
228            .await
229            .expect("create_password_reset");
230        assert!(token.is_some(), "should return a token for a known email");
231        let raw = token.unwrap();
232        assert!(!raw.is_empty(), "token must not be empty");
233    }
234
235    #[tokio::test]
236    async fn create_reset_returns_none_for_unknown_email() {
237        let db = test_db().await;
238        let email = Email::new("nobody@example.com".to_string()).unwrap();
239        let token = db
240            .create_password_reset(&email)
241            .await
242            .expect("create_password_reset");
243        assert!(token.is_none(), "should return None for unknown email");
244    }
245
246    #[tokio::test]
247    async fn validate_reset_token_returns_ids_for_valid_token() {
248        let db = test_db().await;
249        let email = make_user(&db).await;
250        let raw = db
251            .create_password_reset(&email)
252            .await
253            .expect("create")
254            .unwrap();
255        let result = db.validate_reset_token(&raw).await.expect("validate");
256        assert!(result.is_some(), "valid token must return Some");
257    }
258
259    #[tokio::test]
260    async fn validate_reset_token_returns_none_for_garbage() {
261        let db = test_db().await;
262        let _ = make_user(&db).await;
263        let result = db
264            .validate_reset_token("not-a-real-token")
265            .await
266            .expect("validate");
267        assert!(result.is_none(), "invalid token must return None");
268    }
269
270    #[tokio::test]
271    async fn execute_reset_changes_password_and_marks_token_used() {
272        let db = test_db().await;
273        let email = make_user(&db).await;
274        let raw = db
275            .create_password_reset(&email)
276            .await
277            .expect("create")
278            .unwrap();
279
280        let success = db
281            .execute_reset(&raw, "new-secure-password")
282            .await
283            .expect("execute_reset");
284        assert!(success, "execute_reset must return true on success");
285
286        // Token is now used — a second attempt must fail.
287        let again = db
288            .execute_reset(&raw, "another-password")
289            .await
290            .expect("second execute_reset");
291        assert!(!again, "used token must not be reusable");
292
293        // Verify the new password works for login.
294        let user = db
295            .find_for_login("reset@example.com")
296            .await
297            .expect("find_for_login");
298        let valid = crate::password::verify_password(
299            "new-secure-password",
300            user.password_hash.as_ref().unwrap(),
301        )
302        .expect("verify");
303        assert!(valid, "new password must verify correctly");
304    }
305
306    #[tokio::test]
307    async fn send_password_reset_succeeds_for_known_email() {
308        let db = test_db().await;
309        let email = make_user(&db).await;
310        let sender = LogEmailSender;
311        let result = db
312            .send_password_reset(&email, "https://example.com", &sender)
313            .await;
314        assert!(
315            result.is_ok(),
316            "send_password_reset must succeed for known email"
317        );
318    }
319
320    #[tokio::test]
321    async fn send_password_reset_is_silent_for_unknown_email() {
322        let db = test_db().await;
323        let email = Email::new("ghost@example.com".to_string()).unwrap();
324        let sender = LogEmailSender;
325        let result = db
326            .send_password_reset(&email, "https://example.com", &sender)
327            .await;
328        assert!(
329            result.is_ok(),
330            "send_password_reset must not error for unknown email"
331        );
332    }
333}