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

+14 -6
View File
@@ -18,6 +18,15 @@
APP_ENV=dev
APP_SECRET=
APP_SHARE_DIR=var/share
# The Host headers this application answers to, as a regular expression -- the OAuth2
# redirect_uri is generated from the incoming request, so an unchecked Host header is what myep
# is told to send the authorization code to. Empty means every host is accepted; set it per
# environment, e.g. SYMFONY_TRUSTED_HOSTS='^ephub\.example\.com$'.
SYMFONY_TRUSTED_HOSTS=
# Only behind a TLS-terminating proxy: the proxy's address, a comma-separated list, or
# "private_ranges". Without it the generated redirect_uri of such a deployment comes out http://.
SYMFONY_TRUSTED_PROXIES=
###< symfony/framework-bundle ###
###> symfony/routing ###
@@ -56,12 +65,11 @@ MAILER_DSN=null://null
# https://ephub.ddev.site/auth/check for local dev, plus the production host. Leave the id and
# secret empty until then -- the login page detects it and says so.
#
# `email` is the address the account actually signed in with, and it is what the local `user`
# table 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, so such a
# change has to be applied to the `user` table too. Do NOT use profile.communication.email: it
# is the first contact address on the BusPro record, shared by every account of the same person.
# The local `user` table is keyed on the `sub` claim -- opaque, stable, and unique per myep
# account -- so an address change on myep updates the row instead of creating a second one.
# `email` is the address the session actually signed in with and is kept as a display and
# contact value. Do NOT use profile.communication.email: it is the first contact address on the
# BusPro record, shared by every account of the same person.
EP_OAUTH2_CLIENT_ID=
EP_OAUTH2_CLIENT_SECRET=
EP_OAUTH2_URL_AUTHORIZE=https://myep.ddev.site/authorize
+6
View File
@@ -1,4 +1,10 @@
###> symfony/framework-bundle ###
# A throwaway for local development, committed on purpose so a fresh checkout boots. The dev
# environment is never served: .env.dev and .env.test are both excluded from the deploy rsync,
# and production takes APP_SECRET from its own uncommitted .env.local.
APP_SECRET=44660e8fcd421376ebe0f7a7d55e179c
###< symfony/framework-bundle ###
# ddev serves the application under this name; anything else is refused.
SYMFONY_TRUSTED_HOSTS='^ephub\.ddev\.site$'
+2
View File
@@ -1,3 +1,5 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
# Fixed and committed so the test suite is reproducible, and never served: .env.test is excluded
# from the deploy rsync and production takes APP_SECRET from its own uncommitted .env.local.
APP_SECRET='$ecretf0rt3st'
+24 -1
View File
@@ -2,8 +2,31 @@
framework:
secret: '%env(APP_SECRET)%'
# The OAuth2 redirect_uri is generated from the incoming request, so the Host and the
# X-Forwarded-* headers decide where myep is told to send the authorization code. Both lists
# read the standard SYMFONY_* variables Symfony already defaults to, and are spelled out here
# because leaving them implicit is what made them easy to overlook. Set SYMFONY_TRUSTED_HOSTS
# per environment -- an empty value means every Host header is accepted.
trusted_hosts: '%env(default::SYMFONY_TRUSTED_HOSTS)%'
# Only ever needed behind a TLS-terminating proxy. Without it the generated redirect_uri of
# such a deployment comes out as http://, and the flow breaks rather than downgrading quietly.
trusted_proxies: '%env(default::SYMFONY_TRUSTED_PROXIES)%'
trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'x-forwarded-prefix']
# Note that the session will be started ONLY if you read or write from it.
session: true
#
# Everything here is pinned rather than left to the default, because a session cookie is the
# whole of this application's authentication once the OAuth2 flow is over. cookie_secure and
# cookie_samesite happen to match the defaults; save_path does not, and it is the one that
# matters in production -- deploy.php shares var/sessions between releases, and without this
# sessions land in PHP's default directory and every release logs everybody out.
session:
handler_id: session.handler.native_file
save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
name: epsession
cookie_secure: true
cookie_samesite: lax
cookie_httponly: true
form:
csrf_protection:
+16
View File
@@ -1,4 +1,9 @@
security:
# Symfony's default, stated because it is a decision: the session id is regenerated on a
# successful login, so a session id an attacker planted beforehand is not the one that ends
# up authenticated.
session_fixation_strategy: migrate
# No password_hashers: there are no passwords here. Authentication is OAuth2 against myep,
# which owns the credentials; ephub only ever sees a userinfo response.
@@ -19,9 +24,20 @@ security:
entry_point: App\Security\LoginEntryPoint
custom_authenticators:
- App\Security\EpAuthenticator
# The local lockout. Roles follow myep on every login and a deleted row is simply
# provisioned again, so this flag is the only thing here that myep cannot undo.
user_checker: App\Security\UserChecker
logout:
path: app_logout
target: app_login
# Without this any third-party page can log somebody out by embedding the URL.
# The header link is a form POST for the same reason.
enable_csrf: true
# Local only: there is no RP-initiated logout against myep and no token
# revocation, so this ends the ephub session and nothing else. It matters less
# than it usually would -- myep destroys its own session immediately after an
# external authorization request, so there is normally no SSO session left to
# end -- and the access token is gone either way, never having been stored.
access_control:
# The login surface itself has to stay reachable, or the entry point redirects in a loop.
+1 -1
View File
@@ -1293,7 +1293,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* lifetime?: int|Param, // Default: 31536000
* path?: scalar|Param|null, // Default: "/"
* domain?: scalar|Param|null, // Default: null
* secure?: true|false|"auto"|Param, // Default: "auto"
* secure?: true|false|"auto"|Param, // Default: true
* httponly?: bool|Param, // Default: true
* samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax"
* always_remember_me?: bool|Param, // Default: false
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260923090000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds myep\'s sub claim and a local lockout flag to user';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE `user` ADD sub VARCHAR(64) DEFAULT NULL, ADD disabled_at DATETIME DEFAULT NULL, ADD disabled_reason VARCHAR(255) DEFAULT NULL');
$this->addSql('CREATE UNIQUE INDEX uniq_user_sub ON `user` (sub)');
// Existing rows keep a NULL sub and are matched on their address once more, on their next
// login, which is where the column is backfilled. The unique index tolerates that: NULL
// is not equal to NULL in MySQL, so any number of rows may be waiting for their first
// login at once.
// Addresses were stored as they arrived, and the new matching normalises before it
// compares -- a row left in mixed case would never be found again.
$this->addSql('UPDATE `user` SET email = LOWER(TRIM(email))');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX uniq_user_sub ON `user`');
$this->addSql('ALTER TABLE `user` DROP sub, DROP disabled_at, DROP disabled_reason');
}
}
+86
View File
@@ -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;
}
}
+4 -1
View File
@@ -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.');
+73
View File
@@ -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
{
}
+8
View File
@@ -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]);
}
}
+89 -10
View File
@@ -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
{
}
+58 -6
View File
@@ -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,
]);
}
+7
View File
@@ -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 = [
+54
View File
@@ -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
{
}
}
+21
View File
@@ -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));
+10 -5
View File
@@ -15,11 +15,16 @@
{% if app.user %}
<div class="flex items-center gap-4">
<span class="hidden sm:block text-sm text-gray-500">{{ app.user.name }}</span>
<a href="{{ path('app_logout') }}"
class="flex items-center gap-2 text-sm font-semibold text-gray-700 hover:text-gray-900">
{{ ux_icon('lucide:log-out', { class: 'h-5 w-5 shrink-0 text-gray-400' }) }}
<span class="hidden sm:inline">Abmelden</span>
</a>
{# A form rather than a link: the firewall's logout is CSRF-protected, so any
third-party page embedding the URL gets nowhere. #}
<form method="post" action="{{ path('app_logout') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}">
<button type="submit"
class="flex items-center gap-2 text-sm font-semibold text-gray-700 hover:text-gray-900 cursor-pointer">
{{ ux_icon('lucide:log-out', { class: 'h-5 w-5 shrink-0 text-gray-400' }) }}
<span class="hidden sm:inline">Abmelden</span>
</button>
</form>
</div>
{% endif %}
</div>
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Tests\Command;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
/**
* The only way to set the flag the UserChecker reads.
*/
class LockUserCommandTest extends KernelTestCase
{
private EntityManagerInterface $em;
private CommandTester $command;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->em->getConnection()->beginTransaction();
$this->command = new CommandTester((new Application(self::$kernel))->find('app:user:lock'));
}
protected function tearDown(): void
{
$this->em->getConnection()->rollBack();
parent::tearDown();
}
public function testAnAccountIsLockedWithItsReason(): void
{
$user = $this->user();
$this->command->execute(['email' => '[email protected]', 'reason' => 'Auf eigenen Wunsch']);
$this->command->assertCommandIsSuccessful();
self::assertTrue($user->isDisabled());
self::assertSame('Auf eigenen Wunsch', $user->getDisabledReason());
}
public function testTheLockIsReleasedAgain(): void
{
$user = $this->user();
$user->disable('Auf eigenen Wunsch');
$this->em->flush();
$this->command->execute(['email' => '[email protected]', '--release' => true]);
$this->command->assertCommandIsSuccessful();
self::assertFalse($user->isDisabled());
self::assertNull($user->getDisabledReason());
}
/**
* Addresses are stored normalised, so a differently cased argument has to find the row
* rather than report that there is none.
*/
public function testTheAddressIsMatchedTheWayALoginMatchesIt(): void
{
$user = $this->user();
$this->command->execute(['email' => ' [email protected] ']);
$this->command->assertCommandIsSuccessful();
self::assertTrue($user->isDisabled());
}
public function testAnUnknownAddressFails(): void
{
self::assertSame(1, $this->command->execute(['email' => '[email protected]']));
self::assertStringContainsString('No account', $this->command->getDisplay());
}
private function user(): User
{
$user = new User('[email protected]', 'Anna');
$user->setRoles(['ROLE_EMPLOYEE']);
$this->em->persist($user);
$this->em->flush();
return $user;
}
}
+2 -1
View File
@@ -70,7 +70,8 @@ class DashboardControllerTest extends WebTestCase
$crawler = $this->client->request('GET', '/');
self::assertCount(0, $crawler->filter('#mobilenav'));
self::assertCount(1, $crawler->filter('a[href="/logout"]'));
// The way out is a form, not a link: the firewall's logout is CSRF-protected.
self::assertCount(1, $crawler->filter('form[action="/logout"]'));
}
public function testATileCountsWhatIsStillFreeToday(): void
@@ -51,7 +51,11 @@ class LogoutAuditTest extends WebTestCase
$this->em->flush();
$this->client->loginUser($user);
$this->client->request('GET', '/logout');
// Submitted from the page rather than requested directly: the firewall's logout is
// CSRF-protected, so the token in the header form is part of what is being pinned down.
$crawler = $this->client->request('GET', '/');
$this->client->submit($crawler->filter('form[action$="/logout"]')->form());
self::assertResponseRedirects();
+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
*/
+148
View File
@@ -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;
}
}
+82
View File
@@ -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])));
}
}