Skip to main content

max / makenotwork

22.7 KB · 688 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 // Session lifecycle
11
12 /// Vulnerability tested: Stale session after logout allows actions.
13 /// After logout, the session cookie should be invalidated. Any attempt to
14 /// use the old session for write operations should be rejected.
15 #[tokio::test]
16 async fn stale_session_after_logout() {
17 let mut h = TestHarness::new().await;
18 let user_id = h.signup("staleuser", "stale@test.com", "password123").await;
19 h.grant_creator(user_id).await;
20 h.client.post_form("/logout", "").await;
21 h.login("staleuser", "password123").await;
22
23 // Verify we can access the API while logged in
24 let resp = h.client.get("/api/projects").await;
25 assert!(
26 resp.status.is_success(),
27 "Should have API access while logged in"
28 );
29
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,
41 resp.text
42 );
43
44 // Try to list projects (read operation)
45 let resp = h.client.get("/api/projects").await;
46 assert!(
47 resp.status == 401 || resp.status == 403 || resp.status.is_redirection(),
48 "Stale session should not allow reading user's projects: {} {}",
49 resp.status,
50 resp.text
51 );
52
53 // Try to access dashboard
54 let resp = h.client.get("/dashboard").await;
55 assert!(
56 resp.status == 401 || resp.status.is_redirection(),
57 "Stale session should not allow dashboard access: {} {}",
58 resp.status,
59 resp.text
60 );
61 }
62
63 /// Vulnerability tested: Unauthenticated API access.
64 /// All /api/* endpoints that manage user resources require authentication.
65 /// A fresh client with no session should be rejected.
66 #[tokio::test]
67 async fn unauthenticated_api_access_rejected() {
68 let mut h = TestHarness::new().await;
69
70 // Fetch CSRF token to establish a session (but don't log in)
71 h.client.fetch_csrf_token().await;
72
73 // Try various API endpoints without authentication
74 let endpoints = vec![("GET", "/api/projects"), ("GET", "/api/promo-codes")];
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,
86 path,
87 resp.status,
88 resp.text
89 );
90 }
91
92 // Try write operations
93 let resp = h
94 .client
95 .post_form("/api/projects", "slug=unauth&title=Unauth")
96 .await;
97 assert!(
98 resp.status == 401 || resp.status == 403 || resp.status.is_redirection(),
99 "Unauthenticated project creation should be rejected: {} {}",
100 resp.status,
101 resp.text
102 );
103
104 let resp = h
105 .client
106 .put_form("/api/users/me", "display_name=Hacker")
107 .await;
108 assert!(
109 resp.status == 401 || resp.status == 403 || resp.status.is_redirection(),
110 "Unauthenticated profile update should be rejected: {} {}",
111 resp.status,
112 resp.text
113 );
114 }
115
116 // Login lockout
117
118 /// Vulnerability tested: Account lockout after repeated failed logins.
119 /// After MAX_LOGIN_ATTEMPTS (5) failed password attempts, the account should
120 /// be locked for LOCKOUT_MINUTES (15). Even the correct password should be
121 /// rejected during lockout.
122 #[tokio::test]
123 async fn login_lockout_after_five_failures() {
124 let mut h = TestHarness::new().await;
125 let _user_id = h
126 .signup("lockuser", "lockuser@test.com", "correctpass1")
127 .await;
128 h.client.post_form("/logout", "").await;
129
130 // Attempt 5 failed logins with wrong password
131 for i in 1..=5 {
132 let resp = h.failed_login_attempt("lockuser", "wrongpassword").await;
133 // First 4 attempts: "Invalid username/email or password"
134 // 5th attempt: triggers lockout
135 assert!(
136 resp.status.is_client_error()
137 || resp.text.contains("Invalid")
138 || resp.text.contains("locked"),
139 "Failed login attempt {} should return error: {} {}",
140 i,
141 resp.status,
142 resp.text
143 );
144 }
145
146 // Now try with the CORRECT password, should still be locked
147 let resp = h.failed_login_attempt("lockuser", "correctpass1").await;
148 assert!(
149 resp.status.is_client_error()
150 || resp.text.contains("locked")
151 || resp.text.contains("Account is locked"),
152 "Correct password during lockout should be rejected: {} {}",
153 resp.status,
154 resp.text
155 );
156
157 // Verify we're NOT logged in
158 let resp = h.client.get("/dashboard").await;
159 assert!(
160 resp.status == 401 || resp.status.is_redirection(),
161 "Should not be logged in during lockout: {} {}",
162 resp.status,
163 resp.text
164 );
165 }
166
167 /// F2 regression: the account-locked notification must fire again on a
168 /// RE-LOCK (the lock window expired while the failed-attempt counter was still
169 /// at/over the threshold), not only on the exact-threshold attempt. The old
170 /// `just_locked = (failed_login_attempts = $2)` read false on a re-lock (the
171 /// counter is already past the threshold), silently skipping the login-link
172 /// email. The CTE-based predicate now mirrors the actual lock condition.
173 #[tokio::test]
174 async fn lockout_email_fires_again_on_relock_after_expiry() {
175 let mut h = TestHarness::with_mocks().await;
176 let user_id = h.signup("relock", "relock@test.com", "correctpass1").await;
177 h.client.post_form("/logout", "").await;
178
179 // Five failures trigger the first lock and its login-link email.
180 for _ in 1..=5 {
181 h.failed_login_attempt("relock", "wrongpassword").await;
182 }
183 let email = h
184 .mock_email
185 .clone()
186 .expect("mock email configured by with_mocks");
187 assert!(
188 !email.sent_to("relock@test.com").is_empty(),
189 "first lock should have emailed a login link"
190 );
191
192 // Expire the lock window WITHOUT resetting the counter (only a successful
193 // login resets it), exactly the re-lock precondition.
194 sqlx::query("UPDATE users SET locked_until = NOW() - interval '1 minute' WHERE id = $1")
195 .bind(user_id)
196 .execute(&h.db)
197 .await
198 .expect("expire lock window");
199
200 let before = email.sent_to("relock@test.com").len();
201 // One more failure re-locks the (already over-threshold) account.
202 h.failed_login_attempt("relock", "wrongpassword").await;
203 // The lockout notification is dispatched on a spawned task, so poll for it.
204 // With the old `just_locked` it is never sent (the loop times out); with the
205 // fix it appears within a beat.
206 let mut after = email.sent_to("relock@test.com").len();
207 for _ in 0..40 {
208 if after > before {
209 break;
210 }
211 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
212 after = email.sent_to("relock@test.com").len();
213 }
214 assert_eq!(
215 after,
216 before + 1,
217 "re-lock after expiry must send another account-locked login-link email"
218 );
219 }
220
221 // 2FA state boundary
222
223 /// Vulnerability tested: 2FA pending state allows API access.
224 /// After password login with TOTP enabled, the session has a pending_2fa_user_id
225 /// but no authenticated "user". The AuthUser extractor should reject API access
226 /// until 2FA is completed.
227 #[tokio::test]
228 async fn two_factor_pending_blocks_api() {
229 let mut h = TestHarness::new().await;
230 let user_id = h.signup("twofa", "twofa@test.com", "password123").await;
231 h.grant_creator(user_id).await;
232
233 // Enable TOTP
234 let resp = h.client.post_form("/api/users/me/totp/setup", "").await;
235 assert!(resp.status.is_success(), "TOTP setup failed: {}", resp.text);
236
237 // Extract secret from HTML response (inside <details> > <code>)
238 let details_start = resp
239 .text
240 .find("<details")
241 .expect("No <details> in TOTP setup HTML");
242 let details_html = &resp.text[details_start..];
243 let code_start = details_html.find("<code").expect("No <code> in details");
244 let after_tag = &details_html[code_start..];
245 let content_start = after_tag.find('>').expect("No > after <code") + 1;
246 let content_end = after_tag[content_start..]
247 .find("</code>")
248 .expect("No </code>");
249 let secret = &after_tag[content_start..content_start + content_end];
250
251 let bytes = totp_rs::Secret::Encoded(secret.to_string())
252 .to_bytes()
253 .expect("Invalid TOTP secret");
254 let totp = totp_rs::TOTP::new(
255 totp_rs::Algorithm::SHA1,
256 6,
257 1,
258 30,
259 bytes,
260 Some("Makenotwork".into()),
261 "twofa@test.com".into(),
262 )
263 .unwrap();
264 let code = totp.generate_current().unwrap();
265
266 // Confirm TOTP
267 let resp = h
268 .client
269 .post_form("/api/users/me/totp/confirm", &format!("code={code}"))
270 .await;
271 assert!(
272 resp.status.is_success(),
273 "TOTP confirm failed: {} {}",
274 resp.status,
275 resp.text
276 );
277
278 h.client.post_form("/logout", "").await;
279
280 // Login with correct password, should redirect to 2FA page (not complete login)
281 let resp = h.failed_login_attempt("twofa", "password123").await;
282 // The response should indicate 2FA is needed (redirect to /auth/2fa or /auth/verify-2fa)
283 assert!(
284 resp.status.is_redirection()
285 || resp.status.is_success()
286 || resp.text.contains("2fa")
287 || resp.text.contains("verify"),
288 "Login with TOTP enabled should require 2FA: {} {}",
289 resp.status,
290 resp.text
291 );
292
293 // NOW try to access protected API endpoints, should fail because
294 // session has pending_2fa but no authenticated user
295 let resp = h.client.get("/api/projects").await;
296 assert!(
297 resp.status == 401 || resp.status == 403 || resp.status.is_redirection(),
298 "API access during 2FA pending should be rejected: {} {}",
299 resp.status,
300 resp.text
301 );
302
303 let resp = h
304 .client
305 .post_form("/api/projects", "slug=pending-hack&title=Hack")
306 .await;
307 assert!(
308 resp.status == 401 || resp.status == 403 || resp.status.is_redirection(),
309 "Project creation during 2FA pending should be rejected: {} {}",
310 resp.status,
311 resp.text
312 );
313 }
314
315 // Login link anti-replay
316
317 /// Vulnerability tested: Login link token replay.
318 /// One-time login tokens should be consumed atomically on first use.
319 /// Second use of the same token should fail.
320 #[tokio::test]
321 async fn login_link_replay_rejected() {
322 let mut h = TestHarness::new().await;
323 let user_id = h.signup("replay", "replay@test.com", "password123").await;
324
325 // Generate a one-time login token
326 let (token, token_hash) = makenotwork::email::generate_login_token();
327
328 // Insert token into DB
329 let expires_at = chrono::Utc::now() + chrono::Duration::minutes(15);
330 sqlx::query("INSERT INTO login_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)")
331 .bind(user_id)
332 .bind(&token_hash)
333 .bind(expires_at)
334 .execute(&h.db)
335 .await
336 .expect("Failed to insert login token");
337
338 h.client.post_form("/logout", "").await;
339
340 // First use, should succeed
341 let resp = h.client.get(&format!("/login-link?token={token}")).await;
342 assert!(
343 resp.status.is_success() || resp.status.is_redirection(),
344 "First login link use should succeed: {} {}",
345 resp.status,
346 resp.text
347 );
348
349 // Verify we're logged in
350 let resp = h.client.get("/dashboard").await;
351 assert_eq!(resp.status, 200, "Should be logged in after first use");
352
353 // Logout and try the same token again
354 h.client.post_form("/logout", "").await;
355
356 let resp = h.client.get(&format!("/login-link?token={token}")).await;
357 // Second use should fail, token was consumed
358 // Could be 400, 401, redirect to login, or error page
359 let is_rejected = resp.status.is_client_error()
360 || resp.text.contains("invalid")
361 || resp.text.contains("Invalid")
362 || resp.text.contains("expired")
363 || resp.text.contains("error");
364 assert!(
365 is_rejected || resp.status.is_redirection(),
366 "Login link replay should be rejected: {} {}",
367 resp.status,
368 resp.text
369 );
370
371 // Verify we're NOT logged in after replay attempt
372 let resp = h.client.get("/dashboard").await;
373 assert!(
374 resp.status.is_redirection() || resp.status == 401,
375 "Should not be logged in after replayed login link: {} {}",
376 resp.status,
377 resp.text
378 );
379 }
380
381 // User enumeration prevention
382
383 /// Vulnerability tested: Login error leaks whether username exists.
384 /// The error message for a non-existent user should be identical to the
385 /// error for a wrong password, preventing user enumeration.
386 #[tokio::test]
387 async fn login_no_user_enumeration() {
388 let mut h = TestHarness::new().await;
389 let _user_id = h
390 .signup("realuser", "realuser@test.com", "password123")
391 .await;
392 h.client.post_form("/logout", "").await;
393
394 // Wrong password for existing user
395 let resp_wrong_pass = h
396 .client
397 .post_form("/login", "login=realuser&password=wrongpassword")
398 .await;
399
400 // Non-existent user
401 let resp_no_user = h
402 .client
403 .post_form("/login", "login=doesnotexist&password=anypassword")
404 .await;
405
406 // Both should return the same status code
407 assert_eq!(
408 resp_wrong_pass.status, resp_no_user.status,
409 "Wrong password ({}) and non-existent user ({}) should return same status",
410 resp_wrong_pass.status, resp_no_user.status
411 );
412
413 // Both should contain the same generic error message (not "user not found")
414 assert!(
415 !resp_no_user.text.contains("not found")
416 && !resp_no_user.text.contains("does not exist")
417 && !resp_no_user.text.contains("no account"),
418 "Non-existent user error should not reveal user doesn't exist: {}",
419 resp_no_user.text
420 );
421 }
422
423 // Failed login doesn't create session
424
425 /// Vulnerability tested: Failed login creates an authenticated session.
426 /// After a failed password attempt, the session should NOT contain an
427 /// authenticated user, dashboard should be inaccessible.
428 #[tokio::test]
429 async fn wrong_password_no_session() {
430 let mut h = TestHarness::new().await;
431 let _user_id = h.signup("nosess", "nosess@test.com", "password123").await;
432 h.client.post_form("/logout", "").await;
433
434 // Attempt login with wrong password
435 let _resp = h
436 .client
437 .post_form("/login", "login=nosess&password=wrongpassword")
438 .await;
439
440 // Verify no authenticated session was created
441 let resp = h.client.get("/dashboard").await;
442 assert!(
443 resp.status == 401 || resp.status.is_redirection(),
444 "Dashboard should not be accessible after failed login: {} {}",
445 resp.status,
446 resp.text
447 );
448
449 // Verify API access is also blocked
450 let resp = h.client.get("/api/projects").await;
451 assert!(
452 resp.status == 401 || resp.status == 403 || resp.status.is_redirection(),
453 "API should not be accessible after failed login: {} {}",
454 resp.status,
455 resp.text
456 );
457 }
458
459 // Empty/malformed credentials
460
461 /// Vulnerability tested: Empty credentials bypass authentication.
462 /// Login with empty username and/or password should fail cleanly.
463 #[tokio::test]
464 async fn empty_credentials_rejected() {
465 let mut h = TestHarness::new().await;
466 let _user_id = h
467 .signup("emptytest", "emptytest@test.com", "password123")
468 .await;
469 h.client.post_form("/logout", "").await;
470
471 // Empty password
472 let resp = h
473 .client
474 .post_form("/login", "login=emptytest&password=")
475 .await;
476 assert!(
477 resp.status.is_client_error() || resp.text.contains("Invalid"),
478 "Empty password should be rejected: {} {}",
479 resp.status,
480 resp.text
481 );
482
483 // Empty username
484 let resp = h
485 .client
486 .post_form("/login", "login=&password=password123")
487 .await;
488 assert!(
489 resp.status.is_client_error() || resp.text.contains("Invalid"),
490 "Empty username should be rejected: {} {}",
491 resp.status,
492 resp.text
493 );
494
495 // Both empty
496 let resp = h.client.post_form("/login", "login=&password=").await;
497 assert!(
498 resp.status.is_client_error() || resp.text.contains("Invalid"),
499 "Both empty should be rejected: {} {}",
500 resp.status,
501 resp.text
502 );
503
504 // Verify no session was created
505 let resp = h.client.get("/dashboard").await;
506 assert!(
507 resp.status == 401 || resp.status.is_redirection(),
508 "Should not be logged in after empty credentials: {} {}",
509 resp.status,
510 resp.text
511 );
512 }
513
514 // Suspended user enforcement
515
516 /// Vulnerability tested: Suspended user bypasses write restrictions.
517 /// A suspended user can still log in and read, but all write operations
518 /// (create/update/delete) should be blocked with 403.
519 #[tokio::test]
520 async fn suspended_user_login_ok_writes_blocked() {
521 let mut h = TestHarness::new().await;
522 let user_id = h
523 .signup("susptest", "susptest@test.com", "password123")
524 .await;
525 h.grant_creator(user_id).await;
526
527 makenotwork::db::users::suspend_user(&h.db, user_id, "adversarial test")
528 .await
529 .unwrap();
530
531 // Logout and re-login, login should still work
532 h.client.post_form("/logout", "").await;
533 h.login("susptest", "password123").await;
534
535 // Read operations should work
536 let resp = h.client.get("/dashboard").await;
537 assert_eq!(
538 resp.status, 200,
539 "Suspended user should access dashboard: {} {}",
540 resp.status, resp.text
541 );
542
543 // Write operations should be blocked
544 let resp = h
545 .client
546 .post_form("/api/projects", "slug=susp-project&title=Suspended")
547 .await;
548 assert_eq!(
549 resp.status, 403,
550 "Suspended user should not create projects: {} {}",
551 resp.status, resp.text
552 );
553
554 // Profile updates (`PUT /api/users/me`) are now blocked for suspended
555 // users, the `check_not_suspended()` guard was extended to profile and
556 // synckit billing mutations to keep account self-management consistent
557 // with the rest of the suspension policy (commit 78dda3d).
558 let resp = h
559 .client
560 .put_form("/api/users/me", "display_name=Updated+Name")
561 .await;
562 assert_eq!(
563 resp.status, 403,
564 "Suspended user profile update should be blocked: {} {}",
565 resp.status, resp.text
566 );
567 }
568
569 // Password change invalidates old password
570
571 /// Vulnerability tested: Old password still works after password change.
572 /// After changing the password, login with the old password should fail.
573 #[tokio::test]
574 async fn old_password_rejected_after_change() {
575 let mut h = TestHarness::new().await;
576 let _user_id = h.signup("oldpw", "oldpw@test.com", "oldpassword1").await;
577
578 // Change password
579 let resp = h
580 .client
581 .put_form(
582 "/api/users/me/password",
583 "current_password=oldpassword1&new_password=newpassword1",
584 )
585 .await;
586 assert!(
587 resp.status.is_success(),
588 "Password change failed: {}",
589 resp.text
590 );
591
592 h.client.post_form("/logout", "").await;
593
594 // Try to login with old password, should fail
595 let resp = h
596 .client
597 .post_form("/login", "login=oldpw&password=oldpassword1")
598 .await;
599 assert!(
600 resp.status.is_client_error() || resp.text.contains("Invalid"),
601 "Old password should be rejected after change: {} {}",
602 resp.status,
603 resp.text
604 );
605
606 // Verify not logged in
607 let resp = h.client.get("/dashboard").await;
608 assert!(
609 resp.status == 401 || resp.status.is_redirection(),
610 "Should not be logged in with old password: {} {}",
611 resp.status,
612 resp.text
613 );
614
615 // Login with new password should work
616 h.login("oldpw", "newpassword1").await;
617 let resp = h.client.get("/dashboard").await;
618 assert_eq!(resp.status, 200, "New password should work");
619 }
620
621 /// Contract: changing the password revokes OTHER active web sessions (a
622 /// stolen/leaked cookie must not outlive a password change) while keeping the
623 /// session that made the change logged in. This pins the `delete_other_sessions`
624 /// sweep in `update_password`, the subtle "revoke everyone else, keep me" half
625 /// of the security contract that `old_password_rejected_after_change` (single
626 /// session) does not exercise.
627 #[tokio::test]
628 async fn password_change_revokes_other_sessions() {
629 let mut h = TestHarness::new().await;
630 // Session A: signup leaves h.client authenticated as the user.
631 h.signup("multisess", "multisess@test.com", "oldpassword1")
632 .await;
633 let resp = h.client.get("/dashboard").await;
634 assert_eq!(
635 resp.status, 200,
636 "session A should start logged in: {}",
637 resp.text
638 );
639
640 // Session B: a second, independent client logs in as the same user.
641 let mut other = h.client.fork_fresh();
642 other.fetch_csrf_token().await;
643 let resp = other
644 .post_form("/login", "login=multisess&password=oldpassword1")
645 .await;
646 assert!(
647 resp.status.is_success() || resp.status.is_redirection(),
648 "session B login failed: {} {}",
649 resp.status,
650 resp.text
651 );
652 let resp = other.get("/dashboard").await;
653 assert_eq!(
654 resp.status, 200,
655 "session B should be logged in before the change"
656 );
657
658 // Session A changes the password.
659 let resp = h
660 .client
661 .put_form(
662 "/api/users/me/password",
663 "current_password=oldpassword1&new_password=newpassword1",
664 )
665 .await;
666 assert!(
667 resp.status.is_success(),
668 "password change failed: {}",
669 resp.text
670 );
671
672 // Session B is now revoked (cookie no longer resolves to a live session).
673 let resp = other.get("/dashboard").await;
674 assert!(
675 resp.status == 401 || resp.status.is_redirection(),
676 "session B must be revoked after the password change: {} {}",
677 resp.status,
678 resp.text
679 );
680
681 // Session A, the session that made the change, stays logged in.
682 let resp = h.client.get("/dashboard").await;
683 assert_eq!(
684 resp.status, 200,
685 "the changing session must remain logged in"
686 );
687 }
688