feat: harden the myep OAuth2 login

This commit is contained in:
fromme committed 2026-09-23 15:18:43 +02:00
1 parent 4517b84023
commit 91419294b8
25 files changed
+1013 -35

No files matched your search

+141
View File
@@ -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
*/