Skip to main content

max / makenotwork

7.6 KB · 237 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_eq!(
45 resp.status, 303,
46 "Forgot password failed: {} {}",
47 resp.status, resp.text
48 );
49
50 let token = issue_reset_token(&h.db, user_id, in_one_hour()).await;
51 let url = format!("/reset-password?token={token}");
52
53 // GET the reset page, valid token shows the form.
54 let resp = h.client.get(&url).await;
55 assert_eq!(
56 resp.status, 200,
57 "Reset page failed: {} {}",
58 resp.status, resp.text
59 );
60
61 // POST the new password.
62 let body = format!(
63 "token={}&password=newpassword1&password_confirm=newpassword1",
64 urlencoding::encode(&token),
65 );
66 let resp = h.client.post_form("/reset-password", &body).await;
67 assert_eq!(
68 resp.status, 303,
69 "Reset password POST failed: {} {}",
70 resp.status, resp.text
71 );
72
73 // Logout and log in with the new password.
74 h.client.post_form("/logout", "").await;
75 h.login("resetuser", "newpassword1").await;
76 let resp = h.client.get("/dashboard").await;
77 assert_eq!(
78 resp.status, 200,
79 "Should access dashboard after password reset"
80 );
81 }
82
83 /// The fix for the SERIOUS replay finding: a reset token works exactly once.
84 #[tokio::test]
85 async fn password_reset_token_is_single_use() {
86 let mut h = TestHarness::new().await;
87 let user_id = h
88 .signup("replayuser", "replay@test.com", "oldpassword1")
89 .await;
90
91 let token = issue_reset_token(&h.db, user_id, in_one_hour()).await;
92 let body = format!(
93 "token={}&password=newpassword1&password_confirm=newpassword1",
94 urlencoding::encode(&token),
95 );
96
97 // First use succeeds.
98 let resp = h.client.post_form("/reset-password", &body).await;
99 assert_eq!(
100 resp.status, 303,
101 "First reset should succeed: {} {}",
102 resp.status, resp.text
103 );
104
105 // Second use of the SAME token must be rejected, the row is consumed.
106 let resp = h.client.post_form("/reset-password", &body).await;
107 assert!(
108 !resp.status.is_redirection(),
109 "Replayed token must not redirect to login, got {}",
110 resp.status
111 );
112 assert!(
113 resp.text.to_lowercase().contains("expired")
114 || resp.text.to_lowercase().contains("used")
115 || resp.text.to_lowercase().contains("invalid"),
116 "Replayed token should be reported expired/used/invalid: {}",
117 resp.text
118 );
119
120 // And changing the password a third way (to prove the second POST really
121 // didn't take), logging in with the *second* attempt's password fails is
122 // implicit; here we confirm the first password still works.
123 h.client.post_form("/logout", "").await;
124 h.login("replayuser", "newpassword1").await;
125 let resp = h.client.get("/dashboard").await;
126 assert_eq!(
127 resp.status, 200,
128 "First reset's password must be the one in effect"
129 );
130 }
131
132 #[tokio::test]
133 async fn password_reset_expired_link() {
134 let mut h = TestHarness::new().await;
135 let user_id = h.signup("expuser", "exp@test.com", "password123").await;
136
137 // Token whose stored expiry is already in the past.
138 let token = issue_reset_token(
139 &h.db,
140 user_id,
141 chrono::Utc::now() - chrono::Duration::hours(1),
142 )
143 .await;
144
145 // GET shows the invalid/expired state (still 200).
146 let resp = h
147 .client
148 .get(&format!("/reset-password?token={token}"))
149 .await;
150 assert_eq!(
151 resp.status, 200,
152 "Reset page should return 200 with invalid state: {} {}",
153 resp.status, resp.text
154 );
155
156 // POST with the expired token is rejected.
157 let body = format!(
158 "token={}&password=newpassword1&password_confirm=newpassword1",
159 urlencoding::encode(&token),
160 );
161 let resp = h.client.post_form("/reset-password", &body).await;
162 assert!(
163 !resp.status.is_redirection(),
164 "Expired link should not redirect to login, got {}",
165 resp.status
166 );
167 assert!(
168 resp.text.to_lowercase().contains("expired")
169 || resp.text.to_lowercase().contains("invalid"),
170 "Response should mention expired/invalid: {}",
171 resp.text
172 );
173 }
174
175 #[tokio::test]
176 async fn password_reset_forged_token() {
177 let mut h = TestHarness::new().await;
178 h.signup("forgeuser", "forge@test.com", "password123").await;
179
180 // A token that was never issued.
181 let forged = "0".repeat(64);
182 let body = format!("token={forged}&password=newpassword1&password_confirm=newpassword1");
183 let resp = h.client.post_form("/reset-password", &body).await;
184 assert!(
185 !resp.status.is_redirection(),
186 "Forged token should not redirect to login, got {}",
187 resp.status
188 );
189 assert!(
190 resp.text.to_lowercase().contains("expired")
191 || resp.text.to_lowercase().contains("invalid")
192 || resp.text.to_lowercase().contains("used"),
193 "Response should reject the forged token: {}",
194 resp.text
195 );
196 }
197
198 #[tokio::test]
199 async fn password_reset_passwords_must_match() {
200 let mut h = TestHarness::new().await;
201 let user_id = h
202 .signup("mismatch", "mismatch@test.com", "password123")
203 .await;
204
205 let token = issue_reset_token(&h.db, user_id, in_one_hour()).await;
206
207 // Mismatched confirmation is rejected *before* the token is consumed.
208 let body = format!(
209 "token={}&password=newpassword1&password_confirm=differentpassword",
210 urlencoding::encode(&token),
211 );
212 let resp = h.client.post_form("/reset-password", &body).await;
213 assert!(
214 !resp.status.is_redirection(),
215 "Mismatched passwords should not redirect to login, got {}",
216 resp.status
217 );
218 assert!(
219 resp.text.to_lowercase().contains("match") || resp.text.to_lowercase().contains("do not"),
220 "Response should mention password mismatch: {}",
221 resp.text
222 );
223
224 // The token survived the mismatch (validation precedes consumption): a
225 // correct retry now succeeds.
226 let body = format!(
227 "token={}&password=newpassword1&password_confirm=newpassword1",
228 urlencoding::encode(&token),
229 );
230 let resp = h.client.post_form("/reset-password", &body).await;
231 assert_eq!(
232 resp.status, 303,
233 "Retry after mismatch should succeed: {} {}",
234 resp.status, resp.text
235 );
236 }
237