Files
preauth/tests/Functional/AuthenticationFlowTest.php
T
lyra cb378e20bc
Sync GitHub / sync (push) Failing after 5s
Tests / test (pull_request) Successful in 49s
chore: add php-cs-fixer with PSR-12 config and CI check
- Add friendsofphp/php-cs-fixer to require-dev
- Create .php-cs-fixer.dist.php configured for @PSR12 ruleset
- Add php-cs-fixer dry-run step to CI pipeline
- Auto-fix existing PSR-12 violations
- Document code style tooling in readme.md
2026-08-11 08:30:05 -04:00

515 lines
19 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Functional;
use App\Data\Payload;
use App\Enum\Scope;
use OTPHP\TOTP;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* End-to-end functional tests exercising the full HTTP kernel: the request
* travels through RejectListener -> LoginListener -> AllowListener ->
* AcceptListener -> InterceptListener and the services they orchestrate.
*/
final class AuthenticationFlowTest extends WebTestCase
{
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
private const string COOKIE_NAME = '__Host-Http-Preauth';
protected static function createClient(array $options = [], array $server = []): KernelBrowser
{
$client = parent::createClient($options, $server);
// The app stores nonces in the (in-memory) nonceCache pool. In
// production APCu keeps them across requests, but KernelBrowser
// reboots the kernel between requests by default which would lose
// them. Disable the reboot so the nonce issued on the login-page
// request survives to the login-submission request.
$client->disableReboot();
return $client;
}
private function validTotpCode(): string
{
// the app uses the real system clock, so generate the code for now()
return TOTP::createFromSecret(self::TOTP_SECRET)->now();
}
/** base64url-encode a payload, matching the client-side JS / X-Preauth header. */
private function encodePayload(array $data): string
{
$json = json_encode($data, JSON_THROW_ON_ERROR);
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
}
private function loginPayload(
string $id = 'testuser',
?string $token = null,
string $nonce = 'test-nonce-abc',
bool $json = true,
): string {
return $this->encodePayload([
'id' => $id,
'token' => $token ?? $this->validTotpCode(),
'nonce' => $nonce,
'json' => $json,
]);
}
/* ── unauthenticated access ──────────────────────────────────────── */
public function testUnauthenticatedRequestShowsLoginPage(): void
{
$client = static::createClient();
$client->request('GET', '/');
// login page is served with 401 (Unauthorized) to signal the proxy
self::assertSame(401, $client->getResponse()->getStatusCode());
self::assertSelectorExists('form#preauth-form');
self::assertSelectorExists('input[name="nonce"]');
self::assertSelectorExists('input[name="username"]');
self::assertSelectorExists('input[name="totp"]');
}
public function testLoginPageContainsGeneratedNonce(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
$nonceInput = $crawler->filter('input[name="nonce"]')->attr('value');
self::assertNotEmpty($nonceInput);
// base64url charset
self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonceInput);
}
public function testLoginFormDoesNotUsePostMethodWithoutAuthSubdomain(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
$form = $crawler->filter('form#preauth-form');
// without central auth, the form should NOT have method="post"
$method = $form->attr('method');
self::assertNull($method);
}
/* ── successful TOTP login ────────────────────────────────────────── */
public function testSuccessfulTotpLoginViaHeaderSetsCookieAndRedirects(): void
{
$client = static::createClient();
// first, grab a valid nonce from the login page
$crawler = $client->request('GET', '/');
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
self::assertNotEmpty($nonce);
// now submit a valid TOTP via the X-Preauth header
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
]),
]);
$response = $client->getResponse();
self::assertSame(303, $response->getStatusCode()); // SEE_OTHER
self::assertTrue($response->headers->has('Location'));
// a session cookie should be set
$cookies = $response->headers->getCookies();
$hasPreauthCookie = false;
foreach ($cookies as $cookie) {
if (str_contains($cookie->getName(), 'Preauth')) {
$hasPreauthCookie = true;
}
}
self::assertTrue($hasPreauthCookie, 'Expected a preauth cookie to be set after login');
}
public function testSuccessfulLoginReturnsJsonWhenJsonRequested(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'bob',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
]),
]);
$response = $client->getResponse();
self::assertSame(303, $response->getStatusCode());
self::assertSame('application/json', $response->headers->get('Content-Type'));
$body = json_decode($response->getContent(), true);
self::assertSame('Login successful', $body['message']);
}
public function testSuccessfulLoginReturnsHtmlWhenJsonFalse(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'carol',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => false,
]),
]);
$response = $client->getResponse();
self::assertSame(303, $response->getStatusCode());
self::assertStringStartsWith('text/html', $response->headers->get('Content-Type'));
}
public function testAuthenticatedCookieAccessAfterLogin(): void
{
$client = static::createClient();
// login
$crawler = $client->request('GET', '/');
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'dave',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
]),
]);
// grab the cookie value from the login response
$loginResponse = $client->getResponse();
$cookieValue = null;
foreach ($loginResponse->headers->getCookies() as $cookie) {
if (str_contains($cookie->getName(), 'Preauth')) {
$cookieValue = $cookie->getValue();
}
}
self::assertNotNull($cookieValue);
// the cookie was set with secure=true, so the CookieJar will only
// send it over HTTPS; the KernelBrowser automatically updates the
// CookieJar from the login response, so the next request over HTTPS
// will include it
$client->request('GET', 'https://localhost/dashboard');
$response = $client->getResponse();
self::assertSame(200, $response->getStatusCode());
self::assertSame('dave', $response->headers->get('Remote-User'));
}
public function testScopeNoneReturnsPlainTextWithoutRedirect(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'eve',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'scope' => 'none',
]),
]);
$response = $client->getResponse();
self::assertSame(200, $response->getStatusCode());
self::assertStringStartsWith('text/plain', $response->headers->get('Content-Type'));
self::assertSame('eve', $response->headers->get('Remote-User'));
// no redirect for scope=none
self::assertFalse($response->headers->has('Location'));
}
/* ── failed login ─────────────────────────────────────────────────── */
public function testFailedLoginReturnsUnauthorizedJsonWithError(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice',
'token' => '000000', // wrong code
'nonce' => $nonce,
'json' => true,
]),
]);
$response = $client->getResponse();
self::assertSame(401, $response->getStatusCode());
self::assertSame('application/json', $response->headers->get('Content-Type'));
$body = json_decode($response->getContent(), true);
self::assertArrayHasKey('message', $body);
self::assertArrayHasKey('nonce', $body);
// a fresh nonce should be returned for the next attempt
self::assertNotEmpty($body['nonce']);
}
public function testFailedLoginReturnsHtmlWhenJsonFalse(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice',
'token' => 'wrong-code',
'nonce' => $nonce,
'json' => false,
]),
]);
$response = $client->getResponse();
self::assertSame(401, $response->getStatusCode());
self::assertStringStartsWith('text/html', $response->headers->get('Content-Type'));
self::assertSelectorExists('form#preauth-form');
}
public function testFailedLoginWithSpentNonceIsRejected(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
// first: successful login consumes the nonce
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
]),
]);
self::assertSame(303, $client->getResponse()->getStatusCode());
// the successful login set a session cookie; clear it so the next
// request is not auto-authenticated by AcceptListener before the
// login attempt is even evaluated
$client->getCookieJar()->clear();
// reuse the same nonce — should fail even with a valid token
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
]),
]);
self::assertSame(401, $client->getResponse()->getStatusCode());
}
public function testFailedLoginWithInvalidNonceIsRejected(): void
{
$client = static::createClient();
// skip fetching a real nonce; use one that was never stored
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice',
'token' => $this->validTotpCode(),
'nonce' => 'never-issued-nonce',
'json' => true,
]),
]);
self::assertSame(401, $client->getResponse()->getStatusCode());
}
/* ── invalid payload ──────────────────────────────────────────────── */
public function testInvalidHeaderPayloadReturnsUnauthorized(): void
{
$client = static::createClient();
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => '!!!not-valid-base64!!!',
]);
// decode fails -> null payload -> failure path -> 401
self::assertSame(401, $client->getResponse()->getStatusCode());
}
public function testPayloadWithMissingFieldsReturnsUnauthorized(): void
{
$client = static::createClient();
// payload missing token
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice', 'nonce' => 'some-nonce',
]),
]);
self::assertSame(401, $client->getResponse()->getStatusCode());
}
/* ── invalid cookie ───────────────────────────────────────────────── */
public function testInvalidCookieIsClearedAndLoginPageShown(): void
{
$client = static::createClient();
// the cookie must be set via the CookieJar so that the HttpFoundation
// Request actually populates its cookies bag (HTTP_COOKIE alone is
// not parsed by Request::create)
$client->getCookieJar()->set(
new \Symfony\Component\BrowserKit\Cookie(
self::COOKIE_NAME,
'invalid-ulid-value',
null,
'/',
'localhost',
true,
true,
false,
'Strict',
)
);
$client->request('GET', 'https://localhost/');
$response = $client->getResponse();
// not authenticated -> login page with 401
self::assertSame(401, $response->getStatusCode());
// the stale cookie should be cleared
$cleared = false;
foreach ($response->headers->getCookies() as $cookie) {
if ($cookie->getName() === self::COOKIE_NAME && $cookie->isCleared()) {
$cleared = true;
}
}
self::assertTrue($cleared, 'Expected the invalid cookie to be cleared');
}
/* ── backup code authentication ───────────────────────────────────── */
public function testBackupCodeAuthenticationWorks(): void
{
$client = static::createClient();
$container = $client->getContainer();
// generate a backup code via the BackupCodeManager
$manager = $container->get(\App\Service\BackupCodeInterface::class);
$codes = $manager->generate(1);
self::assertCount(1, $codes);
$crawler = $client->request('GET', '/');
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'frank',
'token' => $codes[0],
'nonce' => $nonce,
'json' => true,
]),
]);
self::assertSame(303, $client->getResponse()->getStatusCode());
}
public function testConsumedBackupCodeCannotBeReused(): void
{
$client = static::createClient();
$container = $client->getContainer();
$manager = $container->get(\App\Service\BackupCodeInterface::class);
$codes = $manager->generate(1);
$code = $codes[0];
// first use
$crawler = $client->request('GET', '/');
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'frank', 'token' => $code, 'nonce' => $nonce, 'json' => true,
]),
]);
self::assertSame(303, $client->getResponse()->getStatusCode());
// the successful login set a session cookie; clear it so the next
// request reaches the login page instead of being auto-authenticated
$client->getCookieJar()->clear();
// second use with a fresh nonce
$crawler = $client->request('GET', '/');
$nonce2 = $crawler->filter('input[name="nonce"]')->attr('value');
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'frank', 'token' => $code, 'nonce' => $nonce2, 'json' => true,
]),
]);
self::assertSame(401, $client->getResponse()->getStatusCode());
}
/* ── return URL handling ──────────────────────────────────────────── */
public function testSuccessfulLoginWithValidReturnUrl(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/?return=https://example.com/app');
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
$client->request('GET', '/?return=https://example.com/app', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice', 'token' => $this->validTotpCode(),
'nonce' => $nonce, 'json' => true,
]),
]);
$response = $client->getResponse();
self::assertSame(303, $response->getStatusCode());
self::assertSame('https://example.com/app', $response->headers->get('Location'));
}
public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/?return=not-a-url');
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
$client->request('GET', '/?return=not-a-url', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice', 'token' => $this->validTotpCode(),
'nonce' => $nonce, 'json' => true,
]),
]);
$response = $client->getResponse();
self::assertSame(303, $response->getStatusCode());
$location = $response->headers->get('Location');
// should fall back to the request path (with query string),
// not redirect to the invalid return URL as an absolute URL
self::assertStringStartsWith('/', $location);
// the invalid return URL is not used as the redirect target
self::assertStringNotContainsString('//not-a-url', $location);
}
}