oidc-1.0.0-alpha2/src/EventSubscriber/RequestSubscriber.php
src/EventSubscriber/RequestSubscriber.php
<?php
namespace Drupal\oidc\EventSubscriber;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Logger\LoggerChannelTrait;
use Drupal\Core\Messenger\MessengerTrait;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\Core\Session\AnonymousUserSession;
use Drupal\Core\Session\SessionManagerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Utility\Error;
use Drupal\oidc\OpenidConnectSessionInterface;
use Drupal\oidc\Routing\ImmutableTrustedRedirectResponse;
use Drupal\Core\Url;
use Drupal\oidc\Token;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Matcher\RequestMatcherInterface;
/**
* Event subscriber for requests.
*/
class RequestSubscriber extends KernelSubscriberBase {
use LoggerChannelTrait;
use MessengerTrait;
use StringTranslationTrait;
/**
* The request matcher service.
*
* @var \Symfony\Component\Routing\Matcher\RequestMatcherInterface
*/
protected $requestMatcher;
/**
* The session manager service.
*
* @var \Drupal\Core\Session\SessionManagerInterface
*/
protected $sessionManager;
/**
* The OpenID Connect session service.
*
* @var \Drupal\oidc\OpenidConnectSessionInterface
*/
protected $session;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountProxyInterface
*/
protected $currentUser;
/**
* The time service.
*
* @var \Drupal\Component\Datetime\TimeInterface
*/
protected $time;
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* Route match cache.
*
* @var array[]
*/
protected $matches = [];
/**
* Class constructor.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The configuration factory service.
* @param \Symfony\Component\Routing\Matcher\RequestMatcherInterface $request_matcher
* The request matcher service.
* @param \Drupal\Core\Session\SessionManagerInterface $session_manager
* The session manager service.
* @param \Drupal\oidc\OpenidConnectSessionInterface $session
* The OpenID Connect session service.
* @param \Drupal\Core\Session\AccountProxyInterface $current_user
* The current user.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The time service.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler.
*/
public function __construct(ConfigFactoryInterface $config_factory, RequestMatcherInterface $request_matcher, SessionManagerInterface $session_manager, OpenidConnectSessionInterface $session, AccountProxyInterface $current_user, TimeInterface $time, ModuleHandlerInterface $module_handler) {
parent::__construct($config_factory);
$this->requestMatcher = $request_matcher;
$this->sessionManager = $session_manager;
$this->session = $session;
$this->currentUser = $current_user;
$this->time = $time;
$this->moduleHandler = $module_handler;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
$events[KernelEvents::REQUEST][] = ['onRequest', 100];
return $events;
}
/**
* Act on the request.
*
* @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
* The request event.
*/
public function onRequest(RequestEvent $event) {
try {
$request = $event->getRequest();
// Ignore unkown and OpenID Connect routes.
$match = $this->getRouteMatch($request);
if (!$match || str_starts_with($match['_route'], 'oidc.openid_connect.')) {
return;
}
// Clear the state.
$this->session->clearState();
// Route based redirection.
if ($this->setRedirectForRoute($event)) {
return;
}
// Ensure the access token is still valid.
if ($this->session->isAuthenticated()) {
$this->ensureValidAccessToken($event);
}
}
catch (\Exception $ex) {
// A kernel request subscriber shouldn't throw any exceptions.
$this->getLogger('oidc')->error(
'%type: @message in %function (line %line of %file).',
Error::decodeException($ex)
);
}
}
/**
* Get the route match.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request to match.
*
* @return array|null
* The route match or NULL if unknown.
*/
protected function getRouteMatch(Request $request) {
$path = $request->getPathInfo();
if (str_starts_with($path, '/system/files/') && !$request->query->has('file')) {
// Private files paths are split by the inbound path processor and the
// relative file path is moved to the 'file' query string parameter. This
// is because the route system does not allow an arbitrary amount of
// parameters. Therefore, we don't match and redirect private files.
// @see \Drupal\system\PathProcessor\PathProcessorFiles::processInbound()
return NULL;
}
if (array_key_exists($path, $this->matches)) {
return $this->matches[$path];
}
try {
$match = $this->requestMatcher->matchRequest($request);
}
catch (\Exception $ex) {
$match = NULL;
}
$this->matches[$path] = $match;
return $match;
}
/**
* Set a redirect response if the route requires it.
*
* @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
* The request event.
*
* @return bool
* TRUE if a redirect was set, FALSE otherwise.
*/
protected function setRedirectForRoute(RequestEvent $event) {
$request = $event->getRequest();
if (!$match = $this->getRouteMatch($request)) {
return FALSE;
}
$url = NULL;
$destination = $request->query->get('destination');
switch ($match['_route']) {
case 'user.login':
if ($this->currentUser->isAnonymous() && !$request->query->has('local')) {
$url = $this->getLoginUrl($destination);
}
break;
case 'user.logout':
if ($this->session->isAuthenticated()) {
$options = [];
if ($destination !== NULL && $destination !== '') {
$options['query']['destination'] = $destination;
}
$url = Url::fromRoute('oidc.openid_connect.logout', [], $options);
}
break;
}
if ($url) {
// Set the redirect response.
return $this->setRedirectIfNotCurrentRoute($event, $url);
}
return FALSE;
}
/**
* Ensure the access token is still valid.
*
* @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
* The request event.
*/
protected function ensureValidAccessToken(RequestEvent $event) {
if (!$tokens = $this->session->getJsonWebTokens()) {
return;
}
// Check if the access token has expired.
if (!$this->isTokenExpired($tokens->getAccessToken())) {
return;
}
// Logout if there's no valid refresh token.
if ($this->isTokenExpired($tokens->getRefreshToken())) {
$this->logout($event);
return;
}
// Get the realm plugin.
$plugin = $this->session->getRealmPlugin();
try {
// Update the tokens.
$tokens = $plugin->getJsonWebTokensforRefresh($tokens->getRefreshToken());
$this->session->setJsonWebTokens($tokens);
}
catch (\Exception $ex) {
$this->getLogger('oidc')->error('Error on refresh via @realm realm: %error', [
'@realm' => $plugin->getPluginId(),
'%error' => $ex->getMessage(),
]);
$this->logout($event);
return;
}
}
/**
* Check if a token has expired.
*
* @param \Drupal\oidc\Token|null $token
* The token.
*
* @return bool
* TRUE if it's expired, FALSE otherwise.
*/
protected function isTokenExpired(?Token $token = NULL) {
return !$token || $token->getExpires() <= $this->time->getRequestTime();
}
/**
* End the user session.
*
* @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
* The request event.
*/
protected function logout(RequestEvent $event) {
// Log out of Drupal. This code is copied from user_logout() because that function also
// destroys the PHP session, which results in the expired session message getting lost.
$this->getLogger('user')->notice('Session closed for %name.', ['%name' => $this->currentUser->getAccountName()]);
$this->moduleHandler->invokeAll('user_logout', [$this->currentUser]);
$this->currentUser->setAccount(new AnonymousUserSession());
if (!$this->settings->get('show_session_expired_message')) {
// No message to be set, destroy the session.
$this->sessionManager->destroy();
}
else {
// Clear the session and set a message.
$this->sessionManager->clear();
$this->messenger()->addWarning($this->t('Your session has ended because the remote session expired.'));
}
// Redirect to the front page.
$this->setRedirectIfNotCurrentRoute($event, Url::fromRoute('<front>'));
}
/**
* Redirect to a URL if it isn't the current route.
*
* @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
* The request event.
* @param \Drupal\Core\Url $url
* The URL to redirect to.
*
* @return bool
* TRUE if a redirect was set, FALSE otherwise.
*/
protected function setRedirectIfNotCurrentRoute(RequestEvent $event, Url $url) {
$match = $this->getRouteMatch($event->getRequest());
// Set the redirect response.
if (!$match || !$url->isRouted() || $url->getRouteName() !== $match['_route']) {
$event->setResponse(new ImmutableTrustedRedirectResponse($url));
return TRUE;
}
return FALSE;
}
}
