feat: add oauth2 login against myep
This commit is contained in:
1 parent
d433d271d0
commit
d63df2cc1b
18 files changed
+1599
-33
No files matched your search
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Security\OAuth2\AuthorizationRequestException;
|
||||
use App\Security\OAuth2\EpClient;
|
||||
use App\Security\OAuth2\UserinfoUnavailableException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||||
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
||||
use Symfony\Component\Security\Http\Util\TargetPathTrait;
|
||||
|
||||
/**
|
||||
* Logs someone in from myep's authorization-code callback.
|
||||
*
|
||||
* ephub reads `/api/userinfo` exactly once, here, and then relies on its own session. The access
|
||||
* token lives ten minutes and is never stored or refreshed, because nothing else in this
|
||||
* application talks to myep's API.
|
||||
*/
|
||||
class EpAuthenticator extends AbstractAuthenticator
|
||||
{
|
||||
use TargetPathTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly EpClient $client,
|
||||
private readonly UserRepository $users,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function supports(Request $request): ?bool
|
||||
{
|
||||
return 'app_auth_check' === $request->attributes->get('_route');
|
||||
}
|
||||
|
||||
public function authenticate(Request $request): Passport
|
||||
{
|
||||
try {
|
||||
$accessToken = $this->client->fetchAccessToken($request);
|
||||
} catch (AuthorizationRequestException|IdentityProviderException $e) {
|
||||
$this->logger->error('Login via myep failed: no access token', ['exception' => $e]);
|
||||
|
||||
throw new CustomUserMessageAuthenticationException('Anmeldung über myep fehlgeschlagen.');
|
||||
}
|
||||
|
||||
try {
|
||||
$userinfo = $this->client->fetchUserinfo($accessToken);
|
||||
} catch (UserinfoUnavailableException $e) {
|
||||
$this->logger->error('Login via myep failed: userinfo unreadable', ['exception' => $e]);
|
||||
|
||||
throw new CustomUserMessageAuthenticationException('Anmeldung über myep fehlgeschlagen.');
|
||||
}
|
||||
|
||||
// The guide is explicit that profile.communication.email comes from the CRM address
|
||||
// record and is NOT necessarily the login email -- one address can hold several
|
||||
// contacts. The top-level claim is the identity, and it is what User is keyed on.
|
||||
$email = $userinfo['email'] ?? null;
|
||||
if (!\is_string($email) || '' === $email) {
|
||||
$this->logger->error('Login via myep failed: userinfo carried no email claim');
|
||||
|
||||
throw new CustomUserMessageAuthenticationException('Anmeldung über myep fehlgeschlagen.');
|
||||
}
|
||||
|
||||
$roles = Role::eligibleOnly($this->claimedRoles($userinfo));
|
||||
if ([] === $roles) {
|
||||
$this->logger->info('Login via myep refused: no eligible role', ['email' => $email]);
|
||||
|
||||
throw new CustomUserMessageAuthenticationException(
|
||||
'Für ephub ist eine Mitarbeiter- oder Administrator-Rolle erforderlich.',
|
||||
);
|
||||
}
|
||||
|
||||
$user = $this->createOrUpdate($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
|
||||
// lookup of a row we already hold.
|
||||
return new SelfValidatingPassport(
|
||||
new UserBadge($user->getUserIdentifier(), static fn (): User => $user),
|
||||
);
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
{
|
||||
// Whatever they were trying to reach before the firewall intervened.
|
||||
$targetPath = $this->getTargetPath($request->getSession(), $firewallName);
|
||||
|
||||
return new RedirectResponse($targetPath ?? $this->urlGenerator->generate('app_dashboard'));
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
|
||||
{
|
||||
$request->getSession()->getFlashBag()->add('error', $exception->getMessageKey());
|
||||
|
||||
return new RedirectResponse($this->urlGenerator->generate('app_login'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $userinfo
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function claimedRoles(array $userinfo): array
|
||||
{
|
||||
$claimed = $userinfo['roles'] ?? [];
|
||||
|
||||
if (!\is_array($claimed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter($claimed, \is_string(...)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $userinfo
|
||||
*/
|
||||
private function nameFrom(array $userinfo, string $fallback): string
|
||||
{
|
||||
$profile = $userinfo['profile'] ?? null;
|
||||
|
||||
if (\is_array($profile)) {
|
||||
$name = trim(\sprintf('%s %s', $profile['first_name'] ?? '', $profile['last_name'] ?? ''));
|
||||
|
||||
if ('' !== $name) {
|
||||
return mb_substr($name, 0, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// The profile scope may not have been granted, and a nameless account would render as a
|
||||
// blank holder on the floor plan -- the email at least identifies somebody.
|
||||
return mb_substr($fallback, 0, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
private function createOrUpdate(string $email, string $name, array $roles): User
|
||||
{
|
||||
$user = $this->users->findOneBy(['email' => $email]);
|
||||
|
||||
if (null === $user) {
|
||||
$user = new User($email, $name);
|
||||
$this->em->persist($user);
|
||||
} else {
|
||||
$user->setName($name);
|
||||
}
|
||||
|
||||
// myep leads: a role it no longer reports is withdrawn here, not kept because it was
|
||||
// granted once.
|
||||
$user->setRoles($roles);
|
||||
$this->em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\When;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -13,14 +12,12 @@ use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
|
||||
|
||||
/**
|
||||
* Sends an unauthenticated visitor to the dev login picker.
|
||||
* Sends an unauthenticated visitor to the login page.
|
||||
*
|
||||
* Dev only -- it exists so UI work is not gated on the OAuth2 client registration on myep.
|
||||
* When the real authenticator lands (plan step 6) the entry point for every environment becomes
|
||||
* the OAuth2 init route and this class goes away.
|
||||
* Deliberately the login *page* rather than straight into /auth/init: the page is where a failed
|
||||
* login's flash message is shown, and bouncing there instead would swallow it.
|
||||
*/
|
||||
#[When('dev')]
|
||||
final readonly class DevLoginEntryPoint implements AuthenticationEntryPointInterface
|
||||
final readonly class LoginEntryPoint implements AuthenticationEntryPointInterface
|
||||
{
|
||||
public function __construct(private UrlGeneratorInterface $urlGenerator)
|
||||
{
|
||||
@@ -28,6 +25,6 @@ final readonly class DevLoginEntryPoint implements AuthenticationEntryPointInter
|
||||
|
||||
public function start(Request $request, ?AuthenticationException $authException = null): Response
|
||||
{
|
||||
return new RedirectResponse($this->urlGenerator->generate('app_dev_login'));
|
||||
return new RedirectResponse($this->urlGenerator->generate('app_login'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
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.
|
||||
*/
|
||||
final class AuthorizationRequestException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security\OAuth2;
|
||||
|
||||
use League\OAuth2\Client\Provider\AbstractProvider;
|
||||
use League\OAuth2\Client\Provider\GenericProvider;
|
||||
use League\OAuth2\Client\Token\AccessTokenInterface;
|
||||
use Psr\Http\Client\ClientExceptionInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* Thin wrapper around league/oauth2-client's GenericProvider, holding everything specific to
|
||||
* myep in one place.
|
||||
*
|
||||
* The endpoint URLs are configuration rather than constants -- same as `myep-team` -- so this
|
||||
* works against a local myep, a staging one, or production without a code change.
|
||||
*/
|
||||
class EpClient
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
array $options,
|
||||
) {
|
||||
$this->config = $this->resolveConfig($options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a client has actually been registered on myep yet. Until it has, the login page
|
||||
* says so instead of bouncing the user to a broken authorize URL.
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return '' !== $this->config['client_id'] && '' !== $this->config['client_secret'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL to send the browser to, having stored the state this session expects back.
|
||||
*/
|
||||
public function authorizationUrl(Request $request): string
|
||||
{
|
||||
$provider = $this->getProvider();
|
||||
$url = $provider->getAuthorizationUrl();
|
||||
|
||||
$request->getSession()->set('oauth2state', $provider->getState());
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchanges the callback's code for an access token.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @throws AuthorizationRequestException
|
||||
*/
|
||||
public function fetchAccessToken(Request $request): AccessTokenInterface
|
||||
{
|
||||
$session = $request->getSession();
|
||||
$expectedState = $session->get('oauth2state');
|
||||
$session->remove('oauth2state');
|
||||
|
||||
$code = $request->query->get('code');
|
||||
if (null === $code || '' === $code) {
|
||||
throw new AuthorizationRequestException('Die Antwort von myep enthielt keinen Code.');
|
||||
}
|
||||
|
||||
if (null === $expectedState || $request->query->get('state') !== $expectedState) {
|
||||
throw new AuthorizationRequestException('Der state-Parameter passt nicht zur Sitzung.');
|
||||
}
|
||||
|
||||
return $this->getProvider()->getAccessToken('authorization_code', ['code' => $code]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads /api/userinfo with the freshly obtained token.
|
||||
*
|
||||
* Lives here rather than in the authenticator so that the authenticator depends on two plain
|
||||
* method calls and can be tested without standing up a PSR-18 client -- and so that
|
||||
* everything that knows about myep's wire format sits in one class.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @throws UserinfoUnavailableException
|
||||
*/
|
||||
public function fetchUserinfo(AccessTokenInterface $accessToken): array
|
||||
{
|
||||
$provider = $this->getProvider();
|
||||
|
||||
try {
|
||||
$response = $provider->getHttpClient()->sendRequest($provider->getAuthenticatedRequest(
|
||||
'GET',
|
||||
$provider->getResourceOwnerDetailsUrl($accessToken),
|
||||
$accessToken,
|
||||
['headers' => ['Accept' => 'application/json']],
|
||||
));
|
||||
} catch (ClientExceptionInterface $e) {
|
||||
throw new UserinfoUnavailableException('userinfo konnte nicht abgerufen werden.', previous: $e);
|
||||
}
|
||||
|
||||
$userinfo = json_decode((string) $response->getBody(), true);
|
||||
|
||||
if (!\is_array($userinfo)) {
|
||||
throw new UserinfoUnavailableException('userinfo lieferte keine lesbare Antwort.');
|
||||
}
|
||||
|
||||
return $userinfo;
|
||||
}
|
||||
|
||||
public function getProvider(): AbstractProvider
|
||||
{
|
||||
return new GenericProvider([
|
||||
'clientId' => $this->config['client_id'],
|
||||
'clientSecret' => $this->config['client_secret'],
|
||||
'redirectUri' => $this->urlGenerator->generate('app_auth_check', [], UrlGeneratorInterface::ABSOLUTE_URL),
|
||||
'urlAuthorize' => $this->config['url_authorize'],
|
||||
'urlAccessToken' => $this->config['url_access_token'],
|
||||
'urlResourceOwnerDetails' => $this->config['url_userinfo'],
|
||||
'scopes' => $this->config['scopes'],
|
||||
// myep's server expects space-separated scopes, not the library's default comma.
|
||||
'scopeSeparator' => ' ',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function resolveConfig(array $options): array
|
||||
{
|
||||
$resolver = new OptionsResolver();
|
||||
$resolver->setRequired(['client_id', 'client_secret', 'url_authorize', 'url_access_token', 'url_userinfo', 'scopes']);
|
||||
$resolver->setAllowedTypes('client_id', 'string');
|
||||
$resolver->setAllowedTypes('client_secret', 'string');
|
||||
$resolver->setAllowedTypes('url_authorize', 'string');
|
||||
$resolver->setAllowedTypes('url_access_token', 'string');
|
||||
$resolver->setAllowedTypes('url_userinfo', 'string');
|
||||
$resolver->setAllowedTypes('scopes', 'string[]');
|
||||
|
||||
return $resolver->resolve($options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security\OAuth2;
|
||||
|
||||
/**
|
||||
* myep accepted the token but its userinfo endpoint could not be read.
|
||||
*/
|
||||
final class UserinfoUnavailableException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
/**
|
||||
* The roles this application knows about.
|
||||
*
|
||||
* **This list is a whitelist, not a mirror.** myep's `roles` claim is filtered through it on every
|
||||
* login, so a role invented on the identity provider -- or injected into a userinfo response --
|
||||
* can never appear on a local account. myep already strips its own `*_PENDING` markers and
|
||||
* non-granting roles before exporting them, but that is its policy, not our guarantee.
|
||||
*
|
||||
* Kept deliberately small: ephub has no notion of teamers, managers or house managers, and
|
||||
* storing roles it assigns no meaning to would only invite someone to start checking them.
|
||||
*/
|
||||
final class Role
|
||||
{
|
||||
/** Derived on myep from the account's email domain: "works here". */
|
||||
public const EMPLOYEE = 'ROLE_EMPLOYEE';
|
||||
|
||||
/** Administrative on myep -- nominated by the CRM, but only exported once approved there. */
|
||||
public const ADMIN = 'ROLE_ADMIN';
|
||||
|
||||
/**
|
||||
* The roles that entitle someone to use ephub at all. Someone who holds neither is refused
|
||||
* at login rather than let in without access, so they get an explanation instead of an
|
||||
* empty screen they cannot act on.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const ELIGIBLE = [
|
||||
self::EMPLOYEE,
|
||||
self::ADMIN,
|
||||
];
|
||||
|
||||
/**
|
||||
* Whatever of a claimed role set this application recognises, in a stable order.
|
||||
*
|
||||
* @param list<string> $claimed
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function eligibleOnly(array $claimed): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
self::ELIGIBLE,
|
||||
static fn (string $role): bool => \in_array($role, $claimed, true),
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user