feat: harden the myep OAuth2 login
This commit is contained in:
1 parent
4517b84023
commit
91419294b8
25 files changed
+1013
-35
No files matched your search
@@ -7,6 +7,7 @@ namespace App\Tests\Security;
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Security\EpAuthenticator;
|
||||
use App\Security\OAuth2\AuthorizationDeniedException;
|
||||
use App\Security\OAuth2\AuthorizationRequestException;
|
||||
use App\Security\OAuth2\EpClient;
|
||||
use App\Security\OAuth2\UserinfoUnavailableException;
|
||||
@@ -281,6 +282,146 @@ class EpAuthenticatorTest extends KernelTestCase
|
||||
self::assertFalse($authenticator->supports($dashboard));
|
||||
}
|
||||
|
||||
/**
|
||||
* `sub` is the account key. An address change on myep has to land on the existing row --
|
||||
* keying on the address would leave the old one orphaned with all its bookings and hand the
|
||||
* person an empty account with no explanation.
|
||||
*/
|
||||
public function testAnAccountIsMatchedOnSubWhenTheEmailChanges(): void
|
||||
{
|
||||
$existing = new User('[email protected]', 'Anna Berger');
|
||||
$existing->setSub('4711');
|
||||
$existing->setRoles([Role::EMPLOYEE]);
|
||||
$this->em->persist($existing);
|
||||
$this->em->flush();
|
||||
|
||||
$user = $this->authenticate([
|
||||
'sub' => '4711',
|
||||
'email' => '[email protected]',
|
||||
'roles' => ['ROLE_EMPLOYEE'],
|
||||
]);
|
||||
|
||||
self::assertSame($existing->getId(), $user->getId());
|
||||
self::assertSame('[email protected]', $user->getEmail());
|
||||
}
|
||||
|
||||
/**
|
||||
* Two myep accounts for one person -- the private address and the company one -- are
|
||||
* separate by design, and hold different roles.
|
||||
*/
|
||||
public function testADifferentSubIsADifferentAccount(): void
|
||||
{
|
||||
$existing = new User('[email protected]', 'Anna Berger');
|
||||
$existing->setSub('4711');
|
||||
$existing->setRoles([Role::EMPLOYEE]);
|
||||
$this->em->persist($existing);
|
||||
$this->em->flush();
|
||||
|
||||
$user = $this->authenticate(['sub' => '4712', 'email' => '[email protected]', 'roles' => ['ROLE_ADMIN']]);
|
||||
|
||||
self::assertNotSame($existing->getId(), $user->getId());
|
||||
self::assertSame('4712', $user->getSub());
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows provisioned before the column existed carry no sub. They are matched on their address
|
||||
* once more, and the backfill is what retires that fallback.
|
||||
*/
|
||||
public function testARowFromBeforeTheSubColumnIsMatchedOnEmailAndBackfilled(): void
|
||||
{
|
||||
$existing = new User('[email protected]', 'Anna Berger');
|
||||
$existing->setRoles([Role::EMPLOYEE]);
|
||||
$this->em->persist($existing);
|
||||
$this->em->flush();
|
||||
|
||||
$user = $this->authenticate(['sub' => '4711', 'email' => '[email protected]', 'roles' => ['ROLE_EMPLOYEE']]);
|
||||
|
||||
self::assertSame($existing->getId(), $user->getId());
|
||||
self::assertSame('4711', $user->getSub());
|
||||
}
|
||||
|
||||
/**
|
||||
* The unique index is byte-exact, so an unnormalised claim would make a second account for
|
||||
* somebody who already has one.
|
||||
*/
|
||||
public function testTheEmailClaimIsNormalisedBeforeItIsMatched(): void
|
||||
{
|
||||
$existing = new User('[email protected]', 'Anna Berger');
|
||||
$existing->setRoles([Role::EMPLOYEE]);
|
||||
$this->em->persist($existing);
|
||||
$this->em->flush();
|
||||
|
||||
$user = $this->authenticate(['email' => ' [email protected] ', 'roles' => ['ROLE_EMPLOYEE']]);
|
||||
|
||||
self::assertSame($existing->getId(), $user->getId());
|
||||
self::assertSame('[email protected]', $user->getEmail());
|
||||
}
|
||||
|
||||
/**
|
||||
* Only reachable from a stale local address. Provisioning would hit the unique index and
|
||||
* fail as a 500, so it is refused with a record naming both rows instead.
|
||||
*/
|
||||
public function testAnEmailHeldByAnotherAccountIsRefused(): void
|
||||
{
|
||||
$existing = new User('[email protected]', 'Anna Berger');
|
||||
$existing->setSub('4711');
|
||||
$existing->setRoles([Role::EMPLOYEE]);
|
||||
$this->em->persist($existing);
|
||||
$this->em->flush();
|
||||
|
||||
try {
|
||||
$this->authenticate(['sub' => '4712', 'email' => '[email protected]', 'roles' => ['ROLE_EMPLOYEE']]);
|
||||
self::fail('Expected the login to be refused.');
|
||||
} catch (CustomUserMessageAuthenticationException) {
|
||||
}
|
||||
|
||||
self::assertSame('email_belongs_to_another_account', $this->audited('Sign-in refused.')->context['reason']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The point of the local lockout: myep still reports the person as eligible, and nothing it
|
||||
* says may reach the row. The refusal itself is the UserChecker's, on the firewall.
|
||||
*/
|
||||
public function testALockedOutAccountIsReturnedUntouched(): void
|
||||
{
|
||||
$existing = new User('[email protected]', 'Anna Alt');
|
||||
$existing->setSub('4711');
|
||||
$existing->setRoles([Role::EMPLOYEE]);
|
||||
$existing->disable('Auf eigenen Wunsch');
|
||||
$this->em->persist($existing);
|
||||
$this->em->flush();
|
||||
|
||||
$user = $this->authenticate([
|
||||
'sub' => '4711',
|
||||
'email' => '[email protected]',
|
||||
'roles' => ['ROLE_EMPLOYEE', 'ROLE_ADMIN'],
|
||||
'profile' => ['first_name' => 'Anna', 'last_name' => 'Neu'],
|
||||
]);
|
||||
|
||||
self::assertSame($existing->getId(), $user->getId());
|
||||
self::assertTrue($user->isDisabled());
|
||||
self::assertSame('Anna Alt', $user->getName());
|
||||
self::assertNotContains(Role::ADMIN, $user->getRoles());
|
||||
}
|
||||
|
||||
/**
|
||||
* myep explained why it is not sending a code. Reporting that as "carried no authorization
|
||||
* code" would tell the person something untrue and lose the reason -- and it is what keeps
|
||||
* myep on an explanation page of its own instead of the spec's redirect.
|
||||
*/
|
||||
public function testARefusedAuthorizationSaysSoRatherThanBlamingTheCallback(): void
|
||||
{
|
||||
try {
|
||||
$this->authenticator(throwOnToken: new AuthorizationDeniedException('access_denied'))
|
||||
->authenticate(new Request());
|
||||
self::fail('Expected the login to be refused.');
|
||||
} catch (CustomUserMessageAuthenticationException $e) {
|
||||
self::assertStringContainsString('abgebrochen', $e->getMessage());
|
||||
}
|
||||
|
||||
self::assertSame('authorization_denied', $this->audited('Sign-in refused.')->context['reason']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $userinfo
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Security\OAuth2;
|
||||
|
||||
use App\Security\OAuth2\AuthorizationDeniedException;
|
||||
use App\Security\OAuth2\AuthorizationRequestException;
|
||||
use App\Security\OAuth2\EpClient;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Session\Session;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* The two legs of the authorization code flow that do not need myep to answer.
|
||||
*
|
||||
* Everything asserted here happens before a single HTTP request goes out: what the browser is
|
||||
* sent to myep with, and which callbacks are refused without an exchange being attempted.
|
||||
*/
|
||||
class EpClientTest extends TestCase
|
||||
{
|
||||
public function testTheAuthorizationUrlCarriesAnS256Challenge(): void
|
||||
{
|
||||
$request = $this->request();
|
||||
$url = $this->client()->authorizationUrl($request);
|
||||
|
||||
parse_str((string) parse_url($url, \PHP_URL_QUERY), $query);
|
||||
|
||||
self::assertSame('S256', $query['code_challenge_method'] ?? null);
|
||||
self::assertNotEmpty($query['code_challenge'] ?? '');
|
||||
|
||||
// The verifier itself never leaves the session -- what went out is its hash.
|
||||
$verifier = $request->getSession()->get('oauth2pkce');
|
||||
self::assertIsString($verifier);
|
||||
self::assertSame(
|
||||
rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='),
|
||||
$query['code_challenge'],
|
||||
);
|
||||
self::assertSame($query['state'] ?? null, $request->getSession()->get('oauth2state'));
|
||||
}
|
||||
|
||||
/**
|
||||
* A callback that myep explained. Reporting it as a missing code would tell the person
|
||||
* something untrue and lose the reason.
|
||||
*/
|
||||
public function testARefusedAuthorizationIsReportedAsSuch(): void
|
||||
{
|
||||
$request = $this->callbackRequest(['error' => 'access_denied', 'error_description' => 'Nope.']);
|
||||
|
||||
try {
|
||||
$this->client()->fetchAccessToken($request);
|
||||
self::fail('Expected the callback to be refused.');
|
||||
} catch (AuthorizationDeniedException $e) {
|
||||
self::assertSame('access_denied', $e->error);
|
||||
self::assertSame('Nope.', $e->errorDescription);
|
||||
}
|
||||
}
|
||||
|
||||
public function testAMismatchedStateIsRefused(): void
|
||||
{
|
||||
$this->expectException(AuthorizationRequestException::class);
|
||||
$this->expectExceptionMessage('state parameter');
|
||||
|
||||
$this->client()->fetchAccessToken($this->callbackRequest(['code' => 'c', 'state' => 'somebody else\'s']));
|
||||
}
|
||||
|
||||
/**
|
||||
* The classic "no state in session means pass" bug.
|
||||
*/
|
||||
public function testACallbackIntoASessionThatStartedNoFlowIsRefused(): void
|
||||
{
|
||||
$this->expectException(AuthorizationRequestException::class);
|
||||
|
||||
$request = $this->callbackRequest(['code' => 'c', 'state' => 'anything'], state: null);
|
||||
|
||||
$this->client()->fetchAccessToken($request);
|
||||
}
|
||||
|
||||
public function testACallbackWithoutTheVerifierIsRefusedRatherThanExchanged(): void
|
||||
{
|
||||
$this->expectException(AuthorizationRequestException::class);
|
||||
$this->expectExceptionMessage('PKCE verifier');
|
||||
|
||||
$this->client()->fetchAccessToken($this->callbackRequest(['code' => 'c', 'state' => 'st'], verifier: null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Both are consumed whatever the outcome, so the same callback cannot be replayed.
|
||||
*/
|
||||
public function testTheStateAndTheVerifierAreConsumedEvenOnAFailedCallback(): void
|
||||
{
|
||||
$request = $this->callbackRequest(['state' => 'st']);
|
||||
|
||||
try {
|
||||
$this->client()->fetchAccessToken($request);
|
||||
} catch (AuthorizationRequestException) {
|
||||
}
|
||||
|
||||
self::assertFalse($request->getSession()->has('oauth2state'));
|
||||
self::assertFalse($request->getSession()->has('oauth2pkce'));
|
||||
}
|
||||
|
||||
private function client(): EpClient
|
||||
{
|
||||
$urls = $this->createStub(UrlGeneratorInterface::class);
|
||||
$urls->method('generate')->willReturn('https://ephub.test/auth/check');
|
||||
|
||||
return new EpClient($urls, [
|
||||
'client_id' => 'id',
|
||||
'client_secret' => 'secret',
|
||||
'url_authorize' => 'https://myep.test/authorize',
|
||||
'url_access_token' => 'https://myep.test/token',
|
||||
'url_userinfo' => 'https://myep.test/api/userinfo',
|
||||
'scopes' => ['email', 'roles'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function request(): Request
|
||||
{
|
||||
$request = new Request();
|
||||
$request->setSession(new Session(new MockArraySessionStorage()));
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* A callback as it arrives from myep, into a session that started the flow.
|
||||
*
|
||||
* @param array<string, string> $query
|
||||
*/
|
||||
private function callbackRequest(array $query, ?string $state = 'st', ?string $verifier = 'verifier'): Request
|
||||
{
|
||||
$request = $this->request();
|
||||
$request->query->replace($query);
|
||||
|
||||
if (null !== $state) {
|
||||
$request->getSession()->set('oauth2state', $state);
|
||||
}
|
||||
|
||||
if (null !== $verifier) {
|
||||
$request->getSession()->set('oauth2pkce', $verifier);
|
||||
}
|
||||
|
||||
return $request;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Security;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Security\UserChecker;
|
||||
use App\Service\AuditLogger;
|
||||
use Monolog\Handler\TestHandler;
|
||||
use Monolog\Logger;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
|
||||
|
||||
/**
|
||||
* The local lockout, which is the one access decision myep has no say in.
|
||||
*/
|
||||
class UserCheckerTest extends TestCase
|
||||
{
|
||||
private TestHandler $auditRecords;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->auditRecords = new TestHandler();
|
||||
}
|
||||
|
||||
public function testAnOrdinaryAccountPasses(): void
|
||||
{
|
||||
$this->expectNotToPerformAssertions();
|
||||
|
||||
$this->checker()->checkPreAuth(new User('[email protected]', 'Anna'));
|
||||
}
|
||||
|
||||
public function testALockedOutAccountIsRefusedWithItsReason(): void
|
||||
{
|
||||
$user = new User('[email protected]', 'Anna');
|
||||
$user->disable('Auf eigenen Wunsch');
|
||||
|
||||
$this->expectException(CustomUserMessageAccountStatusException::class);
|
||||
$this->expectExceptionMessage('Auf eigenen Wunsch');
|
||||
|
||||
$this->checker()->checkPreAuth($user);
|
||||
}
|
||||
|
||||
public function testALockoutWithoutAReasonStillSaysWhatHappened(): void
|
||||
{
|
||||
$user = new User('[email protected]', 'Anna');
|
||||
$user->disable();
|
||||
|
||||
$this->expectException(CustomUserMessageAccountStatusException::class);
|
||||
$this->expectExceptionMessage('gesperrt');
|
||||
|
||||
$this->checker()->checkPreAuth($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* The authenticator audits the refusals it can see; this one happens after it has handed the
|
||||
* passport over, so the trail would otherwise show a sign-in that simply never completed.
|
||||
*/
|
||||
public function testTheRefusalIsAudited(): void
|
||||
{
|
||||
$user = new User('[email protected]', 'Anna');
|
||||
$user->disable('Auf eigenen Wunsch');
|
||||
|
||||
try {
|
||||
$this->checker()->checkPreAuth($user);
|
||||
} catch (CustomUserMessageAccountStatusException) {
|
||||
}
|
||||
|
||||
$records = $this->auditRecords->getRecords();
|
||||
|
||||
self::assertCount(1, $records);
|
||||
self::assertSame('Sign-in refused.', $records[0]->message);
|
||||
self::assertSame('account_disabled', $records[0]->context['reason']);
|
||||
self::assertSame('[email protected]', $records[0]->context['account']);
|
||||
}
|
||||
|
||||
private function checker(): UserChecker
|
||||
{
|
||||
return new UserChecker(new AuditLogger(new Logger('audit', [$this->auditRecords])));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user