Skip to main content

max / makenotwork

19.9 KB · 540 lines History Blame Raw
1 //! Adversarial authentication & session tests.
2 //!
3 //! Focus: Authentication & session handling (Option C from adversarial.md).
4 //! Tests auth boundaries, session lifecycle, lockout, 2FA state, and anti-replay.
5 //! Tests that PASS prove the app correctly enforces auth boundaries.
6 //! Tests that FAIL have found a real bug.
7
8 use crate::harness::TestHarness;
9
10 // =============================================================================
11 // Session lifecycle
12 // =============================================================================
13
14 /// Vulnerability tested: Stale session after logout allows actions.
15 /// After logout, the session cookie should be invalidated. Any attempt to
16 /// use the old session for write operations should be rejected.
17 #[tokio::test]
18 async fn stale_session_after_logout() {
19 let mut h = TestHarness::new().await;
20 let user_id = h.signup("staleuser", "stale@test.com", "password123").await;
21 h.grant_creator(user_id).await;
22 h.client.post_form("/logout", "").await;
23 h.login("staleuser", "password123").await;
24
25 // Verify we can access the API while logged in
26 let resp = h.client.get("/api/projects").await;
27 assert!(resp.status.is_success(), "Should have API access while logged in");
28
29 // Logout
30 h.client.post_form("/logout", "").await;
31
32 // Try to create a project with the stale session
33 let resp = h
34 .client
35 .post_form("/api/projects", "slug=stale-project&title=Stale+Project")
36 .await;
37 assert!(
38 resp.status == 401 || resp.status == 403 || resp.status.is_redirection(),
39 "Stale session should not allow project creation: {} {}",
40 resp.status, resp.text
41 );
42
43 // Try to list projects (read operation)
44 let resp = h.client.get("/api/projects").await;
45 assert!(
46 resp.status == 401 || resp.status == 403 || resp.status.is_redirection(),
47 "Stale session should not allow reading user's projects: {} {}",
48 resp.status, resp.text
49 );
50
51 // Try to access dashboard
52 let resp = h.client.get("/dashboard").await;
53 assert!(
54 resp.status == 401 || resp.status.is_redirection(),
55 "Stale session should not allow dashboard access: {} {}",
56 resp.status, resp.text
57 );
58 }
59
60 /// Vulnerability tested: Unauthenticated API access.
61 /// All /api/* endpoints that manage user resources require authentication.
62 /// A fresh client with no session should be rejected.
63 #[tokio::test]
64 async fn unauthenticated_api_access_rejected() {
65 let mut h = TestHarness::new().await;
66
67 // Fetch CSRF token to establish a session (but don't log in)
68 h.client.fetch_csrf_token().await;
69
70 // Try various API endpoints without authentication
71 let endpoints = vec![
72 ("GET", "/api/projects"),
73 ("GET", "/api/promo-codes"),
74 ];
75
76 for (method, path) in &endpoints {
77 let resp = if *method == "GET" {
78 h.client.get(path).await
79 } else {
80 h.client.post_form(path, "").await
81 };
82 assert!(
83 resp.status == 401 || resp.status == 403 || resp.status.is_redirection(),
84 "Unauthenticated {} {} should be rejected: {} {}",
85 method, path, resp.status, resp.text
86 );
87 }
88
89 // Try write operations
90 let resp = h
91 .client
92 .post_form("/api/projects", "slug=unauth&title=Unauth")
93 .await;
94 assert!(
95 resp.status == 401 || resp.status == 403 || resp.status.is_redirection(),
96 "Unauthenticated project creation should be rejected: {} {}",
97 resp.status, resp.text
98 );
99
100 let resp = h
101 .client
102 .put_form("/api/users/me", "display_name=Hacker")
103 .await;
104 assert!(
105 resp.status == 401 || resp.status == 403 || resp.status.is_redirection(),
106 "Unauthenticated profile update should be rejected: {} {}",
107 resp.status, resp.text
108 );
109 }
110
111 // =============================================================================
112 // Login lockout
113 // =============================================================================
114
115 /// Vulnerability tested: Account lockout after repeated failed logins.
116 /// After MAX_LOGIN_ATTEMPTS (5) failed password attempts, the account should
117 /// be locked for LOCKOUT_MINUTES (15). Even the correct password should be
118 /// rejected during lockout.
119 #[tokio::test]
120 async fn login_lockout_after_five_failures() {
121 let mut h = TestHarness::new().await;
122 let _user_id = h.signup("lockuser", "lockuser@test.com", "correctpass1").await;
123 h.client.post_form("/logout", "").await;
124
125 // Attempt 5 failed logins with wrong password
126 for i in 1..=5 {
127 let resp = h.failed_login_attempt("lockuser", "wrongpassword").await;
128 // First 4 attempts: "Invalid username/email or password"
129 // 5th attempt: triggers lockout
130 assert!(
131 resp.status.is_client_error() || resp.text.contains("Invalid") || resp.text.contains("locked"),
132 "Failed login attempt {} should return error: {} {}",
133 i, resp.status, resp.text
134 );
135 }
136
137 // Now try with the CORRECT password — should still be locked
138 let resp = h.failed_login_attempt("lockuser", "correctpass1").await;
139 assert!(
140 resp.status.is_client_error() || resp.text.contains("locked") || resp.text.contains("Account is locked"),
141 "Correct password during lockout should be rejected: {} {}",
142 resp.status, resp.text
143 );
144
145 // Verify we're NOT logged in
146 let resp = h.client.get("/dashboard").await;
147 assert!(
148 resp.status == 401 || resp.status.is_redirection(),
149 "Should not be logged in during lockout: {} {}",
150 resp.status, resp.text
151 );
152 }
153
154 // =============================================================================
155 // 2FA state boundary
156 // =============================================================================
157
158 /// Vulnerability tested: 2FA pending state allows API access.
159 /// After password login with TOTP enabled, the session has a pending_2fa_user_id
160 /// but no authenticated "user". The AuthUser extractor should reject API access
161 /// until 2FA is completed.
162 #[tokio::test]
163 async fn two_factor_pending_blocks_api() {
164 let mut h = TestHarness::new().await;
165 let user_id = h.signup("twofa", "twofa@test.com", "password123").await;
166 h.grant_creator(user_id).await;
167
168 // Enable TOTP
169 let resp = h.client.post_form("/api/users/me/totp/setup", "").await;
170 assert!(resp.status.is_success(), "TOTP setup failed: {}", resp.text);
171
172 // Extract secret from HTML response (inside <details> > <code>)
173 let details_start = resp.text.find("<details").expect("No <details> in TOTP setup HTML");
174 let details_html = &resp.text[details_start..];
175 let code_start = details_html.find("<code").expect("No <code> in details");
176 let after_tag = &details_html[code_start..];
177 let content_start = after_tag.find('>').expect("No > after <code") + 1;
178 let content_end = after_tag[content_start..]
179 .find("</code>")
180 .expect("No </code>");
181 let secret = &after_tag[content_start..content_start + content_end];
182
183 let bytes = totp_rs::Secret::Encoded(secret.to_string())
184 .to_bytes()
185 .expect("Invalid TOTP secret");
186 let totp = totp_rs::TOTP::new(
187 totp_rs::Algorithm::SHA1,
188 6,
189 1,
190 30,
191 bytes,
192 Some("Makenotwork".into()),
193 "twofa@test.com".into(),
194 )
195 .unwrap();
196 let code = totp.generate_current().unwrap();
197
198 // Confirm TOTP
199 let resp = h
200 .client
201 .post_form("/api/users/me/totp/confirm", &format!("code={}", code))
202 .await;
203 assert!(resp.status.is_success(), "TOTP confirm failed: {} {}", resp.status, resp.text);
204
205 // Logout
206 h.client.post_form("/logout", "").await;
207
208 // Login with correct password — should redirect to 2FA page (not complete login)
209 let resp = h.failed_login_attempt("twofa", "password123").await;
210 // The response should indicate 2FA is needed (redirect to /auth/2fa or /auth/verify-2fa)
211 assert!(
212 resp.status.is_redirection()
213 || resp.status.is_success()
214 || resp.text.contains("2fa")
215 || resp.text.contains("verify"),
216 "Login with TOTP enabled should require 2FA: {} {}",
217 resp.status, resp.text
218 );
219
220 // NOW try to access protected API endpoints — should fail because
221 // session has pending_2fa but no authenticated user
222 let resp = h.client.get("/api/projects").await;
223 assert!(
224 resp.status == 401 || resp.status == 403 || resp.status.is_redirection(),
225 "API access during 2FA pending should be rejected: {} {}",
226 resp.status, resp.text
227 );
228
229 let resp = h
230 .client
231 .post_form("/api/projects", "slug=pending-hack&title=Hack")
232 .await;
233 assert!(
234 resp.status == 401 || resp.status == 403 || resp.status.is_redirection(),
235 "Project creation during 2FA pending should be rejected: {} {}",
236 resp.status, resp.text
237 );
238 }
239
240 // =============================================================================
241 // Login link anti-replay
242 // =============================================================================
243
244 /// Vulnerability tested: Login link token replay.
245 /// One-time login tokens should be consumed atomically on first use.
246 /// Second use of the same token should fail.
247 #[tokio::test]
248 async fn login_link_replay_rejected() {
249 let mut h = TestHarness::new().await;
250 let user_id = h.signup("replay", "replay@test.com", "password123").await;
251
252 // Generate a one-time login token
253 let (token, token_hash) = makenotwork::email::generate_login_token();
254
255 // Insert token into DB
256 let expires_at = chrono::Utc::now() + chrono::Duration::minutes(15);
257 sqlx::query("INSERT INTO login_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)")
258 .bind(user_id)
259 .bind(&token_hash)
260 .bind(expires_at)
261 .execute(&h.db)
262 .await
263 .expect("Failed to insert login token");
264
265 // Logout
266 h.client.post_form("/logout", "").await;
267
268 // First use — should succeed
269 let resp = h
270 .client
271 .get(&format!("/login-link?token={}", token))
272 .await;
273 assert!(
274 resp.status.is_success() || resp.status.is_redirection(),
275 "First login link use should succeed: {} {}",
276 resp.status, resp.text
277 );
278
279 // Verify we're logged in
280 let resp = h.client.get("/dashboard").await;
281 assert_eq!(resp.status, 200, "Should be logged in after first use");
282
283 // Logout and try the same token again
284 h.client.post_form("/logout", "").await;
285
286 let resp = h
287 .client
288 .get(&format!("/login-link?token={}", token))
289 .await;
290 // Second use should fail — token was consumed
291 // Could be 400, 401, redirect to login, or error page
292 let is_rejected = resp.status.is_client_error()
293 || resp.text.contains("invalid")
294 || resp.text.contains("Invalid")
295 || resp.text.contains("expired")
296 || resp.text.contains("error");
297 assert!(
298 is_rejected || resp.status.is_redirection(),
299 "Login link replay should be rejected: {} {}",
300 resp.status, resp.text
301 );
302
303 // Verify we're NOT logged in after replay attempt
304 let resp = h.client.get("/dashboard").await;
305 assert!(
306 resp.status.is_redirection() || resp.status == 401,
307 "Should not be logged in after replayed login link: {} {}",
308 resp.status, resp.text
309 );
310 }
311
312 // =============================================================================
313 // User enumeration prevention
314 // =============================================================================
315
316 /// Vulnerability tested: Login error leaks whether username exists.
317 /// The error message for a non-existent user should be identical to the
318 /// error for a wrong password, preventing user enumeration.
319 #[tokio::test]
320 async fn login_no_user_enumeration() {
321 let mut h = TestHarness::new().await;
322 let _user_id = h.signup("realuser", "realuser@test.com", "password123").await;
323 h.client.post_form("/logout", "").await;
324
325 // Wrong password for existing user
326 let resp_wrong_pass = h
327 .client
328 .post_form("/login", "login=realuser&password=wrongpassword")
329 .await;
330
331 // Non-existent user
332 let resp_no_user = h
333 .client
334 .post_form("/login", "login=doesnotexist&password=anypassword")
335 .await;
336
337 // Both should return the same status code
338 assert_eq!(
339 resp_wrong_pass.status, resp_no_user.status,
340 "Wrong password ({}) and non-existent user ({}) should return same status",
341 resp_wrong_pass.status, resp_no_user.status
342 );
343
344 // Both should contain the same generic error message (not "user not found")
345 assert!(
346 !resp_no_user.text.contains("not found")
347 && !resp_no_user.text.contains("does not exist")
348 && !resp_no_user.text.contains("no account"),
349 "Non-existent user error should not reveal user doesn't exist: {}",
350 resp_no_user.text
351 );
352 }
353
354 // =============================================================================
355 // Failed login doesn't create session
356 // =============================================================================
357
358 /// Vulnerability tested: Failed login creates an authenticated session.
359 /// After a failed password attempt, the session should NOT contain an
360 /// authenticated user — dashboard should be inaccessible.
361 #[tokio::test]
362 async fn wrong_password_no_session() {
363 let mut h = TestHarness::new().await;
364 let _user_id = h.signup("nosess", "nosess@test.com", "password123").await;
365 h.client.post_form("/logout", "").await;
366
367 // Attempt login with wrong password
368 let _resp = h
369 .client
370 .post_form("/login", "login=nosess&password=wrongpassword")
371 .await;
372
373 // Verify no authenticated session was created
374 let resp = h.client.get("/dashboard").await;
375 assert!(
376 resp.status == 401 || resp.status.is_redirection(),
377 "Dashboard should not be accessible after failed login: {} {}",
378 resp.status, resp.text
379 );
380
381 // Verify API access is also blocked
382 let resp = h.client.get("/api/projects").await;
383 assert!(
384 resp.status == 401 || resp.status == 403 || resp.status.is_redirection(),
385 "API should not be accessible after failed login: {} {}",
386 resp.status, resp.text
387 );
388 }
389
390 // =============================================================================
391 // Empty/malformed credentials
392 // =============================================================================
393
394 /// Vulnerability tested: Empty credentials bypass authentication.
395 /// Login with empty username and/or password should fail cleanly.
396 #[tokio::test]
397 async fn empty_credentials_rejected() {
398 let mut h = TestHarness::new().await;
399 let _user_id = h.signup("emptytest", "emptytest@test.com", "password123").await;
400 h.client.post_form("/logout", "").await;
401
402 // Empty password
403 let resp = h
404 .client
405 .post_form("/login", "login=emptytest&password=")
406 .await;
407 assert!(
408 resp.status.is_client_error() || resp.text.contains("Invalid"),
409 "Empty password should be rejected: {} {}",
410 resp.status, resp.text
411 );
412
413 // Empty username
414 let resp = h.client.post_form("/login", "login=&password=password123").await;
415 assert!(
416 resp.status.is_client_error() || resp.text.contains("Invalid"),
417 "Empty username should be rejected: {} {}",
418 resp.status, resp.text
419 );
420
421 // Both empty
422 let resp = h.client.post_form("/login", "login=&password=").await;
423 assert!(
424 resp.status.is_client_error() || resp.text.contains("Invalid"),
425 "Both empty should be rejected: {} {}",
426 resp.status, resp.text
427 );
428
429 // Verify no session was created
430 let resp = h.client.get("/dashboard").await;
431 assert!(
432 resp.status == 401 || resp.status.is_redirection(),
433 "Should not be logged in after empty credentials: {} {}",
434 resp.status, resp.text
435 );
436 }
437
438 // =============================================================================
439 // Suspended user enforcement
440 // =============================================================================
441
442 /// Vulnerability tested: Suspended user bypasses write restrictions.
443 /// A suspended user can still log in and read, but all write operations
444 /// (create/update/delete) should be blocked with 403.
445 #[tokio::test]
446 async fn suspended_user_login_ok_writes_blocked() {
447 let mut h = TestHarness::new().await;
448 let user_id = h.signup("susptest", "susptest@test.com", "password123").await;
449 h.grant_creator(user_id).await;
450
451 // Suspend the user
452 makenotwork::db::users::suspend_user(&h.db, user_id, "adversarial test").await.unwrap();
453
454 // Logout and re-login — login should still work
455 h.client.post_form("/logout", "").await;
456 h.login("susptest", "password123").await;
457
458 // Read operations should work
459 let resp = h.client.get("/dashboard").await;
460 assert_eq!(
461 resp.status, 200,
462 "Suspended user should access dashboard: {} {}",
463 resp.status, resp.text
464 );
465
466 // Write operations should be blocked
467 let resp = h
468 .client
469 .post_form("/api/projects", "slug=susp-project&title=Suspended")
470 .await;
471 assert_eq!(
472 resp.status, 403,
473 "Suspended user should not create projects: {} {}",
474 resp.status, resp.text
475 );
476
477 // Profile updates (`PUT /api/users/me`) are now blocked for suspended
478 // users — the `check_not_suspended()` guard was extended to profile and
479 // synckit billing mutations to keep account self-management consistent
480 // with the rest of the suspension policy (commit 78dda3d).
481 let resp = h
482 .client
483 .put_form("/api/users/me", "display_name=Updated+Name")
484 .await;
485 assert_eq!(
486 resp.status, 403,
487 "Suspended user profile update should be blocked: {} {}",
488 resp.status, resp.text
489 );
490 }
491
492 // =============================================================================
493 // Password change invalidates old password
494 // =============================================================================
495
496 /// Vulnerability tested: Old password still works after password change.
497 /// After changing the password, login with the old password should fail.
498 #[tokio::test]
499 async fn old_password_rejected_after_change() {
500 let mut h = TestHarness::new().await;
501 let _user_id = h.signup("oldpw", "oldpw@test.com", "oldpassword1").await;
502
503 // Change password
504 let resp = h
505 .client
506 .put_form(
507 "/api/users/me/password",
508 "current_password=oldpassword1&new_password=newpassword1",
509 )
510 .await;
511 assert!(resp.status.is_success(), "Password change failed: {}", resp.text);
512
513 // Logout
514 h.client.post_form("/logout", "").await;
515
516 // Try to login with old password — should fail
517 let resp = h
518 .client
519 .post_form("/login", "login=oldpw&password=oldpassword1")
520 .await;
521 assert!(
522 resp.status.is_client_error() || resp.text.contains("Invalid"),
523 "Old password should be rejected after change: {} {}",
524 resp.status, resp.text
525 );
526
527 // Verify not logged in
528 let resp = h.client.get("/dashboard").await;
529 assert!(
530 resp.status == 401 || resp.status.is_redirection(),
531 "Should not be logged in with old password: {} {}",
532 resp.status, resp.text
533 );
534
535 // Login with new password should work
536 h.login("oldpw", "newpassword1").await;
537 let resp = h.client.get("/dashboard").await;
538 assert_eq!(resp.status, 200, "New password should work");
539 }
540