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
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user