Implement ConfirmationTokenChain to handle multiple tokens at once

This commit is contained in:
Loz Calver 2018-08-24 15:36:51 +01:00 committed by Aaron Carlino
parent 637b4225c6
commit 0877442c64
8 changed files with 500 additions and 59 deletions

View File

@ -15,7 +15,7 @@ use SilverStripe\Security\RandomGenerator;
*
* @internal This class is designed specifically for use pre-startup and may change without warning
*/
abstract class ConfirmationToken
abstract class AbstractConfirmationToken
{
/**
* @var HTTPRequest
@ -173,6 +173,16 @@ HTML;
*/
abstract public function params($includeToken = true);
/**
* @return string
*/
abstract public function getRedirectUrlBase();
/**
* @return array
*/
abstract public function getRedirectUrlParams();
/**
* Get redirection URL
*

View File

@ -0,0 +1,178 @@
<?php
namespace SilverStripe\Core\Startup;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Core\Convert;
/**
* A chain of confirmation tokens to be validated on each request. This allows the application to
* check multiple tokens at once without having to potentially redirect the user for each of them
*
* @internal This class is designed specifically for use pre-startup and may change without warning
*/
class ConfirmationTokenChain
{
/**
* @var array
*/
protected $tokens = [];
/**
* @param AbstractConfirmationToken $token
*/
public function pushToken(AbstractConfirmationToken $token)
{
$this->tokens[] = $token;
}
/**
* Collect all tokens that require a redirect
*
* @return \Generator
*/
protected function filteredTokens()
{
foreach ($this->tokens as $token) {
if ($token->reloadRequired() || $token->reloadRequiredIfError()) {
yield $token;
}
}
}
/**
* @return bool
*/
public function suppressionRequired()
{
foreach ($this->tokens as $token) {
if ($token->reloadRequired()) {
return true;
}
}
return false;
}
/**
* Suppress URLs & GET vars from tokens that require a redirect
*/
public function suppressTokens()
{
foreach ($this->filteredTokens() as $token) {
$token->suppress();
}
}
/**
* @return bool
*/
public function reloadRequired()
{
foreach ($this->tokens as $token) {
if ($token->reloadRequired()) {
return true;
}
}
return false;
}
/**
* @return bool
*/
public function reloadRequiredIfError()
{
foreach ($this->tokens as $token) {
if ($token->reloadRequiredIfError()) {
return true;
}
}
return false;
}
/**
* @param bool $includeToken
* @return array
*/
public function params($includeToken = true)
{
$params = [];
foreach ($this->tokens as $token) {
$params = array_merge($params, $token->params($includeToken));
}
return $params;
}
/**
* Fetch the URL we want to redirect to, excluding query string parameters. This may
* be the same URL (with a token to be added outside this method), or to a different
* URL if the current one has been suppressed
*
* @return string
*/
public function getRedirectUrlBase()
{
// URLConfirmationTokens may alter the URL to suppress the URL they're protecting,
// so we need to ensure they're inspected last and therefore take priority
$tokens = iterator_to_array($this->filteredTokens(), false);
usort($tokens, function ($a, $b) {
return ($a instanceof URLConfirmationToken) ? 1 : 0;
});
$urlBase = Director::baseURL();
foreach ($tokens as $token) {
$urlBase = $token->getRedirectUrlBase();
}
return $urlBase;
}
/**
* Collate GET vars from all token providers that need to apply a token
*
* @return array
*/
public function getRedirectUrlParams()
{
$params = [];
foreach ($this->filteredTokens() as $token) {
$params = array_merge($params, $token->getRedirectUrlParams());
}
return $params;
}
/**
* @return string
*/
protected function redirectURL()
{
$params = http_build_query($this->getRedirectUrlParams());
return Controller::join_links($this->getRedirectUrlBase(), '?' . $params);
}
/**
* @return HTTPResponse
*/
public function reloadWithTokens()
{
$location = $this->redirectURL();
$locationJS = Convert::raw2js($location);
$locationATT = Convert::raw2att($location);
$body = <<<HTML
<script>location.href='$locationJS';</script>
<noscript><meta http-equiv="refresh" content="0; url=$locationATT"></noscript>
You are being redirected. If you are not redirected soon, <a href="$locationATT">click here to continue</a>
HTML;
// Build response
$result = new HTTPResponse($body);
$result->redirect($location);
return $result;
}
}

View File

@ -34,20 +34,18 @@ class ErrorControlChainMiddleware implements HTTPMiddleware
/**
* @param HTTPRequest $request
* @return ConfirmationToken|null
* @return ConfirmationTokenChain
*/
protected function prepareConfirmationTokenIfRequired(HTTPRequest $request)
protected function prepareConfirmationTokenChain(HTTPRequest $request)
{
$token = URLConfirmationToken::prepare_tokens(['dev/build'], $request);
$chain = new ConfirmationTokenChain();
$chain->pushToken(new URLConfirmationToken('dev/build', $request));
if (!$token) {
$token = ParameterConfirmationToken::prepare_tokens(
['isTest', 'isDev', 'flush'],
$request
);
foreach (['isTest', 'isDev', 'flush'] as $parameter) {
$chain->pushToken(new ParameterConfirmationToken($parameter, $request));
}
return $token;
return $chain;
}
public function process(HTTPRequest $request, callable $next)
@ -55,19 +53,21 @@ class ErrorControlChainMiddleware implements HTTPMiddleware
$result = null;
// Prepare tokens and execute chain
$confirmationToken = $this->prepareConfirmationTokenIfRequired($request);
$chain = new ErrorControlChain();
$chain
->then(function () use ($request, $chain, $confirmationToken, $next, &$result) {
// If no redirection is necessary then we can disable error supression
if (!$confirmationToken) {
$chain->setSuppression(false);
$confirmationTokenChain = $this->prepareConfirmationTokenChain($request);
$errorControlChain = new ErrorControlChain();
$errorControlChain
->then(function () use ($request, $errorControlChain, $confirmationTokenChain, $next, &$result) {
if ($confirmationTokenChain->suppressionRequired()) {
$confirmationTokenChain->suppressTokens();
} else {
// If no redirection is necessary then we can disable error supression
$errorControlChain->setSuppression(false);
}
try {
// Check if a token is requesting a redirect
if ($confirmationToken && $confirmationToken->reloadRequired()) {
$result = $this->safeReloadWithToken($request, $confirmationToken);
if ($confirmationTokenChain && $confirmationTokenChain->reloadRequired()) {
$result = $this->safeReloadWithTokens($request, $confirmationTokenChain);
} else {
// If no reload necessary, process application
$result = call_user_func($next, $request);
@ -77,10 +77,16 @@ class ErrorControlChainMiddleware implements HTTPMiddleware
}
})
// Finally if a token was requested but there was an error while figuring out if it's allowed, do it anyway
->thenIfErrored(function () use ($confirmationToken) {
if ($confirmationToken && $confirmationToken->reloadRequiredIfError()) {
$result = $confirmationToken->reloadWithToken();
$result->output();
->thenIfErrored(function () use ($confirmationTokenChain) {
if ($confirmationTokenChain && $confirmationTokenChain->reloadRequiredIfError()) {
try {
// Reload requires manual boot
$this->getApplication()->getKernel()->boot(false);
} finally {
// Given we're in an error state here, try to continue even if the kernel boot fails
$result = $confirmationTokenChain->reloadWithTokens();
$result->output();
}
}
})
->execute();
@ -92,10 +98,10 @@ class ErrorControlChainMiddleware implements HTTPMiddleware
* or authentication is impossible.
*
* @param HTTPRequest $request
* @param ParameterConfirmationToken $reloadToken
* @param ConfirmationTokenChain $confirmationTokenChain
* @return HTTPResponse
*/
protected function safeReloadWithToken(HTTPRequest $request, $reloadToken)
protected function safeReloadWithTokens(HTTPRequest $request, ConfirmationTokenChain $confirmationTokenChain)
{
// Safe reload requires manual boot
$this->getApplication()->getKernel()->boot(false);
@ -104,9 +110,9 @@ class ErrorControlChainMiddleware implements HTTPMiddleware
$request->getSession()->init($request);
// Request with ErrorDirector
$result = ErrorDirector::singleton()->handleRequestWithToken(
$result = ErrorDirector::singleton()->handleRequestWithTokenChain(
$request,
$reloadToken,
$confirmationTokenChain,
$this->getApplication()->getKernel()
);
if ($result) {
@ -114,8 +120,8 @@ class ErrorControlChainMiddleware implements HTTPMiddleware
}
// Fail and redirect the user to the login page
$params = array_merge($request->getVars(), $reloadToken->params(false));
$backURL = $reloadToken->currentURL() . '?' . http_build_query($params);
$params = array_merge($request->getVars(), $confirmationTokenChain->params(false));
$backURL = $confirmationTokenChain->getRedirectUrlBase() . '?' . http_build_query($params);
$loginPage = Director::absoluteURL(Security::config()->get('login_url'));
$loginPage .= "?BackURL=" . urlencode($backURL);
$result = new HTTPResponse();

View File

@ -21,18 +21,21 @@ class ErrorDirector extends Director
* Redirect with token if allowed, or null if not allowed
*
* @param HTTPRequest $request
* @param ConfirmationToken $token
* @param ConfirmationTokenChain $confirmationTokenChain
* @param Kernel $kernel
* @return null|HTTPResponse Redirection response, or null if not able to redirect
*/
public function handleRequestWithToken(HTTPRequest $request, ConfirmationToken $token, Kernel $kernel)
{
public function handleRequestWithTokenChain(
HTTPRequest $request,
ConfirmationTokenChain $confirmationTokenChain,
Kernel $kernel
) {
Injector::inst()->registerService($request, HTTPRequest::class);
// Next, check if we're in dev mode, or the database doesn't have any security data, or we are admin
$reload = function (HTTPRequest $request) use ($token, $kernel) {
$reload = function (HTTPRequest $request) use ($confirmationTokenChain, $kernel) {
if ($kernel->getEnvironment() === Kernel::DEV || !Security::database_is_ready() || Permission::check('ADMIN')) {
return $token->reloadWithToken();
return $confirmationTokenChain->reloadWithTokens();
}
return null;
};

View File

@ -3,6 +3,7 @@
namespace SilverStripe\Core\Startup;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Core\Convert;
@ -15,7 +16,7 @@ use SilverStripe\Security\RandomGenerator;
*
* @internal This class is designed specifically for use pre-startup and may change without warning
*/
class ParameterConfirmationToken extends ConfirmationToken
class ParameterConfirmationToken extends AbstractConfirmationToken
{
/**
* The name of the parameter
@ -140,19 +141,22 @@ class ParameterConfirmationToken extends ConfirmationToken
}
return $params;
}
public function getRedirectUrlBase()
{
return ($this->existsInReferer() && !$this->parameterProvided()) ? Director::baseURL() : $this->currentURL();
}
public function getRedirectUrlParams()
{
return ($this->existsInReferer() && !$this->parameterProvided())
? $this->params()
: array_merge($this->request->getVars(), $this->params());
}
protected function redirectURL()
{
// If url is encoded via BackURL, defer to home page (prevent redirect to form action)
if ($this->existsInReferer() && !$this->parameterProvided()) {
$url = BASE_URL ?: '/';
$params = $this->params();
} else {
$url = $this->currentURL();
$params = array_merge($this->request->getVars(), $this->params());
}
// Merge get params with current url
return Controller::join_links($url, '?' . http_build_query($params));
$query = http_build_query($this->getRedirectUrlParams());
return Controller::join_links($this->getRedirectUrlBase(), '?' . $query);
}
}

View File

@ -12,7 +12,7 @@ use SilverStripe\Control\HTTPRequest;
*
* @internal This class is designed specifically for use pre-startup and may change without warning
*/
class URLConfirmationToken extends ConfirmationToken
class URLConfirmationToken extends AbstractConfirmationToken
{
/**
* @var string
@ -60,7 +60,7 @@ class URLConfirmationToken extends ConfirmationToken
*/
protected function getURLExistsInBackURL(HTTPRequest $request)
{
$backURL = $request->getVar('BackURL');
$backURL = ltrim($request->getVar('BackURL'), '/');
return (strpos($backURL, $this->urlToCheck) === 0);
}
@ -119,18 +119,21 @@ class URLConfirmationToken extends ConfirmationToken
return Controller::join_links(Director::baseURL(), $this->currentURL);
}
public function getRedirectUrlBase()
{
return ($this->urlExistsInBackURL && !$this->urlMatches()) ? Director::baseURL() : $this->currentURL();
}
public function getRedirectUrlParams()
{
return ($this->urlExistsInBackURL && !$this->urlMatches())
? $this->params()
: array_merge($this->request->getVars(), $this->params());
}
protected function redirectURL()
{
// If url is encoded via BackURL, defer to home page (prevent redirect to form action)
if ($this->urlExistsInBackURL && !$this->urlMatches()) {
$url = BASE_URL ?: '/';
$params = $this->params();
} else {
$url = $this->currentURL();
$params = array_merge($this->request->getVars(), $this->params());
}
// Merge get params with current url
return Controller::join_links($url, '?' . http_build_query($params));
$query = http_build_query($this->getRedirectUrlParams());
return Controller::join_links($this->getRedirectUrlBase(), '?' . $query);
}
}

View File

@ -0,0 +1,185 @@
<?php
namespace SilverStripe\Core\Tests\Startup;
use SilverStripe\Core\Startup\ConfirmationTokenChain;
use SilverStripe\Core\Startup\ParameterConfirmationToken;
use SilverStripe\Core\Startup\URLConfirmationToken;
use SilverStripe\Dev\SapphireTest;
class ConfirmationTokenChainTest extends SapphireTest
{
protected function getTokenRequiringReload($requiresReload = true, $extraMethods = [])
{
$methods = array_merge(['reloadRequired'], $extraMethods);
$mock = $this->createPartialMock(ParameterConfirmationToken::class, $methods);
$mock->expects($this->any())
->method('reloadRequired')
->will($this->returnValue($requiresReload));
return $mock;
}
protected function getTokenRequiringReloadIfError($requiresReload = true, $extraMethods = [])
{
$methods = array_merge(['reloadRequired', 'reloadRequiredIfError'], $extraMethods);
$mock = $this->createPartialMock(ParameterConfirmationToken::class, $methods);
$mock->expects($this->any())
->method('reloadRequired')
->will($this->returnValue(false));
$mock->expects($this->any())
->method('reloadRequiredIfError')
->will($this->returnValue($requiresReload));
return $mock;
}
public function testFilteredTokens()
{
$chain = new ConfirmationTokenChain();
$chain->pushToken($tokenRequiringReload = $this->getTokenRequiringReload());
$chain->pushToken($tokenNotRequiringReload = $this->getTokenRequiringReload(false));
$chain->pushToken($tokenRequiringReloadIfError = $this->getTokenRequiringReloadIfError());
$chain->pushToken($tokenNotRequiringReloadIfError = $this->getTokenRequiringReloadIfError(false));
$reflectionMethod = new \ReflectionMethod(ConfirmationTokenChain::class, 'filteredTokens');
$reflectionMethod->setAccessible(true);
$tokens = iterator_to_array($reflectionMethod->invoke($chain));
$this->assertContains($tokenRequiringReload, $tokens, 'Token requiring a reload was not returned');
$this->assertNotContains($tokenNotRequiringReload, $tokens, 'Token not requiring a reload was returned');
$this->assertContains($tokenRequiringReloadIfError, $tokens, 'Token requiring a reload on error was not returned');
$this->assertNotContains($tokenNotRequiringReloadIfError, $tokens, 'Token not requiring a reload on error was returned');
}
public function testSuppressionRequired()
{
$chain = new ConfirmationTokenChain();
$chain->pushToken($this->getTokenRequiringReload(false));
$this->assertFalse($chain->suppressionRequired(), 'Suppression incorrectly marked as required');
$chain = new ConfirmationTokenChain();
$chain->pushToken($this->getTokenRequiringReloadIfError(false));
$this->assertFalse($chain->suppressionRequired(), 'Suppression incorrectly marked as required');
$chain = new ConfirmationTokenChain();
$chain->pushToken($this->getTokenRequiringReload());
$this->assertTrue($chain->suppressionRequired(), 'Suppression not marked as required');
$chain = new ConfirmationTokenChain();
$chain->pushToken($this->getTokenRequiringReloadIfError());
$this->assertFalse($chain->suppressionRequired(), 'Suppression incorrectly marked as required');
}
public function testSuppressTokens()
{
$mockToken = $this->getTokenRequiringReload(true, ['suppress']);
$mockToken->expects($this->once())
->method('suppress');
$secondMockToken = $this->getTokenRequiringReloadIfError(true, ['suppress']);
$secondMockToken->expects($this->once())
->method('suppress');
$chain = new ConfirmationTokenChain();
$chain->pushToken($mockToken);
$chain->pushToken($secondMockToken);
$chain->suppressTokens();
}
public function testReloadRequired()
{
$mockToken = $this->getTokenRequiringReload(true);
$secondMockToken = $this->getTokenRequiringReload(false);
$chain = new ConfirmationTokenChain();
$chain->pushToken($mockToken);
$chain->pushToken($secondMockToken);
$this->assertTrue($chain->reloadRequired());
}
public function testReloadRequiredIfError()
{
$mockToken = $this->getTokenRequiringReloadIfError(true);
$secondMockToken = $this->getTokenRequiringReloadIfError(false);
$chain = new ConfirmationTokenChain();
$chain->pushToken($mockToken);
$chain->pushToken($secondMockToken);
$this->assertTrue($chain->reloadRequiredIfError());
}
public function testParams()
{
$mockToken = $this->getTokenRequiringReload(true, ['params']);
$mockToken->expects($this->once())
->method('params')
->with($this->isTrue())
->will($this->returnValue(['mockTokenParam' => '1']));
$secondMockToken = $this->getTokenRequiringReload(true, ['params']);
$secondMockToken->expects($this->once())
->method('params')
->with($this->isTrue())
->will($this->returnValue(['secondMockTokenParam' => '2']));
$chain = new ConfirmationTokenChain();
$chain->pushToken($mockToken);
$chain->pushToken($secondMockToken);
$this->assertEquals(['mockTokenParam' => '1', 'secondMockTokenParam' => '2'], $chain->params(true));
$mockToken = $this->getTokenRequiringReload(true, ['params']);
$mockToken->expects($this->once())
->method('params')
->with($this->isFalse())
->will($this->returnValue(['mockTokenParam' => '1']));
$chain = new ConfirmationTokenChain();
$chain->pushToken($mockToken);
$this->assertEquals(['mockTokenParam' => '1'], $chain->params(false));
}
public function testGetRedirectUrlBase()
{
$mockUrlToken = $this->createPartialMock(URLConfirmationToken::class, ['reloadRequired', 'getRedirectUrlBase']);
$mockUrlToken->expects($this->any())
->method('reloadRequired')
->will($this->returnValue(true));
$mockUrlToken->expects($this->any())
->method('getRedirectUrlBase')
->will($this->returnValue('url-base'));
$mockParameterToken = $this->createPartialMock(ParameterConfirmationToken::class, ['reloadRequired', 'getRedirectUrlBase']);
$mockParameterToken->expects($this->any())
->method('reloadRequired')
->will($this->returnValue(true));
$mockParameterToken->expects($this->any())
->method('getRedirectUrlBase')
->will($this->returnValue('parameter-base'));
$chain = new ConfirmationTokenChain();
$chain->pushToken($mockParameterToken);
$chain->pushToken($mockUrlToken);
$this->assertEquals('url-base', $chain->getRedirectUrlBase(), 'URLConfirmationToken url base should take priority');
// Push them in reverse order to check priority still correct
$chain = new ConfirmationTokenChain();
$chain->pushToken($mockUrlToken);
$chain->pushToken($mockParameterToken);
$this->assertEquals('url-base', $chain->getRedirectUrlBase(), 'URLConfirmationToken url base should take priority');
}
public function testGetRedirectUrlParams()
{
$mockToken = $this->getTokenRequiringReload(true, ['getRedirectUrlParams']);
$mockToken->expects($this->once())
->method('getRedirectUrlParams')
->will($this->returnValue(['mockTokenParam' => '1']));
$secondMockToken = $this->getTokenRequiringReload(true, ['getRedirectUrlParams']);
$secondMockToken->expects($this->once())
->method('getRedirectUrlParams')
->will($this->returnValue(['secondMockTokenParam' => '2']));
$chain = new ConfirmationTokenChain();
$chain->pushToken($mockToken);
$chain->pushToken($secondMockToken);
$this->assertEquals(['mockTokenParam' => '1', 'secondMockTokenParam' => '2'], $chain->getRedirectUrlParams());
}
}

View File

@ -121,4 +121,56 @@ class ErrorControlChainMiddlewareTest extends SapphireTest
$this->assertNotContains('?devbuildtoken=', $location);
$this->assertContains('Security/login', $location);
}
public function testLiveBuildAndFlushAdmin()
{
// Mock admin
$adminID = $this->logInWithPermission('ADMIN');
$this->logOut();
// Mock app
$app = new HTTPApplication(new BlankKernel(BASE_PATH));
$app->getKernel()->setEnvironment(Kernel::LIVE);
// Test being logged in as admin
$chain = new ErrorControlChainMiddleware($app);
$request = new HTTPRequest('GET', '/dev/build/', ['flush' => '1']);
$request->setSession(new Session(['loggedInAs' => $adminID]));
$result = $chain->process($request, function () {
return null;
});
$this->assertInstanceOf(HTTPResponse::class, $result);
$location = $result->getHeader('Location');
$this->assertContains('/dev/build', $location);
$this->assertContains('flush=1', $location);
$this->assertContains('devbuildtoken=', $location);
$this->assertContains('flushtoken=', $location);
$this->assertNotContains('Security/login', $location);
}
public function testLiveBuildAndFlushUnauthenticated()
{
// Mock app
$app = new HTTPApplication(new BlankKernel(BASE_PATH));
$app->getKernel()->setEnvironment(Kernel::LIVE);
// Test being logged in as no one
Security::setCurrentUser(null);
$chain = new ErrorControlChainMiddleware($app);
$request = new HTTPRequest('GET', '/dev/build', ['flush' => '1']);
$request->setSession(new Session(['loggedInAs' => 0]));
$result = $chain->process($request, function () {
return null;
});
// Should be directed to login, not to flush
$this->assertInstanceOf(HTTPResponse::class, $result);
$location = $result->getHeader('Location');
$this->assertNotContains('/dev/build', $location);
$this->assertNotContains('flush=1', $location);
$this->assertNotContains('devbuildtoken=', $location);
$this->assertNotContains('flushtoken=', $location);
$this->assertContains('Security/login', $location);
}
}