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 testFailuresArePersistedToConfiguredFilePath(): void { // SEC_REVIEW F6: confirm production wiring writes throttle state // to the on-disk path (so a worker recycle cannot wipe it). Drives // a failure through the full Slim stack, then asserts the file // exists and decodes to the expected per-IP/per-username buckets. // The cross-instance persistence guarantee itself is covered in // FileThrottleStoreTest. $this->request('GET', '/login'); $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? ''); $bad = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'WRONG']); $this->request('POST', '/login/local', [], $bad, 'application/x-www-form-urlencoded'); /** @var \DI\Container $c */ $c = $this->container; $path = (string) $c->get('settings.login_throttle_path'); self::assertFileExists($path); $decoded = json_decode((string) file_get_contents($path), true); self::assertIsArray($decoded); self::assertArrayHasKey('ip', $decoded); self::assertArrayHasKey('username', $decoded); self::assertArrayHasKey('admin', $decoded['username']); self::assertSame(1, $decoded['username']['admin']['count']); // And the live LoginThrottle (built via the container) sees that // count too — i.e., it shares the same store. /** @var LoginThrottle $live */ $live = $c->get(LoginThrottle::class); self::assertFalse($live->isLocked('admin', '0.0.0.0')); // Top up to the per-IP threshold — uses the SAME file the prior // request wrote to. for ($i = 0; $i < 4; ++$i) { $live->recordFailure('admin', ''); } self::assertTrue($live->isLocked('admin', '')); } 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']); } }