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
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Service\AuditLogger;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Locks a local account out of ephub, or lets it back in.
|
||||
*
|
||||
* The lever the UserChecker reads. Everything else about an account follows myep -- roles are
|
||||
* re-read on every login, and deleting a row only means the next login provisions it again -- so
|
||||
* without this there is no way to refuse somebody here that does not depend on myep agreeing.
|
||||
*
|
||||
* A command rather than an admin screen: locking somebody out is rare, and a screen for it would
|
||||
* be one more thing in /admin that the person being locked out might reach first.
|
||||
*/
|
||||
#[AsCommand(name: 'app:user:lock', description: 'Locks a user out of ephub, or releases the lock')]
|
||||
final class LockUserCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $users,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly AuditLogger $audit,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('email', InputArgument::REQUIRED, 'The address of the account')
|
||||
->addArgument('reason', InputArgument::OPTIONAL, 'Shown to the person on their next login attempt')
|
||||
->addOption('release', null, InputOption::VALUE_NONE, 'Lets the account sign in again');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
// Matched the way a login matches it, or an address typed in a different case would
|
||||
// report "no account" for a row that is plainly there.
|
||||
$email = mb_strtolower(trim((string) $input->getArgument('email')));
|
||||
$user = $this->users->findOneByEmail($email);
|
||||
|
||||
if (!$user instanceof User) {
|
||||
$io->error(\sprintf('No account for %s.', $email));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($input->getOption('release')) {
|
||||
$user->enable();
|
||||
$this->em->flush();
|
||||
$this->audit->accountUnlocked($user);
|
||||
|
||||
$io->success(\sprintf('%s may sign in again.', $email));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$reason = $input->getArgument('reason');
|
||||
$reason = \is_string($reason) && '' !== trim($reason) ? trim($reason) : null;
|
||||
|
||||
$user->disable($reason);
|
||||
$this->em->flush();
|
||||
$this->audit->accountLocked($user, $reason);
|
||||
|
||||
// The lock is applied at authentication, so a session opened before it survives until it
|
||||
// expires. Said out loud rather than left to be discovered.
|
||||
$io->success(\sprintf('%s is locked out. A session opened before now stays valid until it expires.', $email));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,11 @@ final class LogoutController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* Never executed: the firewall's logout listener intercepts this route.
|
||||
*
|
||||
* POST only, and CSRF-protected on the firewall: a logout that a GET could trigger is one
|
||||
* any third-party page can trigger by embedding the URL.
|
||||
*/
|
||||
#[Route('/logout', name: 'app_logout', methods: ['GET'])]
|
||||
#[Route('/logout', name: 'app_logout', methods: ['POST'])]
|
||||
public function index(): never
|
||||
{
|
||||
throw new \LogicException('Intercepted by the logout key on the main firewall.');
|
||||
|
||||
@@ -18,6 +18,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||
#[ORM\Table(name: '`user`')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_user_email', columns: ['email'])]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_user_sub', columns: ['sub'])]
|
||||
class User implements UserInterface
|
||||
{
|
||||
#[ORM\Id]
|
||||
@@ -25,6 +26,20 @@ class User implements UserInterface
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
/**
|
||||
* myep's account identifier: opaque, stable, and the only claim that identifies an account.
|
||||
* Nullable because rows provisioned before it was stored have none -- those are matched on
|
||||
* their email once more and backfilled on that login, so the column empties out by itself.
|
||||
*/
|
||||
#[ORM\Column(length: 64, nullable: true)]
|
||||
private ?string $sub = null;
|
||||
|
||||
/**
|
||||
* The address this session authenticated with, normalised to lower case. Distinct per myep
|
||||
* account and kept up to date from the claim, but a display and contact value rather than
|
||||
* the key -- one person can hold several myep accounts, each with its own address and its
|
||||
* own roles.
|
||||
*/
|
||||
#[ORM\Column(length: 180)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Email]
|
||||
@@ -42,12 +57,34 @@ class User implements UserInterface
|
||||
#[ORM\Column]
|
||||
private array $roles = [];
|
||||
|
||||
/**
|
||||
* When the account was locked out here, or null while it may sign in.
|
||||
*
|
||||
* Local and deliberately not derived from anything myep says: it is the only lever this
|
||||
* application has over its own access, and a login must not be able to clear it.
|
||||
*/
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $disabledAt = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $disabledReason = null;
|
||||
|
||||
public function __construct(string $email, string $name)
|
||||
{
|
||||
$this->email = $email;
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
public function getSub(): ?string
|
||||
{
|
||||
return $this->sub;
|
||||
}
|
||||
|
||||
public function setSub(?string $sub): void
|
||||
{
|
||||
$this->sub = $sub;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -58,6 +95,11 @@ class User implements UserInterface
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function setEmail(string $email): void
|
||||
{
|
||||
$this->email = $email;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
@@ -89,6 +131,37 @@ class User implements UserInterface
|
||||
$this->roles = $roles;
|
||||
}
|
||||
|
||||
public function isDisabled(): bool
|
||||
{
|
||||
return null !== $this->disabledAt;
|
||||
}
|
||||
|
||||
public function getDisabledAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->disabledAt;
|
||||
}
|
||||
|
||||
public function getDisabledReason(): ?string
|
||||
{
|
||||
return $this->disabledReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-locking an already locked account leaves the original timestamp alone: when the lockout
|
||||
* started is the part worth keeping.
|
||||
*/
|
||||
public function disable(?string $reason = null): void
|
||||
{
|
||||
$this->disabledAt ??= new \DateTimeImmutable();
|
||||
$this->disabledReason = $reason;
|
||||
}
|
||||
|
||||
public function enable(): void
|
||||
{
|
||||
$this->disabledAt = null;
|
||||
$this->disabledReason = null;
|
||||
}
|
||||
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
}
|
||||
|
||||
@@ -22,4 +22,12 @@ class UserRepository extends ServiceEntityRepository
|
||||
{
|
||||
return $this->findOneBy(['email' => $email]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The account myep's `sub` claim names, if this application has met it before.
|
||||
*/
|
||||
public function findOneBySub(string $sub): ?User
|
||||
{
|
||||
return $this->findOneBy(['sub' => $sub]);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Security;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Security\OAuth2\AuthorizationDeniedException;
|
||||
use App\Security\OAuth2\AuthorizationRequestException;
|
||||
use App\Security\OAuth2\EpClient;
|
||||
use App\Security\OAuth2\UserinfoUnavailableException;
|
||||
@@ -56,6 +57,17 @@ class EpAuthenticator extends AbstractAuthenticator
|
||||
{
|
||||
try {
|
||||
$accessToken = $this->client->fetchAccessToken($request);
|
||||
} catch (AuthorizationDeniedException $e) {
|
||||
// myep said why, so the person gets that instead of a generic failure. Caught before
|
||||
// its parent: every other broken callback still falls into the branch below.
|
||||
$this->logger->error('Login via myep refused by the authorization server', ['exception' => $e]);
|
||||
$this->audit->signInRefused('access_denied' === $e->error ? 'authorization_denied' : 'authorization_error');
|
||||
|
||||
throw new CustomUserMessageAuthenticationException(
|
||||
'access_denied' === $e->error
|
||||
? 'Die Anmeldung über myep wurde abgebrochen.'
|
||||
: 'Anmeldung über myep fehlgeschlagen.',
|
||||
);
|
||||
} catch (AuthorizationRequestException|IdentityProviderException $e) {
|
||||
$this->logger->error('Login via myep failed: no access token', ['exception' => $e]);
|
||||
// Audited here rather than from a failure event: this is the only point that still
|
||||
@@ -75,22 +87,61 @@ class EpAuthenticator extends AbstractAuthenticator
|
||||
throw new CustomUserMessageAuthenticationException('Anmeldung über myep fehlgeschlagen.');
|
||||
}
|
||||
|
||||
// `email` is the address this session actually authenticated with, and it is what User
|
||||
// is keyed on. It is not myep's account identifier -- that is the opaque, stable `sub`
|
||||
// claim, which myep's consumer guide asks consumers to match on; keying on email here
|
||||
// is deliberate, at the price of a myep-side email change reading as a new account.
|
||||
// Never profile.communication.email: that is the first contact address on the BusPro
|
||||
// record, shared by every account of the same person. myep patches this claim from the
|
||||
// `email` is the address this session actually authenticated with -- a display and
|
||||
// contact value, kept up to date but not the account key. Never
|
||||
// profile.communication.email: that is the first contact address on the BusPro record,
|
||||
// shared by every account of the same person. myep patches this claim from the
|
||||
// authenticated account and exports no fallback, so an absent one is a myep-side fault
|
||||
// rather than an ungranted scope -- refused either way.
|
||||
$email = $userinfo['email'] ?? null;
|
||||
if (!\is_string($email) || '' === $email) {
|
||||
if (!\is_string($email) || '' === trim($email)) {
|
||||
$this->logger->error('Login via myep failed: userinfo carried no email claim');
|
||||
$this->audit->signInRefused('no_email_claim');
|
||||
|
||||
throw new CustomUserMessageAuthenticationException('Anmeldung über myep fehlgeschlagen.');
|
||||
}
|
||||
|
||||
// Normalised before it is compared with or written to anything: the unique index is
|
||||
// byte-exact, so [email protected] and [email protected] would otherwise become two
|
||||
// accounts for one person. Same as the sibling `myep-team` client does.
|
||||
$email = mb_strtolower(trim($email));
|
||||
|
||||
// `sub` is what actually identifies the account: opaque, stable, and unique per myep
|
||||
// account. One person can hold several -- BusPro accepts any address on their record as
|
||||
// a login and each one is its own account with its own roles -- so the same human
|
||||
// signing in privately and with their @ep-reisen.de address is two accounts by design,
|
||||
// and an address change on myep must not read as a third.
|
||||
$sub = $userinfo['sub'] ?? null;
|
||||
$sub = \is_string($sub) && '' !== $sub ? $sub : null;
|
||||
|
||||
$user = $this->findLocal($sub, $email);
|
||||
|
||||
// The address belongs to a row that is a different myep account. Only reachable from a
|
||||
// stale local address -- myep gives each account its own -- and provisioning would hit
|
||||
// the unique index and fail as a 500. Refused instead, so the trail names the two rows
|
||||
// and an administrator can correct the stale one.
|
||||
if (null === $user && null !== $conflict = $this->users->findOneByEmail($email)) {
|
||||
$this->logger->error('Login via myep refused: the email claim belongs to another account', [
|
||||
'email' => $email,
|
||||
'sub' => $sub,
|
||||
'conflicting_user_id' => $conflict->getId(),
|
||||
]);
|
||||
$this->audit->signInRefused('email_belongs_to_another_account', $email);
|
||||
|
||||
throw new CustomUserMessageAuthenticationException('Anmeldung über myep fehlgeschlagen.');
|
||||
}
|
||||
|
||||
// A locked-out account is returned untouched -- no name, no email, no roles, nothing
|
||||
// written back -- so that myep cannot undo a local lockout by reporting the person as
|
||||
// eligible again. The UserChecker on the firewall refuses the login and says why.
|
||||
if (null !== $user && $user->isDisabled()) {
|
||||
$this->logger->info('Login via myep refused: account disabled locally', ['email' => $email]);
|
||||
|
||||
return new SelfValidatingPassport(
|
||||
new UserBadge($user->getUserIdentifier(), static fn (): User => $user),
|
||||
);
|
||||
}
|
||||
|
||||
$roles = Role::eligibleOnly($this->claimedRoles($userinfo));
|
||||
if ([] === $roles) {
|
||||
$this->logger->info('Login via myep refused: no eligible role', ['email' => $email]);
|
||||
@@ -101,7 +152,7 @@ class EpAuthenticator extends AbstractAuthenticator
|
||||
);
|
||||
}
|
||||
|
||||
$user = $this->createOrUpdate($email, $this->nameFrom($userinfo, $email), $roles);
|
||||
$user = $this->createOrUpdate($user, $sub, $email, $this->nameFrom($userinfo, $email), $roles);
|
||||
|
||||
// Resolved here rather than by the user provider: the account may have just been
|
||||
// created in this very request, and handing the provider an email would mean a second
|
||||
@@ -162,12 +213,31 @@ class EpAuthenticator extends AbstractAuthenticator
|
||||
return mb_substr($fallback, 0, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* The account this login belongs to, if ephub has met it before.
|
||||
*
|
||||
* `sub` first. The email fallback is for rows provisioned before the column existed: they
|
||||
* carry no `sub` yet, and matching them once more on their address is what lets the backfill
|
||||
* in createOrUpdate() reach them. It can go once every account has signed in once -- by then
|
||||
* every row carries a `sub`, and a row that does is deliberately not matched this way,
|
||||
* because a shared address would mean it belongs to somebody else's account.
|
||||
*/
|
||||
private function findLocal(?string $sub, string $email): ?User
|
||||
{
|
||||
if (null !== $sub && null !== $user = $this->users->findOneBySub($sub)) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
$user = $this->users->findOneByEmail($email);
|
||||
|
||||
return null !== $user && null === $user->getSub() ? $user : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
private function createOrUpdate(string $email, string $name, array $roles): User
|
||||
private function createOrUpdate(?User $user, ?string $sub, string $email, string $name, array $roles): User
|
||||
{
|
||||
$user = $this->users->findOneBy(['email' => $email]);
|
||||
$isNew = null === $user;
|
||||
|
||||
if (null === $user) {
|
||||
@@ -175,6 +245,15 @@ class EpAuthenticator extends AbstractAuthenticator
|
||||
$this->em->persist($user);
|
||||
} else {
|
||||
$user->setName($name);
|
||||
// Follows myep: the address on the account is whichever one this session signed in
|
||||
// with, and on a row matched by `sub` that may be a new one.
|
||||
$user->setEmail($email);
|
||||
}
|
||||
|
||||
// Backfills a row that predates the column, and is a no-op on every later login. Never
|
||||
// clears one: a userinfo response that dropped the claim must not un-key an account.
|
||||
if (null !== $sub) {
|
||||
$user->setSub($sub);
|
||||
}
|
||||
|
||||
// Filtered the same way the claim was, so the comparison below is between two sets of
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security\OAuth2;
|
||||
|
||||
/**
|
||||
* myep answered the authorization request with `?error=...` instead of a code.
|
||||
*
|
||||
* Separate from its parent because this is the one failure that myep actually explained: the
|
||||
* flow did not break, it was refused, and `access_denied` in particular means the person said no
|
||||
* on the consent screen. Reporting that as "the callback carried no authorization code" tells
|
||||
* them something untrue and loses the reason.
|
||||
*
|
||||
* myep currently shows an explanation page of its own rather than redirecting back here when an
|
||||
* account may not use ephub, precisely because this branch used to be missing. Handling it is
|
||||
* what lets myep move to the spec's `error=access_denied` redirect later.
|
||||
*/
|
||||
final class AuthorizationDeniedException extends AuthorizationRequestException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $error,
|
||||
public readonly ?string $errorDescription = null,
|
||||
) {
|
||||
parent::__construct(\sprintf(
|
||||
'myep refused the authorization request: %s%s',
|
||||
$this->error,
|
||||
null === $this->errorDescription ? '' : ' ('.$this->errorDescription.')',
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,12 @@ declare(strict_types=1);
|
||||
namespace App\Security\OAuth2;
|
||||
|
||||
/**
|
||||
* The callback from myep was not a usable authorization response -- no code, or a state that
|
||||
* does not match the one this session started with.
|
||||
* The callback from myep was not a usable authorization response -- no code, a state that does
|
||||
* not match the one this session started with, or a missing PKCE verifier.
|
||||
*
|
||||
* Not final: AuthorizationDeniedException is the one case that carries a reason worth telling
|
||||
* the user, and every existing catch of this class is meant to keep covering it.
|
||||
*/
|
||||
final class AuthorizationRequestException extends \RuntimeException
|
||||
class AuthorizationRequestException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -29,6 +29,9 @@ class EpClient
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
// The client secret rides in here. Marked sensitive so that it is redacted from stack
|
||||
// traces rather than printed in full on an exception page or in a log.
|
||||
#[\SensitiveParameter]
|
||||
array $options,
|
||||
) {
|
||||
$this->config = $this->resolveConfig($options);
|
||||
@@ -44,14 +47,20 @@ class EpClient
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL to send the browser to, having stored the state this session expects back.
|
||||
* The URL to send the browser to, having stored the state and the PKCE verifier this session
|
||||
* expects back.
|
||||
*/
|
||||
public function authorizationUrl(Request $request): string
|
||||
{
|
||||
$provider = $this->getProvider();
|
||||
$url = $provider->getAuthorizationUrl();
|
||||
|
||||
$request->getSession()->set('oauth2state', $provider->getState());
|
||||
$session = $request->getSession();
|
||||
$session->set('oauth2state', $provider->getState());
|
||||
// Generated by getAuthorizationUrl() together with the S256 challenge that just went out
|
||||
// in the URL, and replayed on the token request. Held on the session rather than
|
||||
// anywhere the browser can reach, which is the whole point of the exercise.
|
||||
$session->set('oauth2pkce', $provider->getPkceCode());
|
||||
|
||||
return $url;
|
||||
}
|
||||
@@ -61,8 +70,10 @@ class EpClient
|
||||
*
|
||||
* The state check is the CSRF protection of the authorization code flow: without it an
|
||||
* attacker can hand a victim a callback URL carrying the attacker's code and have the
|
||||
* victim's session bound to the attacker's identity. The stored state is consumed either
|
||||
* way, so replaying the same callback cannot succeed twice.
|
||||
* victim's session bound to the attacker's identity. PKCE binds the code a second time, to a
|
||||
* secret that never left this session -- unlike the redirect URI, which is generated from
|
||||
* the request's own Host header. The state and the verifier are consumed either way, so
|
||||
* replaying the same callback cannot succeed twice.
|
||||
*
|
||||
* @throws AuthorizationRequestException
|
||||
*/
|
||||
@@ -70,18 +81,42 @@ class EpClient
|
||||
{
|
||||
$session = $request->getSession();
|
||||
$expectedState = $session->get('oauth2state');
|
||||
$verifier = $session->get('oauth2pkce');
|
||||
$session->remove('oauth2state');
|
||||
$session->remove('oauth2pkce');
|
||||
|
||||
// Before everything else: myep explained why it is not sending a code, and any later
|
||||
// check would report the wrong cause.
|
||||
$error = $request->query->get('error');
|
||||
if (\is_string($error) && '' !== $error) {
|
||||
$description = $request->query->get('error_description');
|
||||
|
||||
throw new AuthorizationDeniedException($error, \is_string($description) && '' !== $description ? $description : null);
|
||||
}
|
||||
|
||||
$code = $request->query->get('code');
|
||||
if (null === $code || '' === $code) {
|
||||
throw new AuthorizationRequestException('The callback from myep carried no authorization code.');
|
||||
}
|
||||
|
||||
if (null === $expectedState || $request->query->get('state') !== $expectedState) {
|
||||
// hash_equals rather than !==: the value is one the caller already knows, so the timing
|
||||
// channel is worth nothing, but a comparison that guards a session is not the place to
|
||||
// keep the habit of using the fast one.
|
||||
if (!\is_string($expectedState) || !\is_string($state = $request->query->get('state')) || !hash_equals($expectedState, $state)) {
|
||||
throw new AuthorizationRequestException('The state parameter does not match the one this session started with.');
|
||||
}
|
||||
|
||||
return $this->getProvider()->getAccessToken('authorization_code', ['code' => $code]);
|
||||
// A challenge went out with the authorization request, so myep will reject an exchange
|
||||
// without the verifier. Refused here instead, where the reason is still known -- the
|
||||
// usual cause is a session that was replaced between the two legs of the flow.
|
||||
if (!\is_string($verifier) || '' === $verifier) {
|
||||
throw new AuthorizationRequestException('This session no longer holds the PKCE verifier the authorization request was made with.');
|
||||
}
|
||||
|
||||
$provider = $this->getProvider();
|
||||
$provider->setPkceCode($verifier);
|
||||
|
||||
return $provider->getAccessToken('authorization_code', ['code' => $code]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,12 +145,25 @@ class EpClient
|
||||
throw new UserinfoUnavailableException('The userinfo endpoint could not be reached.', previous: $e);
|
||||
}
|
||||
|
||||
$status = $response->getStatusCode();
|
||||
if ($status < 200 || $status >= 300) {
|
||||
throw new UserinfoUnavailableException(\sprintf('The userinfo endpoint answered with HTTP %d.', $status));
|
||||
}
|
||||
|
||||
$userinfo = json_decode((string) $response->getBody(), true);
|
||||
|
||||
if (!\is_array($userinfo)) {
|
||||
throw new UserinfoUnavailableException('The userinfo endpoint returned an unreadable payload.');
|
||||
}
|
||||
|
||||
// myep's consumer guide warns that several of its endpoints answer `{"error": ...,
|
||||
// "type": "api_error"}` with HTTP 200, so the status alone does not settle it. Without
|
||||
// this an error body travels on and is refused further down for carrying no email
|
||||
// claim, which names the wrong cause.
|
||||
if (isset($userinfo['error'])) {
|
||||
throw new UserinfoUnavailableException('The userinfo endpoint returned an error body.');
|
||||
}
|
||||
|
||||
return $userinfo;
|
||||
}
|
||||
|
||||
@@ -131,6 +179,10 @@ class EpClient
|
||||
'scopes' => $this->config['scopes'],
|
||||
// myep's server expects space-separated scopes, not the library's default comma.
|
||||
'scopeSeparator' => ' ',
|
||||
// The library does the whole round trip from here: getAuthorizationUrl() generates
|
||||
// the verifier and sends the S256 challenge, getAccessToken() replays the verifier
|
||||
// that authorizationUrl() put on the session.
|
||||
'pkceMethod' => AbstractProvider::PKCE_METHOD_S256,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,13 @@ final class Role
|
||||
* at login rather than let in without access, so they get an explanation instead of an
|
||||
* empty screen they cannot act on.
|
||||
*
|
||||
* **Keep this in step with `required_roles` on ephub's `oauth2_client` row on myep.** myep
|
||||
* gates its own authorization endpoint on the same list and shows an explanation page
|
||||
* instead of redirecting back here, so the two are one policy written down twice: widen one
|
||||
* alone and somebody is either refused on myep with nothing this side can explain, or let
|
||||
* through to an ephub that refuses them. The refusal below stays either way -- it is
|
||||
* defence in depth now rather than the first line, and in practice it stops firing.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const ELIGIBLE = [
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Service\AuditLogger;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
|
||||
use Symfony\Component\Security\Core\User\UserCheckerInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* The local lockout, applied after the authenticator has resolved an account.
|
||||
*
|
||||
* Everything else about an account follows myep: roles are re-read on every login and withdrawn
|
||||
* when they stop being claimed, and deleting a row here only means the next login provisions it
|
||||
* again. This is the one thing myep cannot undo -- ROLE_ADMIN is granted on the strength of an
|
||||
* unsigned JSON body, so ephub needs a way to refuse somebody that does not depend on the
|
||||
* identity provider agreeing.
|
||||
*
|
||||
* It is a checker rather than a branch in the authenticator so that it runs on every
|
||||
* authentication the firewall performs, whatever put the passport together.
|
||||
*/
|
||||
final readonly class UserChecker implements UserCheckerInterface
|
||||
{
|
||||
public function __construct(private AuditLogger $audit)
|
||||
{
|
||||
}
|
||||
|
||||
public function checkPreAuth(UserInterface $user): void
|
||||
{
|
||||
if (!$user instanceof User || !$user->isDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The authenticator audits the refusals it can see; this one happens after it has handed
|
||||
// the passport over, so it is recorded here.
|
||||
$this->audit->signInRefused('account_disabled', $user->getEmail());
|
||||
|
||||
$reason = $user->getDisabledReason();
|
||||
|
||||
throw new CustomUserMessageAccountStatusException(
|
||||
null === $reason || '' === $reason
|
||||
? 'Dieser Zugang wurde gesperrt. Bitte wende dich an die Administration.'
|
||||
: 'Dieser Zugang wurde gesperrt: '.$reason,
|
||||
);
|
||||
}
|
||||
|
||||
public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,27 @@ final readonly class AuditLogger
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The local lockout, set and released from the console. myep is not involved and cannot undo
|
||||
* it, which is exactly why it is worth a record: it is the only access decision this
|
||||
* application makes on its own. The actor reads as "system" -- a console command has no
|
||||
* token -- so the reason is what identifies the act.
|
||||
*/
|
||||
public function accountLocked(User $user, ?string $reason): void
|
||||
{
|
||||
$this->logger->info('User account locked.', [
|
||||
'account' => $user->getEmail(),
|
||||
'reason' => $reason,
|
||||
]);
|
||||
}
|
||||
|
||||
public function accountUnlocked(User $user): void
|
||||
{
|
||||
$this->logger->info('User account unlocked.', [
|
||||
'account' => $user->getEmail(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function resourceCreated(Resource $resource): void
|
||||
{
|
||||
$this->logger->info('Resource created.', $this->resource($resource));
|
||||
|
||||
Reference in new issue
Block a user