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