feat: harden the myep oauth2 client

This commit is contained in:
2026-09-23 16:06:25 +02:00
parent 2a8649fc6d
commit 3f4586ce06
8 changed files with 284 additions and 11 deletions
+15 -1
View File
@@ -6,6 +6,7 @@ use App\BusProNet\UserDataHandler;
use App\Entity\Teamer;
use App\Entity\User;
use App\RequiredTeamerCheck\RequiredTeamerCheckRegistry;
use App\Security\OAuth2\AuthorizationDeniedException;
use App\Security\OAuth2\AuthorizationRequestException;
use App\Security\OAuth2\MyEpClient;
use Doctrine\ORM\EntityManagerInterface;
@@ -34,6 +35,12 @@ class MyEpAuthenticator extends AbstractAuthenticator
* The roles that entitle someone to log in here at all. Anything else MyE&P reports
* is dropped rather than stored, so that no role this application assigns a meaning
* to can be set from the outside.
*
* This list exists a second time on MyE&P, as the required_roles of this application's
* oauth2_client row: MyE&P refuses the authorization request outright when the account
* holds none of them, and explains why on its own page. The two are one policy written
* twice and must be changed together — widening only one either strands a user at MyE&P
* with no explanation this side can give, or lets one through to be refused here.
*/
private const ELIGIBLE_ROLES = ['ROLE_TEAM_ADMIN', 'ROLE_TEAMER', 'ROLE_MANAGER', 'ROLE_HOUSE_MANAGER'];
@@ -63,11 +70,18 @@ class MyEpAuthenticator extends AbstractAuthenticator
{
try {
$accessToken = $this->client->fetchAccessToken($request);
} catch (AuthorizationDeniedException $e) {
// MyE&P said why it sent no code, so this is not a failure to report as one
$this->logger->info('Login via MyE&P was denied', [
'error' => $e->getError(),
'error_description' => $e->getErrorDescription(),
]);
throw new CustomUserMessageAuthenticationException('Login via MyE&P wurde abgebrochen');
} catch (AuthorizationRequestException|IdentityProviderException $e) {
$this->logger->error('Login via MyE&P failed due to unobtainable access token', [
'exception' => $e,
]);
throw new CustomUserMessageAuthenticationException('Invalid token');
throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich');
}
try {
@@ -0,0 +1,34 @@
<?php
namespace App\Security\OAuth2;
use Symfony\Component\HttpFoundation\Request;
/**
* MyE&P redirected back with an `error` instead of a code — the person cancelled, or the
* authorization was refused.
*
* A subclass of AuthorizationRequestException so that a caller which does not care why the
* callback carried no code still catches it, and one that does can tell this apart from a
* malformed or replayed callback and say so.
*/
class AuthorizationDeniedException extends AuthorizationRequestException
{
public function __construct(
private readonly string $error,
private readonly ?string $errorDescription,
Request $request,
) {
parent::__construct(sprintf('Authorization denied: %s', $error), 400, $request);
}
public function getError(): string
{
return $this->error;
}
public function getErrorDescription(): ?string
{
return $this->errorDescription;
}
}
+73 -6
View File
@@ -13,41 +13,104 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class MyEpClient
{
private const SESSION_KEY_STATE = 'oauth2state';
private const SESSION_KEY_PKCE = 'oauth2pkce';
private array $config;
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
private readonly LoggerInterface $logger,
// the client secret rides in here, marked sensitive so 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);
}
/**
* Starts the authorization code flow and stores what the callback has to prove.
*
* The state and the PKCE verifier are generated by getAuthorizationUrl() and are only
* readable afterwards, so both are taken from the provider rather than built here.
*/
public function createAuthorizationUrl(Request $request): string
{
$provider = $this->getProvider();
$url = $provider->getAuthorizationUrl();
$session = $request->getSession();
$session->set(self::SESSION_KEY_STATE, $provider->getState());
$session->set(self::SESSION_KEY_PKCE, $provider->getPkceCode());
return $url;
}
/**
* @throws IdentityProviderException
* @throws AuthorizationDeniedException
* @throws AuthorizationRequestException
*/
public function fetchAccessToken(Request $request): AccessTokenInterface
{
$session = $request->getSession();
// Both are single-use, so they are consumed before anything can fail: a callback
// that throws must not leave a state behind that a second attempt could replay.
$expectedState = $session->remove(self::SESSION_KEY_STATE);
$pkceCode = $session->remove(self::SESSION_KEY_PKCE);
// Before anything else: MyE&P has said why it is not sending a code, and every
// later check would report the wrong cause. The role gate renders on MyE&P rather
// than redirecting, but a cancelled or refused authorization arrives here.
$error = $request->query->get('error');
if (true === is_string($error) && '' !== $error) {
$description = $request->query->get('error_description');
$description = is_string($description) && '' !== $description ? $description : null;
$this->logger->error('OAuth2 login request denied', [
'error' => $error,
'error_description' => $description,
]);
throw new AuthorizationDeniedException($error, $description, $request);
}
if (null === $code = $request->query->get('code')) {
$this->logger->error('OAuth2 login request missing code');
throw new AuthorizationRequestException('Missing code', 400, $request);
}
$session = $request->getSession();
$state = $request->query->get('state');
// hash_equals() rather than !==, the state being the CSRF secret of the flow. The
// string checks come first: a callback with no state, or a session that never held
// one, must fail here rather than reach a comparison with null.
if (
null === $request->query->get('state')
|| $request->query->get('state') !== $session->get('oauth2state')
false === is_string($state)
|| false === is_string($expectedState)
|| false === hash_equals($expectedState, $state)
) {
$session->remove('oauth2state');
$this->logger->error('OAuth2 login request missing state or mismatch');
throw new AuthorizationRequestException('Missing state or mismatch', 400, $request);
}
$session->remove('oauth2state');
// A challenge went out with the authorization request, so MyE&P rejects an exchange
// without the verifier. Refused here instead, where the reason is still known — the
// usual cause is a session replaced between the two legs of the flow.
if (false === is_string($pkceCode) || '' === $pkceCode) {
$this->logger->error('OAuth2 login request without the PKCE verifier of its session');
throw new AuthorizationRequestException('Missing PKCE verifier', 400, $request);
}
return $this->getProvider()->getAccessToken('authorization_code', [
$provider = $this->getProvider();
// replays the verifier whose challenge was sent with the authorization request
$provider->setPkceCode($pkceCode);
return $provider->getAccessToken('authorization_code', [
'code' => $code,
]);
}
@@ -68,6 +131,10 @@ class MyEpClient
'urlResourceOwnerDetails' => $this->config['myep_oauth2_url_resource_owner_details'],
'scopes' => $this->config['myep_oauth2_scopes'],
'scopeSeparator' => ' ',
// The redirect URI above is generated from the incoming request, so it is only
// as trustworthy as the Host header. PKCE binds the authorization code to this
// flow by a second, header-independent means.
'pkceMethod' => AbstractProvider::PKCE_METHOD_S256,
]);
}