bootApp(); } public function testGetLoginRendersForm(): void { $response = $this->request('GET', '/login'); self::assertSame(200, $response->getStatusCode()); $body = (string) $response->getBody(); self::assertStringContainsString('Sign in', $body); // Local sign-in toggle present (oidc disabled in this fixture). self::assertStringContainsString('name="username"', $body); self::assertStringContainsString('csrf_token', $body); } public function testCorrectCredentialsLogInAndRedirectToMe(): void { $this->enqueueApiResponse(200, [ 'user_id' => 1, 'role' => 'admin', 'email' => null, 'display_name' => 'Local Admin', 'is_local' => true, ]); // Need a session + csrf token; first GET /login to set one up. $this->request('GET', '/login'); $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? ''); self::assertNotEmpty($token); $body = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'test1234']); $response = $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded'); self::assertSame(303, $response->getStatusCode()); self::assertSame('/app/dashboard', $response->getHeaderLine('Location')); self::assertNotNull($_SESSION['_user'] ?? null); self::assertSame('admin', $_SESSION['_user']['role']); } public function testWrongPasswordRedirectsBackToLoginWithFlash(): void { $this->request('GET', '/login'); $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? ''); $body = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'WRONG']); $response = $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded'); self::assertSame(303, $response->getStatusCode()); self::assertSame('/login', $response->getHeaderLine('Location')); $flash = $_SESSION['_flash'] ?? []; self::assertNotEmpty($flash); self::assertSame('error', $flash[0]['type']); } public function testWrongUsernameAlsoRecordsFailure(): void { $this->request('GET', '/login'); $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? ''); $body = http_build_query(['csrf_token' => $token, 'username' => 'someone', 'password' => 'test1234']); $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded'); // Failure recorded on the LoginThrottle (not in the session). // Five more attempts from the same IP for the same bogus user // would lock the bucket; one shot doesn't, so we just verify // the next attempt isn't locked yet. /** @var LoginThrottle $throttle */ $throttle = $this->container->get(LoginThrottle::class); self::assertFalse($throttle->isLocked('someone', '')); } public function testCsrfMissingIs403(): void { $this->request('GET', '/login'); $body = http_build_query(['username' => 'admin', 'password' => 'test1234']); $response = $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded'); self::assertSame(403, $response->getStatusCode()); } public function testFiveFailuresLockOutNextAttempt(): void { $this->request('GET', '/login'); $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? ''); $bad = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'WRONG']); for ($i = 0; $i < 5; ++$i) { $this->request('POST', '/login/local', [], $bad, 'application/x-www-form-urlencoded'); } // 6th attempt — even with correct credentials — gets the lockout flash. $this->enqueueApiResponse(200, [ 'user_id' => 1, 'role' => 'admin', 'email' => null, 'display_name' => 'Local Admin', 'is_local' => true, ]); $good = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'test1234']); $response = $this->request('POST', '/login/local', [], $good, 'application/x-www-form-urlencoded'); self::assertSame(303, $response->getStatusCode()); self::assertSame('/login', $response->getHeaderLine('Location')); $flash = $_SESSION['_flash'] ?? []; self::assertNotEmpty($flash); self::assertStringContainsStringIgnoringCase('too many', $flash[0]['message']); } public function testRotatingXForwardedForDoesNotEvadePerIpLockout(): void { // SEC_REVIEW F1: an attacker spoofing X-Forwarded-For per request // must NOT mint a fresh throttle bucket. The per-IP bucket is keyed // on REMOTE_ADDR, which is constant here — so 5 failures should // still trip the lockout regardless of XFF rotation. $this->request('GET', '/login'); $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? ''); $bad = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'WRONG']); for ($i = 0; $i < 5; ++$i) { $this->request( 'POST', '/login/local', ['X-Forwarded-For' => '203.0.113.' . $i], $bad, 'application/x-www-form-urlencoded', ['REMOTE_ADDR' => '198.51.100.7'], ); } // 6th attempt with correct credentials but a fresh XFF still gets the // lockout flash because the per-(user, REMOTE_ADDR) bucket is full. $this->enqueueApiResponse(200, [ 'user_id' => 1, 'role' => 'admin', 'email' => null, 'display_name' => 'Local Admin', 'is_local' => true, ]); $good = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'test1234']); $response = $this->request( 'POST', '/login/local', ['X-Forwarded-For' => '203.0.113.99'], $good, 'application/x-www-form-urlencoded', ['REMOTE_ADDR' => '198.51.100.7'], ); self::assertSame(303, $response->getStatusCode()); self::assertSame('/login', $response->getHeaderLine('Location')); $flash = $_SESSION['_flash'] ?? []; self::assertNotEmpty($flash); self::assertStringContainsStringIgnoringCase('too many', $flash[0]['message']); } public function testRotatingRemoteAddrEventuallyHitsPerUsernameLockout(): void { // SEC_REVIEW F2: even with proper REMOTE_ADDR, an attacker on a // residential proxy pool gets a fresh per-IP bucket per request. // The per-username (cross-IP) bucket must catch this at 25 failures. $this->request('GET', '/login'); $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? ''); $bad = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'WRONG']); for ($i = 0; $i < 25; ++$i) { $this->request( 'POST', '/login/local', [], $bad, 'application/x-www-form-urlencoded', ['REMOTE_ADDR' => '198.51.100.' . $i], ); } // Next attempt from yet another IP — even with correct credentials — // gets the lockout flash because the per-username bucket is full. $this->enqueueApiResponse(200, [ 'user_id' => 1, 'role' => 'admin', 'email' => null, 'display_name' => 'Local Admin', 'is_local' => true, ]); $good = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'test1234']); $response = $this->request( 'POST', '/login/local', [], $good, 'application/x-www-form-urlencoded', ['REMOTE_ADDR' => '198.51.100.250'], ); self::assertSame(303, $response->getStatusCode()); self::assertSame('/login', $response->getHeaderLine('Location')); $flash = $_SESSION['_flash'] ?? []; self::assertNotEmpty($flash); self::assertStringContainsStringIgnoringCase('too many', $flash[0]['message']); } public function testApiDownDuringUpsertFlashesError(): void { $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException( 'connection refused', new \GuzzleHttp\Psr7\Request('POST', '/api/v1/auth/users/upsert-local'), )); $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException( 'connection refused', new \GuzzleHttp\Psr7\Request('POST', '/api/v1/auth/users/upsert-local'), )); $this->request('GET', '/login'); $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? ''); $body = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'test1234']); $response = $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded'); self::assertSame(303, $response->getStatusCode()); self::assertSame('/login', $response->getHeaderLine('Location')); $flash = $_SESSION['_flash'] ?? []; self::assertNotEmpty($flash); self::assertStringContainsStringIgnoringCase('api', $flash[0]['message']); } }