//! 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"); // Logout 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 ); } // ============================================================================= // 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); // Logout 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"); // Logout 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; // Suspend the user 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); // Logout 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"); }