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::error::AuthError;
9use crate::password::hash_password;
10use crate::types::{Email, ResetTokenId, UserId};
11
12const RESET_TTL_MINUTES: i64 = 30;
13
14fn 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
26fn hash_reset_token(token: &str) -> String {
31 let digest = Sha256::digest(token.as_bytes());
32 format!("{digest:x}")
33}
34
35impl Db {
36 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 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 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 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 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 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#[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 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 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}