Commit b83782a7 authored by effulgentsia's avatar effulgentsia

Issue #2920001 by Wim Leers, dawehner, borisson_, aheimlich, davidwbarratt:...

Issue #2920001 by Wim Leers, dawehner, borisson_, aheimlich, davidwbarratt: Add cacheable HTTP exceptions: Symfony HTTP exceptions + Drupal cacheability metadata
parent 52a598e2
<?php
namespace Drupal\Core\Cache;
/**
* Trait for \Drupal\Core\Cache\CacheableDependencyInterface.
*/
trait CacheableDependencyTrait {
/**
* Cache contexts.
*
* @var string[]
*/
protected $cacheContexts = [];
/**
* Cache tags.
*
* @var string[]
*/
protected $cacheTags = [];
/**
* Cache max-age.
*
* @var int
*/
protected $cacheMaxAge = Cache::PERMANENT;
/**
* Sets cacheability; useful for value object constructors.
*
* @param \Drupal\Core\Cache\CacheableDependencyInterface $cacheability
* The cacheability to set.
*
* @return $this
*/
protected function setCacheability(CacheableDependencyInterface $cacheability) {
$this->cacheContexts = $cacheability->getCacheContexts();
$this->cacheTags = $cacheability->getCacheTags();
$this->cacheMaxAge = $cacheability->getCacheMaxAge();
return $this;
}
/**
* {@inheritdoc}
*/
public function getCacheTags() {
return $this->cacheTags;
}
/**
* {@inheritdoc}
*/
public function getCacheContexts() {
return $this->cacheContexts;
}
/**
* {@inheritdoc}
*/
public function getCacheMaxAge() {
return $this->cacheMaxAge;
}
}
......@@ -7,47 +7,7 @@
*/
trait RefinableCacheableDependencyTrait {
/**
* Cache contexts.
*
* @var string[]
*/
protected $cacheContexts = [];
/**
* Cache tags.
*
* @var string[]
*/
protected $cacheTags = [];
/**
* Cache max-age.
*
* @var int
*/
protected $cacheMaxAge = Cache::PERMANENT;
/**
* {@inheritdoc}
*/
public function getCacheTags() {
return $this->cacheTags;
}
/**
* {@inheritdoc}
*/
public function getCacheContexts() {
return $this->cacheContexts;
}
/**
* {@inheritdoc}
*/
public function getCacheMaxAge() {
return $this->cacheMaxAge;
}
use CacheableDependencyTrait;
/**
* {@inheritdoc}
......
......@@ -2,6 +2,7 @@
namespace Drupal\Core\EventSubscriber;
use Drupal\Core\Cache\CacheableResponseInterface;
use Drupal\Core\Routing\RedirectDestinationInterface;
use Drupal\Core\Utility\Error;
use Psr\Log\LoggerInterface;
......@@ -170,6 +171,13 @@ protected function makeSubrequest(GetResponseForExceptionEvent $event, $url, $st
$response->setStatusCode($status_code);
}
// Persist the exception's cacheability metadata, if any. If the exception
// itself isn't cacheable, then this will make the response uncacheable:
// max-age=0 will be set.
if ($response instanceof CacheableResponseInterface) {
$response->addCacheableDependency($exception);
}
// Persist any special HTTP headers that were set on the exception.
if ($exception instanceof HttpExceptionInterface) {
$response->headers->add($exception->getHeaders());
......
......@@ -2,6 +2,8 @@
namespace Drupal\Core\EventSubscriber;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableJsonResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
......@@ -35,7 +37,16 @@ protected static function getPriority() {
public function on4xx(GetResponseForExceptionEvent $event) {
/** @var \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $exception */
$exception = $event->getException();
$response = new JsonResponse(['message' => $event->getException()->getMessage()], $exception->getStatusCode(), $exception->getHeaders());
// If the exception is cacheable, generate a cacheable response.
if ($exception instanceof CacheableDependencyInterface) {
$response = new CacheableJsonResponse(['message' => $event->getException()->getMessage()], $exception->getStatusCode(), $exception->getHeaders());
$response->addCacheableDependency($exception);
}
else {
$response = new JsonResponse(['message' => $event->getException()->getMessage()], $exception->getStatusCode(), $exception->getHeaders());
}
$event->setResponse($response);
}
......
<?php
namespace Drupal\Core\Http\Exception;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableDependencyTrait;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* A cacheable AccessDeniedHttpException.
*/
class CacheableAccessDeniedHttpException extends AccessDeniedHttpException implements CacheableDependencyInterface {
use CacheableDependencyTrait;
/**
* {@inheritdoc}
*/
public function __construct(CacheableDependencyInterface $cacheability, $message = NULL, \Exception $previous = NULL, $code = 0) {
$this->setCacheability($cacheability);
parent::__construct($message, $previous, $code);
}
}
<?php
namespace Drupal\Core\Http\Exception;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableDependencyTrait;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* A cacheable BadRequestHttpException.
*/
class CacheableBadRequestHttpException extends BadRequestHttpException implements CacheableDependencyInterface {
use CacheableDependencyTrait;
/**
* {@inheritdoc}
*/
public function __construct(CacheableDependencyInterface $cacheability, $message = NULL, \Exception $previous = NULL, $code = 0) {
$this->setCacheability($cacheability);
parent::__construct($message, $previous, $code);
}
}
<?php
namespace Drupal\Core\Http\Exception;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableDependencyTrait;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
/**
* A cacheable ConflictHttpException.
*/
class CacheableConflictHttpException extends ConflictHttpException implements CacheableDependencyInterface {
use CacheableDependencyTrait;
/**
* {@inheritdoc}
*/
public function __construct(CacheableDependencyInterface $cacheability, $message = NULL, \Exception $previous = NULL, $code = 0) {
$this->setCacheability($cacheability);
parent::__construct($message, $previous, $code);
}
}
<?php
namespace Drupal\Core\Http\Exception;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableDependencyTrait;
use Symfony\Component\HttpKernel\Exception\GoneHttpException;
/**
* A cacheable GoneHttpException.
*/
class CacheableGoneHttpException extends GoneHttpException implements CacheableDependencyInterface {
use CacheableDependencyTrait;
/**
* {@inheritdoc}
*/
public function __construct(CacheableDependencyInterface $cacheability, $message = NULL, \Exception $previous = NULL, $code = 0) {
$this->setCacheability($cacheability);
parent::__construct($message, $previous, $code);
}
}
<?php
namespace Drupal\Core\Http\Exception;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableDependencyTrait;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* A cacheable HttpException.
*/
class CacheableHttpException extends HttpException implements CacheableDependencyInterface {
use CacheableDependencyTrait;
/**
* {@inheritdoc}
*/
public function __construct(CacheableDependencyInterface $cacheability, $statusCode = 0, $message = NULL, \Exception $previous = NULL, $code = 0) {
$this->setCacheability($cacheability);
parent::__construct($statusCode, $message, $previous, $code);
}
}
<?php
namespace Drupal\Core\Http\Exception;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableDependencyTrait;
use Symfony\Component\HttpKernel\Exception\LengthRequiredHttpException;
/**
* A cacheable LengthRequiredHttpException.
*/
class CacheableLengthRequiredHttpException extends LengthRequiredHttpException implements CacheableDependencyInterface {
use CacheableDependencyTrait;
/**
* {@inheritdoc}
*/
public function __construct(CacheableDependencyInterface $cacheability, $message = NULL, \Exception $previous = NULL, $code = 0) {
$this->setCacheability($cacheability);
parent::__construct($message, $previous, $code);
}
}
<?php
namespace Drupal\Core\Http\Exception;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableDependencyTrait;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
/**
* A cacheable MethodNotAllowedHttpException.
*/
class CacheableMethodNotAllowedHttpException extends MethodNotAllowedHttpException implements CacheableDependencyInterface {
use CacheableDependencyTrait;
/**
* {@inheritdoc}
*/
public function __construct(CacheableDependencyInterface $cacheability, array $allow, $message = NULL, \Exception $previous = NULL, $code = 0) {
$this->setCacheability($cacheability);
parent::__construct($allow, $message, $previous, $code);
}
}
<?php
namespace Drupal\Core\Http\Exception;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableDependencyTrait;
use Symfony\Component\HttpKernel\Exception\NotAcceptableHttpException;
/**
* A cacheable NotAcceptableHttpException.
*/
class CacheableNotAcceptableHttpException extends NotAcceptableHttpException implements CacheableDependencyInterface {
use CacheableDependencyTrait;
/**
* {@inheritdoc}
*/
public function __construct(CacheableDependencyInterface $cacheability, $message = NULL, \Exception $previous = NULL, $code = 0) {
$this->setCacheability($cacheability);
parent::__construct($message, $previous, $code);
}
}
<?php
namespace Drupal\Core\Http\Exception;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableDependencyTrait;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* A cacheable NotFoundHttpException.
*/
class CacheableNotFoundHttpException extends NotFoundHttpException implements CacheableDependencyInterface {
use CacheableDependencyTrait;
/**
* {@inheritdoc}
*/
public function __construct(CacheableDependencyInterface $cacheability, $message = NULL, \Exception $previous = NULL, $code = 0) {
$this->setCacheability($cacheability);
parent::__construct($message, $previous, $code);
}
}
<?php
namespace Drupal\Core\Http\Exception;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableDependencyTrait;
use Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException;
/**
* A cacheable PreconditionFailedHttpException.
*/
class CacheablePreconditionFailedHttpException extends PreconditionFailedHttpException implements CacheableDependencyInterface {
use CacheableDependencyTrait;
/**
* {@inheritdoc}
*/
public function __construct(CacheableDependencyInterface $cacheability, $message = NULL, \Exception $previous = NULL, $code = 0) {
$this->setCacheability($cacheability);
parent::__construct($message, $previous, $code);
}
}
<?php
namespace Drupal\Core\Http\Exception;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableDependencyTrait;
use Symfony\Component\HttpKernel\Exception\PreconditionRequiredHttpException;
/**
* A cacheable PreconditionRequiredHttpException.
*/
class CacheablePreconditionRequiredHttpException extends PreconditionRequiredHttpException implements CacheableDependencyInterface {
use CacheableDependencyTrait;
/**
* {@inheritdoc}
*/
public function __construct(CacheableDependencyInterface $cacheability, $message = NULL, \Exception $previous = NULL, $code = 0) {
$this->setCacheability($cacheability);
parent::__construct($message, $previous, $code);
}
}
<?php
namespace Drupal\Core\Http\Exception;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableDependencyTrait;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
/**
* A cacheable ServiceUnavailableHttpException.
*/
class CacheableServiceUnavailableHttpException extends ServiceUnavailableHttpException implements CacheableDependencyInterface {
use CacheableDependencyTrait;
/**
* {@inheritdoc}
*/
public function __construct(CacheableDependencyInterface $cacheability, $retryAfter = NULL, $message = NULL, \Exception $previous = NULL, $code = 0) {
$this->setCacheability($cacheability);
parent::__construct($retryAfter, $message, $previous, $code);
}
}
<?php
namespace Drupal\Core\Http\Exception;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableDependencyTrait;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
/**
* A cacheable TooManyRequestsHttpException.
*/
class CacheableTooManyRequestsHttpException extends TooManyRequestsHttpException implements CacheableDependencyInterface {
use CacheableDependencyTrait;
/**
* {@inheritdoc}
*/
public function __construct(CacheableDependencyInterface $cacheability, $retryAfter = NULL, $message = NULL, \Exception $previous = NULL, $code = 0) {
$this->setCacheability($cacheability);
parent::__construct($retryAfter, $message, $previous, $code);
}
}
<?php
namespace Drupal\Core\Http\Exception;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableDependencyTrait;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
/**
* A cacheable UnauthorizedHttpException.
*/
class CacheableUnauthorizedHttpException extends UnauthorizedHttpException implements CacheableDependencyInterface {
use CacheableDependencyTrait;
/**
* {@inheritdoc}
*/
public function __construct(CacheableDependencyInterface $cacheability, $challenge, $message = NULL, \Exception $previous = NULL, $code = 0) {
$this->setCacheability($cacheability);
parent::__construct($challenge, $message, $previous, $code);
}
}
<?php
namespace Drupal\Core\Http\Exception;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableDependencyTrait;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* A cacheable UnprocessableEntityHttpException.
*/
class CacheableUnprocessableEntityHttpException extends UnprocessableEntityHttpException implements CacheableDependencyInterface {
use CacheableDependencyTrait;
/**
* {@inheritdoc}
*/
public function __construct(CacheableDependencyInterface $cacheability, $message = NULL, \Exception $previous = NULL, $code = 0) {
$this->setCacheability($cacheability);
parent::__construct($message, $previous, $code);
}
}
<?php
namespace Drupal\Core\Http\Exception;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableDependencyTrait;
use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException;
/**
* A cacheable UnsupportedMediaTypeHttpException.
*/
class CacheableUnsupportedMediaTypeHttpException extends UnsupportedMediaTypeHttpException implements CacheableDependencyInterface {
use CacheableDependencyTrait;
/**
* {@inheritdoc}
*/
public function __construct(CacheableDependencyInterface $cacheability, $message = NULL, \Exception $previous = NULL, $code = 0) {
$this->setCacheability($cacheability);
parent::__construct($message, $previous, $code);
}
}
......@@ -5,12 +5,13 @@
use Drupal\Component\Utility\SafeMarkup;
use Drupal\Core\Authentication\AuthenticationProviderInterface;
use Drupal\Core\Authentication\AuthenticationProviderChallengeInterface;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Flood\FloodInterface;
use Drupal\Core\Http\Exception\CacheableUnauthorizedHttpException;
use Drupal\user\UserAuthInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
/**
* HTTP Basic authentication provider.
......@@ -126,11 +127,35 @@ public function authenticate(Request $request) {
* {@inheritdoc}
*/
public function challengeException(Request $request, \Exception $previous) {
$site_name = $this->configFactory->get('system.site')->get('name');
$site_config = $this->configFactory->get('system.site');
$site_name = $site_config->get('name');
$challenge = SafeMarkup::format('Basic realm="@realm"', [
'@realm' => !empty($site_name) ? $site_name : 'Access restricted',
]);
return new UnauthorizedHttpException((string) $challenge, 'No authentication credentials provided.', $previous);
// A 403 is converted to a 401 here, but it doesn't matter what the
// cacheability was of the 403 exception: what matters here is that
// authentication credentials are missing, i.e. that this request was made
// as the anonymous user.
// Therefore, all we must do, is make this response:
// 1. vary by whether the current user has the 'anonymous' role or not. This
// works fine because:
// - Thanks to \Drupal\basic_auth\PageCache\DisallowBasicAuthRequests,
// Page Cache never caches a response whose request has Basic Auth
// credentials.
// - Dynamic Page Cache will cache a different result for when the
// request is unauthenticated (this 401) versus authenticated (some
// other response)
// 2. have the 'config:user.role.anonymous' cache tag, because the only
// reason this 401 would no longer be a 401 is if permissions for the
// 'anonymous' role change, causing that cache tag to be invalidated.
// @see \Drupal\Core\EventSubscriber\AuthenticationSubscriber::onExceptionSendChallenge()
// @see \Drupal\Core\EventSubscriber\ClientErrorResponseSubscriber()
// @see \Drupal\Core\EventSubscriber\FinishResponseSubscriber::onAllResponds()
$cacheability = CacheableMetadata::createFromObject($site_config)
->addCacheTags(['config:user.role.anonymous'])
->addCacheContexts(['user.roles:anonymous']);
return new CacheableUnauthorizedHttpException($cacheability, (string) $challenge, 'No authentication credentials provided.', $previous);
}
}
......@@ -7,6 +7,7 @@
use Drupal\Tests\basic_auth\Traits\BasicAuthTestTrait;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\Tests\BrowserTestBase;
use Drupal\user\Entity\Role;
/**
* Tests for BasicAuth authentication provider.
......@@ -180,6 +181,47 @@ public function testUnauthorizedErrorMessage() {
$this->assertText('Access denied', "A user friendly access denied message is displayed");
}
/**
* Tests the cacheability of Basic Auth's 401 response.
*
* @see \Drupal\basic_auth\Authentication\Provider\BasicAuth::challengeException()
*/
public function testCacheabilityOf401Response() {
$session = $this->getSession();
$url = Url::fromRoute('router_test.11');
$assert_response_cacheability = function ($expected_page_cache_header_value, $expected_dynamic_page_cache_header_value) use ($session, $url) {
$this->drupalGet($url);
$this->assertSession()->statusCodeEquals(401);
$this->assertSame($expected_page_cache_header_value, $session->getResponseHeader('X-Drupal-Cache'));
$this->assertSame($expected_dynamic_page_cache_header_value, $session->getResponseHeader('X-Drupal-Dynamic-Cache'));
};
// 1. First request: cold caches, both Page Cache and Dynamic Page Cache are
// now primed.
$assert_response_cacheability('MISS', 'MISS');
// 2. Second request: Page Cache HIT, we don't even hit Dynamic Page Cache.
// This is going to keep happening.
$assert_response_cacheability('HIT', 'MISS');
// 3. Third request: after clearing Page Cache, we now see that Dynamic Page
// Cache is a HIT too.
$this->container->get('cache.page')->deleteAll();
$assert_response_cacheability('MISS', 'HIT');
// 4. Fourth request: warm caches.
$assert_response_cacheability('HIT', 'HIT');
// If the permissions of the 'anonymous' role change, it may no longer be
// necessary to be authenticated to access this route. Therefore the cached
// 401 responses should be invalidated.
$this->grantPermissions(Role::load(Role::ANONYMOUS_ID), [$this->randomMachineName()]);
$assert_response_cacheability('MISS', 'MISS');
$assert_response_cacheability('HIT', 'MISS');
// Idem for when the 'system.site' config changes.
$this->config('system.site')->save();
$assert_response_cacheability('MISS', 'MISS');
$assert_response_cacheability('HIT', 'MISS');
}
/**
* Tests if the controller is called before authentication.
*
......
......@@ -7,3 +7,8 @@ services:
class: Drupal\rest_test\Authentication\Provider\TestAuthGlobal
tags:
- { name: authentication_provider, provider_id: 'rest_test_auth_global', global: TRUE }
rest_test.page_cache_request_policy.deny_test_auth_requests:
class: Drupal\rest_test\PageCache\RequestPolicy\DenyTestAuthRequests
public: false
tags:
- { name: page_cache_request_policy }