- 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
446 lines
17 KiB
PHP
446 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Service;
|
|
|
|
use App\Data\Payload;
|
|
use App\Enum\Scope;
|
|
use App\Service\BackupCodeInterface;
|
|
use App\Service\DomainManager;
|
|
use App\Trait\StringTrait;
|
|
use App\Service\LoginManager;
|
|
use App\Tests\Support\TotpTestHelper;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Psr\Cache\CacheItemInterface;
|
|
use Psr\Cache\CacheItemPoolInterface;
|
|
use Psr\Log\NullLogger;
|
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
|
|
final class LoginManagerTest extends TestCase
|
|
{
|
|
use TotpTestHelper;
|
|
use StringTrait;
|
|
|
|
private ArrayAdapter $pool;
|
|
private BackupCodeInterface $backupCodeManager;
|
|
private DomainManager $domainManager;
|
|
|
|
private function makeLoginManager(
|
|
?int $ipTtl = 0,
|
|
bool $subdomainRedirect = false,
|
|
string $authSubdomain = '',
|
|
): LoginManager {
|
|
$this->pool = new ArrayAdapter();
|
|
$this->backupCodeManager = $this->createStub(BackupCodeInterface::class);
|
|
$this->domainManager = new DomainManager($subdomainRedirect, $authSubdomain);
|
|
|
|
$manager = new LoginManager($this->pool, $this->backupCodeManager, $this->domainManager);
|
|
$manager->setConfig($this->makeConfig(ipTtl: $ipTtl));
|
|
$manager->setLogger(new NullLogger());
|
|
$manager->setNonceCache(new ArrayAdapter());
|
|
return $manager;
|
|
}
|
|
|
|
/** Build a Payload with a valid server-side nonce already stored. */
|
|
private function makePayloadWithNonce(
|
|
LoginManager $manager,
|
|
string $id = 'testuser',
|
|
Scope $scope = Scope::Cookie,
|
|
?string $token = null,
|
|
): Payload {
|
|
$token ??= $this->validTotpCode();
|
|
$nonce = $this->insertNonce($manager, 'test-nonce-123');
|
|
|
|
$payload = new Payload();
|
|
$payload->id = $id;
|
|
$payload->token = $token;
|
|
$payload->nonce = $nonce;
|
|
$payload->json = true;
|
|
$payload->scope = $scope;
|
|
return $payload;
|
|
}
|
|
|
|
/** Inject a nonce directly into the manager's nonce cache. */
|
|
private function insertNonce(LoginManager $manager, string $nonce): string
|
|
{
|
|
$reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache');
|
|
$nonceCache = $reflection->getValue($manager);
|
|
|
|
$key = $this->makeCacheKey($nonce);
|
|
$item = $nonceCache->getItem($key);
|
|
$item->set(true);
|
|
$nonceCache->save($item);
|
|
|
|
return $nonce;
|
|
}
|
|
|
|
public function testCheckTokenReturnsNullForInvalidTotp(): void
|
|
{
|
|
$manager = $this->makeLoginManager();
|
|
$payload = $this->makePayloadWithNonce($manager, token: 'wrong-code');
|
|
|
|
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
|
|
|
$request = Request::create('/', 'GET');
|
|
|
|
self::assertNull($manager->checkToken($payload, $request));
|
|
}
|
|
|
|
public function testCheckTokenReturnsNullForSpentNonce(): void
|
|
{
|
|
$manager = $this->makeLoginManager();
|
|
$payload = $this->makePayloadWithNonce($manager);
|
|
|
|
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
|
|
|
// spend the nonce first (use the same cache key the manager does)
|
|
$reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache');
|
|
$nonceCache = $reflection->getValue($manager);
|
|
$nonceItem = $nonceCache->getItem($this->makeCacheKey('test-nonce-123'));
|
|
$nonceItem->set(false);
|
|
$nonceCache->save($nonceItem);
|
|
|
|
$request = Request::create('/', 'GET');
|
|
|
|
self::assertNull($manager->checkToken($payload, $request));
|
|
}
|
|
|
|
public function testCheckTokenReturnsNullForMissingNonce(): void
|
|
{
|
|
$manager = $this->makeLoginManager();
|
|
|
|
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
|
|
|
$payload = new Payload();
|
|
$payload->id = 'user1';
|
|
$payload->token = $this->validTotpCode();
|
|
$payload->nonce = 'never-stored';
|
|
$payload->json = true;
|
|
$payload->scope = Scope::Cookie;
|
|
|
|
$request = Request::create('/', 'GET');
|
|
|
|
self::assertNull($manager->checkToken($payload, $request));
|
|
}
|
|
|
|
public function testSuccessfulTotpLoginWithCookieScopeReturnsRedirect(): void
|
|
{
|
|
$manager = $this->makeLoginManager();
|
|
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
|
|
|
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
|
|
|
$request = Request::create('/dashboard', 'GET');
|
|
|
|
$response = $manager->checkToken($payload, $request);
|
|
|
|
self::assertNotNull($response);
|
|
self::assertSame(303, $response->getStatusCode()); // HTTP_SEE_OTHER
|
|
self::assertTrue($response->headers->has('Location'));
|
|
self::assertTrue($response->headers->has('Set-Cookie'));
|
|
}
|
|
|
|
public function testSuccessfulLoginWithNoneScopeReturnsPlainResponse(): void
|
|
{
|
|
$manager = $this->makeLoginManager();
|
|
$payload = $this->makePayloadWithNonce($manager, scope: Scope::None);
|
|
|
|
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
|
|
|
$request = Request::create('/', 'GET');
|
|
|
|
$response = $manager->checkToken($payload, $request);
|
|
|
|
self::assertNotNull($response);
|
|
self::assertSame(200, $response->getStatusCode());
|
|
self::assertSame('text/plain', $response->headers->get('Content-Type'));
|
|
self::assertTrue($response->headers->has('Remote-User'));
|
|
// no redirect for Scope::None
|
|
self::assertFalse($response->headers->has('Location'));
|
|
}
|
|
|
|
public function testSuccessfulLoginSetsRemoteUserHeader(): void
|
|
{
|
|
$manager = $this->makeLoginManager();
|
|
$payload = $this->makePayloadWithNonce($manager, id: 'alice', scope: Scope::None);
|
|
|
|
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
|
|
|
$request = Request::create('/', 'GET');
|
|
|
|
$response = $manager->checkToken($payload, $request);
|
|
|
|
self::assertNotNull($response);
|
|
self::assertSame('alice', $response->headers->get('Remote-User'));
|
|
}
|
|
|
|
public function testSuccessfulLoginJsonResponse(): void
|
|
{
|
|
$manager = $this->makeLoginManager();
|
|
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie, token: null);
|
|
$payload->json = true;
|
|
|
|
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
|
|
|
$request = Request::create('/protected', 'GET');
|
|
|
|
$response = $manager->checkToken($payload, $request);
|
|
|
|
self::assertNotNull($response);
|
|
self::assertSame('application/json', $response->headers->get('Content-Type'));
|
|
$body = json_decode($response->getContent(), true);
|
|
self::assertSame('Login successful', $body['message']);
|
|
}
|
|
|
|
public function testSuccessfulLoginHtmlResponse(): void
|
|
{
|
|
$manager = $this->makeLoginManager();
|
|
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
|
$payload->json = false;
|
|
|
|
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
|
|
|
$request = Request::create('/protected', 'GET');
|
|
|
|
$response = $manager->checkToken($payload, $request);
|
|
|
|
self::assertNotNull($response);
|
|
self::assertSame('text/html', $response->headers->get('Content-Type'));
|
|
}
|
|
|
|
public function testSuccessfulLoginWithReturnUrl(): void
|
|
{
|
|
$manager = $this->makeLoginManager();
|
|
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
|
|
|
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
|
|
|
$request = Request::create('/login?return=https://example.com/app', 'GET');
|
|
|
|
$response = $manager->checkToken($payload, $request);
|
|
|
|
self::assertNotNull($response);
|
|
self::assertSame('https://example.com/app', $response->headers->get('Location'));
|
|
}
|
|
|
|
public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void
|
|
{
|
|
$manager = $this->makeLoginManager();
|
|
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
|
|
|
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
|
|
|
$request = Request::create('/login?return=not-a-url', 'GET');
|
|
|
|
$response = $manager->checkToken($payload, $request);
|
|
|
|
self::assertNotNull($response);
|
|
$location = $response->headers->get('Location');
|
|
self::assertStringStartsWith('/login', $location);
|
|
}
|
|
|
|
public function testIpScopeDowngradesToCookieWhenIpAccessDisabled(): void
|
|
{
|
|
$manager = $this->makeLoginManager(ipTtl: 0);
|
|
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip);
|
|
|
|
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
|
|
|
$request = Request::create('/', 'GET');
|
|
|
|
$response = $manager->checkToken($payload, $request);
|
|
|
|
// Should have a Set-Cookie (downgraded to cookie scope)
|
|
self::assertNotNull($response);
|
|
self::assertTrue($response->headers->has('Set-Cookie'));
|
|
}
|
|
|
|
public function testIpScopeWhenEnabledSetsIpSession(): void
|
|
{
|
|
$manager = $this->makeLoginManager(ipTtl: 1800);
|
|
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip);
|
|
|
|
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
|
|
|
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
|
|
|
$response = $manager->checkToken($payload, $request);
|
|
|
|
self::assertNotNull($response);
|
|
// IP session should be stored; no Set-Cookie for IP scope
|
|
self::assertFalse($response->headers->has('Set-Cookie'));
|
|
|
|
// verify the IP session exists in the cache
|
|
$reflection = new \ReflectionProperty(LoginManager::class, 'sessionCache');
|
|
$sessionCache = $reflection->getValue($manager);
|
|
self::assertTrue($sessionCache->hasItem('ip_1.2.3.4'));
|
|
}
|
|
|
|
public function testBackupCodeAuthentication(): void
|
|
{
|
|
$manager = $this->makeLoginManager();
|
|
$payload = $this->makePayloadWithNonce($manager, token: 'backup-code-123');
|
|
|
|
$this->backupCodeManager->method('verifyAndConsume')->willReturn(true);
|
|
|
|
$request = Request::create('/', 'GET');
|
|
|
|
$response = $manager->checkToken($payload, $request);
|
|
|
|
self::assertNotNull($response);
|
|
self::assertSame(303, $response->getStatusCode());
|
|
}
|
|
|
|
public function testNonceIsConsumedAfterSuccessfulLogin(): void
|
|
{
|
|
$manager = $this->makeLoginManager();
|
|
$payload = $this->makePayloadWithNonce($manager);
|
|
|
|
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
|
|
|
$request = Request::create('/', 'GET');
|
|
|
|
$manager->checkToken($payload, $request);
|
|
|
|
// nonce should now be marked invalid (false); look it up via the same
|
|
// cache key the manager uses (makeCacheKey rewrites '-' to '_')
|
|
$reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache');
|
|
$nonceCache = $reflection->getValue($manager);
|
|
$nonceItem = $nonceCache->getItem($this->makeCacheKey('test-nonce-123'));
|
|
self::assertFalse($nonceItem->get());
|
|
}
|
|
|
|
public function testUlidCollisionThrowsHttpException(): void
|
|
{
|
|
// Use a stub pool where every cookie_ key is already a hit (collision)
|
|
$pool = $this->createStub(CacheItemPoolInterface::class);
|
|
$item = $this->createStub(CacheItemInterface::class);
|
|
$item->method('isHit')->willReturn(true);
|
|
$item->method('get')->willReturn('existing');
|
|
// The nonce cache needs to work, so we return the stub item for
|
|
// cookie_ keys but a real working item for nonce keys.
|
|
$pool->method('getItem')->willReturnCallback(function (string $key) use ($item) {
|
|
if (str_starts_with($key, 'cookie_')) {
|
|
return $item; // collision
|
|
}
|
|
// For nonce keys, return a real item from an ArrayAdapter
|
|
static $realPool = null;
|
|
$realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter();
|
|
return $realPool->getItem($key);
|
|
});
|
|
$pool->method('hasItem')->willReturnCallback(function (string $key) use ($item) {
|
|
if (str_starts_with($key, 'cookie_')) {
|
|
return true;
|
|
}
|
|
static $realPool = null;
|
|
$realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter();
|
|
return $realPool->hasItem($key);
|
|
});
|
|
$pool->method('save')->willReturn(true);
|
|
$pool->method('saveDeferred')->willReturn(true);
|
|
$pool->method('commit')->willReturn(true);
|
|
$pool->method('getItems')->willReturnCallback(function (array $keys) {
|
|
static $realPool = null;
|
|
$realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter();
|
|
return $realPool->getItems($keys);
|
|
});
|
|
$pool->method('clear')->willReturn(true);
|
|
$pool->method('deleteItem')->willReturn(true);
|
|
$pool->method('deleteItems')->willReturn(true);
|
|
|
|
$this->domainManager = new DomainManager(false, '');
|
|
$this->backupCodeManager = $this->createStub(BackupCodeInterface::class);
|
|
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
|
|
|
$manager = new LoginManager($pool, $this->backupCodeManager, $this->domainManager);
|
|
$manager->setConfig($this->makeConfig());
|
|
$manager->setLogger(new NullLogger());
|
|
$manager->setNonceCache(new \Symfony\Component\Cache\Adapter\ArrayAdapter());
|
|
|
|
$payload = new Payload();
|
|
$payload->id = 'collide-user';
|
|
$payload->token = $this->validTotpCode();
|
|
$payload->nonce = 'test-nonce-123';
|
|
$payload->json = true;
|
|
$payload->scope = Scope::Cookie;
|
|
|
|
// inject the nonce
|
|
$this->insertNonce($manager, 'test-nonce-123');
|
|
|
|
$request = Request::create('/', 'GET');
|
|
|
|
$this->expectException(HttpException::class);
|
|
$manager->checkToken($payload, $request);
|
|
}
|
|
|
|
public function testCookieScopeWithCentralAuthSetsDomainOnMatchingHost(): void
|
|
{
|
|
$manager = $this->makeLoginManager(
|
|
subdomainRedirect: true,
|
|
authSubdomain: 'auth.example.com',
|
|
);
|
|
$payload = $this->makePayloadWithNonce($manager, id: 'alice', scope: Scope::Cookie);
|
|
|
|
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
|
|
|
// host matches the auth base domain
|
|
$request = Request::create('https://auth.example.com/', 'GET');
|
|
|
|
$response = $manager->checkToken($payload, $request);
|
|
|
|
self::assertNotNull($response);
|
|
$cookies = $response->headers->getCookies();
|
|
self::assertCount(1, $cookies);
|
|
// when using central auth and host matches, the cookie domain is set
|
|
self::assertSame('example.com', $cookies[0]->getDomain());
|
|
// the auth cookie name is used instead of the host-prefixed name
|
|
self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName());
|
|
}
|
|
|
|
public function testCookieScopeWithCentralAuthOnNonMatchingHostUsesNullDomain(): void
|
|
{
|
|
$manager = $this->makeLoginManager(
|
|
subdomainRedirect: true,
|
|
authSubdomain: 'auth.example.com',
|
|
);
|
|
$payload = $this->makePayloadWithNonce($manager, id: 'bob', scope: Scope::Cookie);
|
|
|
|
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
|
|
|
// host does NOT match the auth base domain
|
|
$request = Request::create('https://other.com/', 'GET');
|
|
|
|
$response = $manager->checkToken($payload, $request);
|
|
|
|
self::assertNotNull($response);
|
|
$cookies = $response->headers->getCookies();
|
|
self::assertCount(1, $cookies);
|
|
// domain is null when host does not match
|
|
self::assertNull($cookies[0]->getDomain());
|
|
// still uses auth cookie name since authBase is set
|
|
self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName());
|
|
}
|
|
|
|
public function testCheckTokenWithEmptyReturnParameterFallsBackToPath(): void
|
|
{
|
|
$manager = $this->makeLoginManager();
|
|
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
|
|
|
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
|
|
|
// return parameter is present but empty string
|
|
$request = Request::create('/?return=', 'GET');
|
|
|
|
$response = $manager->checkToken($payload, $request);
|
|
|
|
self::assertNotNull($response);
|
|
$location = $response->headers->get('Location');
|
|
self::assertNotNull($location);
|
|
// should fall back to path since empty string is not a valid URL
|
|
self::assertStringStartsWith('/', $location);
|
|
}
|
|
}
|