//! Adversarial authentication & session tests. //! //! Focus: Authentication & session handling (Option C from adversarial.md). //! Tests auth boundaries, session lifecycle, lockout, 2FA state, and anti-replay. //! Tests that PASS prove the app correctly enforces auth boundaries. //! Tests that FAIL have found a real bug. use crate::harness::TestHarness; // Session lifecycle /// Vulnerability tested: Stale session after logout allows actions. /// After logout, the session cookie should be invalidated. Any attempt to /// use the old session for write operations should be rejected. #[tokio::test] async fn stale_session_after_logout() { let mut h = TestHarness::new().await; let user_id = h.signup("staleuser", "stale@test.com", "password123").await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login("staleuser", "password123").await; // Verify we can access the API while logged in let resp = h.client.get("/api/projects").await; assert!( resp.status.is_success(), "Should have API access while logged in" ); h.client.post_form("/logout", "").await; // Try to create a project with the stale session let resp = h .client .post_form("/api/projects", "slug=stale-project&title=Stale+Project") .await; assert!( resp.status == 401 || resp.status == 403 || resp.status.is_redirection(), "Stale session should not allow project creation: {} {}", resp.status, resp.text ); // Try to list projects (read operation) let resp = h.client.get("/api/projects").await; assert!( resp.status == 401 || resp.status == 403 || resp.status.is_redirection(), "Stale session should not allow reading user's projects: {} {}", resp.status, resp.text ); // Try to access dashboard let resp = h.client.get("/dashboard").await; assert!( resp.status == 401 || resp.status.is_redirection(), "Stale session should not allow dashboard access: {} {}", resp.status, resp.text ); } /// Vulnerability tested: Unauthenticated API access. /// All /api/* endpoints that manage user resources require authentication. /// A fresh client with no session should be rejected. #[tokio::test] async fn unauthenticated_api_access_rejected() { let mut h = TestHarness::new().await; // Fetch CSRF token to establish a session (but don't log in) h.client.fetch_csrf_token().await; // Try various API endpoints without authentication let endpoints = vec![("GET", "/api/projects"), ("GET", "/api/promo-codes")]; for (method, path) in &endpoints { let resp = if *method == "GET" { h.client.get(path).await } else { h.client.post_form(path, "").await }; assert!( resp.status == 401 || resp.status == 403 || resp.status.is_redirection(), "Unauthenticated {} {} should be rejected: {} {}", method, path, resp.status, resp.text ); } // Try write operations let resp = h .client .post_form("/api/projects", "slug=unauth&title=Unauth") .await; assert!( resp.status == 401 || resp.status == 403 || resp.status.is_redirection(), "Unauthenticated project creation should be rejected: {} {}", resp.status, resp.text ); let resp = h .client .put_form("/api/users/me", "display_name=Hacker") .await; assert!( resp.status == 401 || resp.status == 403 || resp.status.is_redirection(), "Unauthenticated profile update should be rejected: {} {}", resp.status, resp.text ); } // Login lockout /// Vulnerability tested: Account lockout after repeated failed logins. /// After MAX_LOGIN_ATTEMPTS (5) failed password attempts, the account should /// be locked for LOCKOUT_MINUTES (15). Even the correct password should be /// rejected during lockout. #[tokio::test] async fn login_lockout_after_five_failures() { let mut h = TestHarness::new().await; let _user_id = h .signup("lockuser", "lockuser@test.com", "correctpass1") .await; h.client.post_form("/logout", "").await; // Attempt 5 failed logins with wrong password for i in 1..=5 { let resp = h.failed_login_attempt("lockuser", "wrongpassword").await; // First 4 attempts: "Invalid username/email or password" // 5th attempt: triggers lockout assert!( resp.status.is_client_error() || resp.text.contains("Invalid") || resp.text.contains("locked"), "Failed login attempt {} should return error: {} {}", i, resp.status, resp.text ); } // Now try with the CORRECT password, should still be locked let resp = h.failed_login_attempt("lockuser", "correctpass1").await; assert!( resp.status.is_client_error() || resp.text.contains("locked") || resp.text.contains("Account is locked"), "Correct password during lockout should be rejected: {} {}", resp.status, resp.text ); // Verify we're NOT logged in let resp = h.client.get("/dashboard").await; assert!( resp.status == 401 || resp.status.is_redirection(), "Should not be logged in during lockout: {} {}", resp.status, resp.text ); } /// F2 regression: the account-locked notification must fire again on a /// RE-LOCK (the lock window expired while the failed-attempt counter was still /// at/over the threshold), not only on the exact-threshold attempt. The old /// `just_locked = (failed_login_attempts = $2)` read false on a re-lock (the /// counter is already past the threshold), silently skipping the login-link /// email. The CTE-based predicate now mirrors the actual lock condition. #[tokio::test] async fn lockout_email_fires_again_on_relock_after_expiry() { let mut h = TestHarness::with_mocks().await; let user_id = h.signup("relock", "relock@test.com", "correctpass1").await; h.client.post_form("/logout", "").await; // Five failures trigger the first lock and its login-link email. for _ in 1..=5 { h.failed_login_attempt("relock", "wrongpassword").await; } let email = h .mock_email .clone() .expect("mock email configured by with_mocks"); assert!( !email.sent_to("relock@test.com").is_empty(), "first lock should have emailed a login link" ); // Expire the lock window WITHOUT resetting the counter (only a successful // login resets it), exactly the re-lock precondition. sqlx::query("UPDATE users SET locked_until = NOW() - interval '1 minute' WHERE id = $1") .bind(user_id) .execute(&h.db) .await .expect("expire lock window"); let before = email.sent_to("relock@test.com").len(); // One more failure re-locks the (already over-threshold) account. h.failed_login_attempt("relock", "wrongpassword").await; // The lockout notification is dispatched on a spawned task, so poll for it. // With the old `just_locked` it is never sent (the loop times out); with the // fix it appears within a beat. let mut after = email.sent_to("relock@test.com").len(); for _ in 0..40 { if after > before { break; } tokio::time::sleep(std::time::Duration::from_millis(50)).await; after = email.sent_to("relock@test.com").len(); } assert_eq!( after, before + 1, "re-lock after expiry must send another account-locked login-link email" ); } // 2FA state boundary /// Vulnerability tested: 2FA pending state allows API access. /// After password login with TOTP enabled, the session has a pending_2fa_user_id /// but no authenticated "user". The AuthUser extractor should reject API access /// until 2FA is completed. #[tokio::test] async fn two_factor_pending_blocks_api() { let mut h = TestHarness::new().await; let user_id = h.signup("twofa", "twofa@test.com", "password123").await; h.grant_creator(user_id).await; // Enable TOTP let resp = h.client.post_form("/api/users/me/totp/setup", "").await; assert!(resp.status.is_success(), "TOTP setup failed: {}", resp.text); // Extract secret from HTML response (inside
> ) let details_start = resp .text .find(" in TOTP setup HTML"); let details_html = &resp.text[details_start..]; let code_start = details_html.find(" in details"); let after_tag = &details_html[code_start..]; let content_start = after_tag.find('>').expect("No > after ") .expect("No "); let secret = &after_tag[content_start..content_start + content_end]; let bytes = totp_rs::Secret::Encoded(secret.to_string()) .to_bytes() .expect("Invalid TOTP secret"); let totp = totp_rs::TOTP::new( totp_rs::Algorithm::SHA1, 6, 1, 30, bytes, Some("Makenotwork".into()), "twofa@test.com".into(), ) .unwrap(); let code = totp.generate_current().unwrap(); // Confirm TOTP let resp = h .client .post_form("/api/users/me/totp/confirm", &format!("code={code}")) .await; assert!( resp.status.is_success(), "TOTP confirm failed: {} {}", resp.status, resp.text ); h.client.post_form("/logout", "").await; // Login with correct password, should redirect to 2FA page (not complete login) let resp = h.failed_login_attempt("twofa", "password123").await; // The response should indicate 2FA is needed (redirect to /auth/2fa or /auth/verify-2fa) assert!( resp.status.is_redirection() || resp.status.is_success() || resp.text.contains("2fa") || resp.text.contains("verify"), "Login with TOTP enabled should require 2FA: {} {}", resp.status, resp.text ); // NOW try to access protected API endpoints, should fail because // session has pending_2fa but no authenticated user let resp = h.client.get("/api/projects").await; assert!( resp.status == 401 || resp.status == 403 || resp.status.is_redirection(), "API access during 2FA pending should be rejected: {} {}", resp.status, resp.text ); let resp = h .client .post_form("/api/projects", "slug=pending-hack&title=Hack") .await; assert!( resp.status == 401 || resp.status == 403 || resp.status.is_redirection(), "Project creation during 2FA pending should be rejected: {} {}", resp.status, resp.text ); } // Login link anti-replay /// Vulnerability tested: Login link token replay. /// One-time login tokens should be consumed atomically on first use. /// Second use of the same token should fail. #[tokio::test] async fn login_link_replay_rejected() { let mut h = TestHarness::new().await; let user_id = h.signup("replay", "replay@test.com", "password123").await; // Generate a one-time login token let (token, token_hash) = makenotwork::email::generate_login_token(); // Insert token into DB let expires_at = chrono::Utc::now() + chrono::Duration::minutes(15); sqlx::query("INSERT INTO login_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)") .bind(user_id) .bind(&token_hash) .bind(expires_at) .execute(&h.db) .await .expect("Failed to insert login token"); h.client.post_form("/logout", "").await; // First use, should succeed let resp = h.client.get(&format!("/login-link?token={token}")).await; assert!( resp.status.is_success() || resp.status.is_redirection(), "First login link use should succeed: {} {}", resp.status, resp.text ); // Verify we're logged in let resp = h.client.get("/dashboard").await; assert_eq!(resp.status, 200, "Should be logged in after first use"); // Logout and try the same token again h.client.post_form("/logout", "").await; let resp = h.client.get(&format!("/login-link?token={token}")).await; // Second use should fail, token was consumed // Could be 400, 401, redirect to login, or error page let is_rejected = resp.status.is_client_error() || resp.text.contains("invalid") || resp.text.contains("Invalid") || resp.text.contains("expired") || resp.text.contains("error"); assert!( is_rejected || resp.status.is_redirection(), "Login link replay should be rejected: {} {}", resp.status, resp.text ); // Verify we're NOT logged in after replay attempt let resp = h.client.get("/dashboard").await; assert!( resp.status.is_redirection() || resp.status == 401, "Should not be logged in after replayed login link: {} {}", resp.status, resp.text ); } // User enumeration prevention /// Vulnerability tested: Login error leaks whether username exists. /// The error message for a non-existent user should be identical to the /// error for a wrong password, preventing user enumeration. #[tokio::test] async fn login_no_user_enumeration() { let mut h = TestHarness::new().await; let _user_id = h .signup("realuser", "realuser@test.com", "password123") .await; h.client.post_form("/logout", "").await; // Wrong password for existing user let resp_wrong_pass = h .client .post_form("/login", "login=realuser&password=wrongpassword") .await; // Non-existent user let resp_no_user = h .client .post_form("/login", "login=doesnotexist&password=anypassword") .await; // Both should return the same status code assert_eq!( resp_wrong_pass.status, resp_no_user.status, "Wrong password ({}) and non-existent user ({}) should return same status", resp_wrong_pass.status, resp_no_user.status ); // Both should contain the same generic error message (not "user not found") assert!( !resp_no_user.text.contains("not found") && !resp_no_user.text.contains("does not exist") && !resp_no_user.text.contains("no account"), "Non-existent user error should not reveal user doesn't exist: {}", resp_no_user.text ); } // Failed login doesn't create session /// Vulnerability tested: Failed login creates an authenticated session. /// After a failed password attempt, the session should NOT contain an /// authenticated user, dashboard should be inaccessible. #[tokio::test] async fn wrong_password_no_session() { let mut h = TestHarness::new().await; let _user_id = h.signup("nosess", "nosess@test.com", "password123").await; h.client.post_form("/logout", "").await; // Attempt login with wrong password let _resp = h .client .post_form("/login", "login=nosess&password=wrongpassword") .await; // Verify no authenticated session was created let resp = h.client.get("/dashboard").await; assert!( resp.status == 401 || resp.status.is_redirection(), "Dashboard should not be accessible after failed login: {} {}", resp.status, resp.text ); // Verify API access is also blocked let resp = h.client.get("/api/projects").await; assert!( resp.status == 401 || resp.status == 403 || resp.status.is_redirection(), "API should not be accessible after failed login: {} {}", resp.status, resp.text ); } // Empty/malformed credentials /// Vulnerability tested: Empty credentials bypass authentication. /// Login with empty username and/or password should fail cleanly. #[tokio::test] async fn empty_credentials_rejected() { let mut h = TestHarness::new().await; let _user_id = h .signup("emptytest", "emptytest@test.com", "password123") .await; h.client.post_form("/logout", "").await; // Empty password let resp = h .client .post_form("/login", "login=emptytest&password=") .await; assert!( resp.status.is_client_error() || resp.text.contains("Invalid"), "Empty password should be rejected: {} {}", resp.status, resp.text ); // Empty username let resp = h .client .post_form("/login", "login=&password=password123") .await; assert!( resp.status.is_client_error() || resp.text.contains("Invalid"), "Empty username should be rejected: {} {}", resp.status, resp.text ); // Both empty let resp = h.client.post_form("/login", "login=&password=").await; assert!( resp.status.is_client_error() || resp.text.contains("Invalid"), "Both empty should be rejected: {} {}", resp.status, resp.text ); // Verify no session was created let resp = h.client.get("/dashboard").await; assert!( resp.status == 401 || resp.status.is_redirection(), "Should not be logged in after empty credentials: {} {}", resp.status, resp.text ); } // Suspended user enforcement /// Vulnerability tested: Suspended user bypasses write restrictions. /// A suspended user can still log in and read, but all write operations /// (create/update/delete) should be blocked with 403. #[tokio::test] async fn suspended_user_login_ok_writes_blocked() { let mut h = TestHarness::new().await; let user_id = h .signup("susptest", "susptest@test.com", "password123") .await; h.grant_creator(user_id).await; makenotwork::db::users::suspend_user(&h.db, user_id, "adversarial test") .await .unwrap(); // Logout and re-login, login should still work h.client.post_form("/logout", "").await; h.login("susptest", "password123").await; // Read operations should work let resp = h.client.get("/dashboard").await; assert_eq!( resp.status, 200, "Suspended user should access dashboard: {} {}", resp.status, resp.text ); // Write operations should be blocked let resp = h .client .post_form("/api/projects", "slug=susp-project&title=Suspended") .await; assert_eq!( resp.status, 403, "Suspended user should not create projects: {} {}", resp.status, resp.text ); // Profile updates (`PUT /api/users/me`) are now blocked for suspended // users, the `check_not_suspended()` guard was extended to profile and // synckit billing mutations to keep account self-management consistent // with the rest of the suspension policy (commit 78dda3d). let resp = h .client .put_form("/api/users/me", "display_name=Updated+Name") .await; assert_eq!( resp.status, 403, "Suspended user profile update should be blocked: {} {}", resp.status, resp.text ); } // Password change invalidates old password /// Vulnerability tested: Old password still works after password change. /// After changing the password, login with the old password should fail. #[tokio::test] async fn old_password_rejected_after_change() { let mut h = TestHarness::new().await; let _user_id = h.signup("oldpw", "oldpw@test.com", "oldpassword1").await; // Change password let resp = h .client .put_form( "/api/users/me/password", "current_password=oldpassword1&new_password=newpassword1", ) .await; assert!( resp.status.is_success(), "Password change failed: {}", resp.text ); h.client.post_form("/logout", "").await; // Try to login with old password, should fail let resp = h .client .post_form("/login", "login=oldpw&password=oldpassword1") .await; assert!( resp.status.is_client_error() || resp.text.contains("Invalid"), "Old password should be rejected after change: {} {}", resp.status, resp.text ); // Verify not logged in let resp = h.client.get("/dashboard").await; assert!( resp.status == 401 || resp.status.is_redirection(), "Should not be logged in with old password: {} {}", resp.status, resp.text ); // Login with new password should work h.login("oldpw", "newpassword1").await; let resp = h.client.get("/dashboard").await; assert_eq!(resp.status, 200, "New password should work"); } /// Contract: changing the password revokes OTHER active web sessions (a /// stolen/leaked cookie must not outlive a password change) while keeping the /// session that made the change logged in. This pins the `delete_other_sessions` /// sweep in `update_password`, the subtle "revoke everyone else, keep me" half /// of the security contract that `old_password_rejected_after_change` (single /// session) does not exercise. #[tokio::test] async fn password_change_revokes_other_sessions() { let mut h = TestHarness::new().await; // Session A: signup leaves h.client authenticated as the user. h.signup("multisess", "multisess@test.com", "oldpassword1") .await; let resp = h.client.get("/dashboard").await; assert_eq!( resp.status, 200, "session A should start logged in: {}", resp.text ); // Session B: a second, independent client logs in as the same user. let mut other = h.client.fork_fresh(); other.fetch_csrf_token().await; let resp = other .post_form("/login", "login=multisess&password=oldpassword1") .await; assert!( resp.status.is_success() || resp.status.is_redirection(), "session B login failed: {} {}", resp.status, resp.text ); let resp = other.get("/dashboard").await; assert_eq!( resp.status, 200, "session B should be logged in before the change" ); // Session A changes the password. let resp = h .client .put_form( "/api/users/me/password", "current_password=oldpassword1&new_password=newpassword1", ) .await; assert!( resp.status.is_success(), "password change failed: {}", resp.text ); // Session B is now revoked (cookie no longer resolves to a live session). let resp = other.get("/dashboard").await; assert!( resp.status == 401 || resp.status.is_redirection(), "session B must be revoked after the password change: {} {}", resp.status, resp.text ); // Session A, the session that made the change, stays logged in. let resp = h.client.get("/dashboard").await; assert_eq!( resp.status, 200, "the changing session must remain logged in" ); }