Skip to main content

max / makenotwork

7.8 KB · 243 lines History Blame Raw
1 //! Password reset: single-use token generation, full flow, expired/forged/
2 //! mismatched cases, and the replay guard (the token is spent exactly once).
3
4 use crate::harness::TestHarness;
5
6 /// Mint a reset token for `user_id`, persist its hash with the given expiry, and
7 /// return the raw token (the value that would be emailed). Mirrors what
8 /// `forgot_password_handler` does, using only the public token API + a direct
9 /// insert so the test doesn't depend on `pub(crate)` db functions.
10 async fn issue_reset_token(
11 pool: &sqlx::PgPool,
12 user_id: makenotwork::db::UserId,
13 expires_at: chrono::DateTime<chrono::Utc>,
14 ) -> String {
15 let (token, token_hash) = makenotwork::email::generate_password_reset_token();
16 sqlx::query(
17 "INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
18 )
19 .bind(user_id)
20 .bind(&token_hash)
21 .bind(expires_at)
22 .execute(pool)
23 .await
24 .expect("insert reset token");
25 token
26 }
27
28 fn in_one_hour() -> chrono::DateTime<chrono::Utc> {
29 chrono::Utc::now() + chrono::Duration::hours(1)
30 }
31
32 #[tokio::test]
33 async fn password_reset_full_flow() {
34 let mut h = TestHarness::new().await;
35 let user_id = h
36 .signup("resetuser", "reset@test.com", "oldpassword1")
37 .await;
38
39 // The forgot-password endpoint always returns success (no enumeration).
40 let resp = h
41 .client
42 .post_form("/forgot-password", "email=reset%40test.com")
43 .await;
44 assert!(
45 resp.status.is_success() || resp.status.is_redirection(),
46 "Forgot password failed: {} {}",
47 resp.status,
48 resp.text
49 );
50
51 let token = issue_reset_token(&h.db, user_id, in_one_hour()).await;
52 let url = format!("/reset-password?token={token}");
53
54 // GET the reset page, valid token shows the form.
55 let resp = h.client.get(&url).await;
56 assert!(
57 resp.status.is_success(),
58 "Reset page failed: {} {}",
59 resp.status,
60 resp.text
61 );
62
63 // POST the new password.
64 let body = format!(
65 "token={}&password=newpassword1&password_confirm=newpassword1",
66 urlencoding::encode(&token),
67 );
68 let resp = h.client.post_form("/reset-password", &body).await;
69 assert!(
70 resp.status.is_success() || resp.status.is_redirection(),
71 "Reset password POST failed: {} {}",
72 resp.status,
73 resp.text
74 );
75
76 // Logout and log in with the new password.
77 h.client.post_form("/logout", "").await;
78 h.login("resetuser", "newpassword1").await;
79 let resp = h.client.get("/dashboard").await;
80 assert_eq!(
81 resp.status, 200,
82 "Should access dashboard after password reset"
83 );
84 }
85
86 /// The fix for the SERIOUS replay finding: a reset token works exactly once.
87 #[tokio::test]
88 async fn password_reset_token_is_single_use() {
89 let mut h = TestHarness::new().await;
90 let user_id = h
91 .signup("replayuser", "replay@test.com", "oldpassword1")
92 .await;
93
94 let token = issue_reset_token(&h.db, user_id, in_one_hour()).await;
95 let body = format!(
96 "token={}&password=newpassword1&password_confirm=newpassword1",
97 urlencoding::encode(&token),
98 );
99
100 // First use succeeds.
101 let resp = h.client.post_form("/reset-password", &body).await;
102 assert!(
103 resp.status.is_success() || resp.status.is_redirection(),
104 "First reset should succeed: {} {}",
105 resp.status,
106 resp.text
107 );
108
109 // Second use of the SAME token must be rejected, the row is consumed.
110 let resp = h.client.post_form("/reset-password", &body).await;
111 assert!(
112 !resp.status.is_redirection(),
113 "Replayed token must not redirect to login, got {}",
114 resp.status
115 );
116 assert!(
117 resp.text.to_lowercase().contains("expired")
118 || resp.text.to_lowercase().contains("used")
119 || resp.text.to_lowercase().contains("invalid"),
120 "Replayed token should be reported expired/used/invalid: {}",
121 resp.text
122 );
123
124 // And changing the password a third way (to prove the second POST really
125 // didn't take), logging in with the *second* attempt's password fails is
126 // implicit; here we confirm the first password still works.
127 h.client.post_form("/logout", "").await;
128 h.login("replayuser", "newpassword1").await;
129 let resp = h.client.get("/dashboard").await;
130 assert_eq!(
131 resp.status, 200,
132 "First reset's password must be the one in effect"
133 );
134 }
135
136 #[tokio::test]
137 async fn password_reset_expired_link() {
138 let mut h = TestHarness::new().await;
139 let user_id = h.signup("expuser", "exp@test.com", "password123").await;
140
141 // Token whose stored expiry is already in the past.
142 let token = issue_reset_token(
143 &h.db,
144 user_id,
145 chrono::Utc::now() - chrono::Duration::hours(1),
146 )
147 .await;
148
149 // GET shows the invalid/expired state (still 200).
150 let resp = h
151 .client
152 .get(&format!("/reset-password?token={token}"))
153 .await;
154 assert!(
155 resp.status.is_success(),
156 "Reset page should return 200 with invalid state: {} {}",
157 resp.status,
158 resp.text
159 );
160
161 // POST with the expired token is rejected.
162 let body = format!(
163 "token={}&password=newpassword1&password_confirm=newpassword1",
164 urlencoding::encode(&token),
165 );
166 let resp = h.client.post_form("/reset-password", &body).await;
167 assert!(
168 !resp.status.is_redirection(),
169 "Expired link should not redirect to login, got {}",
170 resp.status
171 );
172 assert!(
173 resp.text.to_lowercase().contains("expired")
174 || resp.text.to_lowercase().contains("invalid"),
175 "Response should mention expired/invalid: {}",
176 resp.text
177 );
178 }
179
180 #[tokio::test]
181 async fn password_reset_forged_token() {
182 let mut h = TestHarness::new().await;
183 h.signup("forgeuser", "forge@test.com", "password123").await;
184
185 // A token that was never issued.
186 let forged = "0".repeat(64);
187 let body = format!("token={forged}&password=newpassword1&password_confirm=newpassword1");
188 let resp = h.client.post_form("/reset-password", &body).await;
189 assert!(
190 !resp.status.is_redirection(),
191 "Forged token should not redirect to login, got {}",
192 resp.status
193 );
194 assert!(
195 resp.text.to_lowercase().contains("expired")
196 || resp.text.to_lowercase().contains("invalid")
197 || resp.text.to_lowercase().contains("used"),
198 "Response should reject the forged token: {}",
199 resp.text
200 );
201 }
202
203 #[tokio::test]
204 async fn password_reset_passwords_must_match() {
205 let mut h = TestHarness::new().await;
206 let user_id = h
207 .signup("mismatch", "mismatch@test.com", "password123")
208 .await;
209
210 let token = issue_reset_token(&h.db, user_id, in_one_hour()).await;
211
212 // Mismatched confirmation is rejected *before* the token is consumed.
213 let body = format!(
214 "token={}&password=newpassword1&password_confirm=differentpassword",
215 urlencoding::encode(&token),
216 );
217 let resp = h.client.post_form("/reset-password", &body).await;
218 assert!(
219 !resp.status.is_redirection(),
220 "Mismatched passwords should not redirect to login, got {}",
221 resp.status
222 );
223 assert!(
224 resp.text.to_lowercase().contains("match") || resp.text.to_lowercase().contains("do not"),
225 "Response should mention password mismatch: {}",
226 resp.text
227 );
228
229 // The token survived the mismatch (validation precedes consumption): a
230 // correct retry now succeeds.
231 let body = format!(
232 "token={}&password=newpassword1&password_confirm=newpassword1",
233 urlencoding::encode(&token),
234 );
235 let resp = h.client.post_form("/reset-password", &body).await;
236 assert!(
237 resp.status.is_success() || resp.status.is_redirection(),
238 "Retry after mismatch should succeed: {} {}",
239 resp.status,
240 resp.text
241 );
242 }
243