Skip to main content

max / makenotwork

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