allowthem_core/
password_reset.rs1use 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
15fn 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
27fn hash_reset_token(token: &str) -> String {
32 let digest = Sha256::digest(token.as_bytes());
33 format!("{digest:x}")
34}
35
36impl Db {
37 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 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 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 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 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 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#[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 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 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}