diff --git a/src/Symfony/Component/Security/Core/Exception/UnexpectedValueException.php b/src/Symfony/Component/Security/Core/Exception/UnexpectedValueException.php new file mode 100644 index 0000000000000..165735eb37f6f --- /dev/null +++ b/src/Symfony/Component/Security/Core/Exception/UnexpectedValueException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Exception; + +/** + * Base UnexpectedValueException for the Security component. + * + * @author Oliver Hoff + */ +class UnexpectedValueException extends \UnexpectedValueException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Security/Csrf/TokenStorage/AbstractTokenStorageProxy.php b/src/Symfony/Component/Security/Csrf/TokenStorage/AbstractTokenStorageProxy.php new file mode 100644 index 0000000000000..326088865ecfb --- /dev/null +++ b/src/Symfony/Component/Security/Csrf/TokenStorage/AbstractTokenStorageProxy.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Csrf\TokenStorage; + +/** + * Forwards calls to another TokenStorageInterface. + * + * @author Oliver Hoff + */ +abstract class AbstractTokenStorageProxy implements TokenStorageInterface +{ + /** + * {@inheritdoc} + */ + public function getToken($tokenId) + { + return $this->getProxiedTokenStorage()->getToken($tokenId); + } + + /** + * {@inheritdoc} + */ + public function setToken($tokenId, $token) + { + // TODO interface declares return void, use return stmt or not? + $this->getProxiedTokenStorage()->setToken($tokenId, $token); + } + + /** + * {@inheritdoc} + */ + public function removeToken($tokenId) + { + return $this->getProxiedTokenStorage()->removeToken($tokenId); + } + + /** + * {@inheritdoc} + */ + public function hasToken($tokenId) + { + return $this->getProxiedTokenStorage()->hasToken($tokenId); + } + + /** + * @return TokenStorageInterface + */ + abstract protected function getProxiedTokenStorage(); +} diff --git a/src/Symfony/Component/Security/Csrf/TokenStorage/CookieTokenStorage.php b/src/Symfony/Component/Security/Csrf/TokenStorage/CookieTokenStorage.php new file mode 100644 index 0000000000000..f306742e39ce4 --- /dev/null +++ b/src/Symfony/Component/Security/Csrf/TokenStorage/CookieTokenStorage.php @@ -0,0 +1,334 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Csrf\TokenStorage; + +use Symfony\Component\HttpFoundation\Cookie; +use Symfony\Component\Security\Core\Exception\InvalidArgumentException; +use Symfony\Component\Security\Csrf\Exception\TokenNotFoundException; + +/** + * Accesses tokens in a set of cookies. A changeset records edits made to + * tokens. The changeset can be retrieved as a list of cookies to be used in a + * response's headers to "persist" the changes. + * + * @author Oliver Hoff + */ +class CookieTokenStorage implements TokenStorageInterface +{ + /** + * @var string + */ + const COOKIE_DELIMITER = '_'; + + /** + * @var array A map of tokens to be written in the response + */ + private $transientTokens = array(); + + /** + * @var array A map of tokens extracted from cookies and verified + */ + private $extractedTokens = array(); + + /** + * @var array + */ + private $nonces = array(); + + /** + * @var array + */ + private $cookies; + + /** + * @var bool + */ + private $secure; + + /** + * @var string + */ + private $secret; + + /** + * @var int + */ + private $ttl; + + /** + * @param string $cookies The raw HTTP Cookie header + * @param bool $secure + * @param string $secret + * @param int $ttl + */ + public function __construct($cookies, $secure, $secret, $ttl = null) + { + $this->cookies = self::parseCookieHeader($cookies); + $this->secure = (bool) $secure; + $this->secret = (string) $secret; + $this->ttl = null === $ttl ? 60 * 60 : (int) $ttl; + + if ('' === $this->secret) { + throw new InvalidArgumentException('Secret must be a non-empty string'); + } + + if ($this->ttl < 60) { + throw new InvalidArgumentException('TTL must be an integer greater than or equal to 60'); + } + } + + /** + * {@inheritdoc} + */ + public function getToken($tokenId) + { + $token = $this->resolveToken($tokenId); + + if ('' === $token) { + throw new TokenNotFoundException(); + } + + return $token; + } + + /** + * {@inheritdoc} + */ + public function hasToken($tokenId) + { + return '' !== $this->resolveToken($tokenId); + } + + /** + * {@inheritdoc} + */ + public function setToken($tokenId, $token) + { + $token = (string) $token; + + if ('' === $token) { + throw new InvalidArgumentException('Empty tokens are not allowed'); + } + + // we need to resolve the token first to record the nonces + $this->resolveToken($tokenId); + + $this->transientTokens[$tokenId] = $token; + } + + /** + * {@inheritdoc} + */ + public function removeToken($tokenId) + { + $token = $this->resolveToken($tokenId); + + $this->transientTokens[$tokenId] = ''; + + return '' === $token ? null : $token; + } + + /** + * @return Cookie[] + */ + public function createCookies() + { + $cookies = array(); + + foreach ($this->transientTokens as $tokenId => $token) { + if (isset($this->nonces[$tokenId])) { + foreach (array_keys($this->nonces[$tokenId]) as $nonce) { + $cookies[] = $this->createDeleteCookie($tokenId, $nonce); + } + } + + if ('' !== $token) { + $cookies[] = $this->createCookie($tokenId, $token); + } + } + + return $cookies; + } + + /** + * @param string $tokenId + * + * @return string + */ + protected function resolveToken($tokenId) + { + if (isset($this->transientTokens[$tokenId])) { + return $this->transientTokens[$tokenId]; + } + + if (isset($this->extractedTokens[$tokenId])) { + return $this->extractedTokens[$tokenId]; + } + + $this->extractedTokens[$tokenId] = ''; + + $prefix = $this->generateCookieName($tokenId, ''); + $prefixLength = strlen($prefix); + $cookies = $this->findCookiesByPrefix($prefix); + + // record the nonces used, so we can delete all obsolete cookies of this + // token id, if necessary + foreach ($cookies as $cookie) { + $this->nonces[$tokenId][substr($cookie[0], $prefixLength)] = true; + } + + // if there is more than one cookie for the prefix, we get cookie tossed maybe + if (count($cookies) != 1) { + return ''; + } + + $parts = explode(self::COOKIE_DELIMITER, $cookies[0][1], 3); + if (count($parts) != 3) { + return ''; + } + list($expires, $signature, $token) = $parts; + + // expired token + $time = time(); + if (!ctype_digit($expires) || $expires < $time) { + return ''; + } + + // invalid signature + $nonce = substr($cookies[0][0], $prefixLength); + if (!hash_equals($this->generateSignature($tokenId, $token, $expires, $nonce), $signature)) { + return ''; + } + + $time += $this->ttl / 2; + if ($expires < $time) { + $this->transientTokens[$tokenId] = $token; + } + + return $this->extractedTokens[$tokenId] = $token; + } + + /** + * @param string $prefix + * + * @return array + */ + protected function findCookiesByPrefix($prefix) + { + $cookies = array(); + foreach ($this->cookies as $cookie) { + if (0 === strpos($cookie[0], $prefix)) { + $cookies[] = $cookie; + } + } + + return $cookies; + } + + /** + * @param string $tokenId + * @param string $nonce + * + * @return Cookie + */ + protected function createDeleteCookie($tokenId, $nonce) + { + $name = $this->generateCookieName($tokenId, $nonce); + + return new Cookie($name, '', 0, null, null, $this->secure, true); + } + + /** + * @param string $tokenId + * @param string $token + * + * @return Cookie + */ + protected function createCookie($tokenId, $token) + { + $expires = time() + $this->ttl; + $nonce = self::encodeBase64Url(random_bytes(6)); + $signature = $this->generateSignature($tokenId, $token, $expires, $nonce); + + $this->nonces[$tokenId][$nonce] = true; + + $name = $this->generateCookieName($tokenId, $nonce); + $value = $expires.self::COOKIE_DELIMITER.$signature.self::COOKIE_DELIMITER.$token; + + return new Cookie($name, $value, 0, null, null, $this->secure, true); + } + + /** + * @param string $tokenId + * @param string $nonce + * + * @return string + */ + protected function generateCookieName($tokenId, $nonce) + { + return sprintf( + '_csrf_%s_%s_%s', + (int) $this->secure, + self::encodeBase64Url($tokenId), + $nonce + ); + } + + /** + * @param string $tokenId + * @param string $token + * @param int $expires + * @param string $nonce + * + * @return string + */ + protected function generateSignature($tokenId, $token, $expires, $nonce) + { + return hash_hmac('sha256', $tokenId.$token.$expires.$nonce.$this->secure, $this->secret); + } + + /** + * @param string $header + * + * @return array + */ + public static function parseCookieHeader($header) + { + $header = trim((string) $header); + if ('' === $header) { + return array(); + } + + $cookies = array(); + foreach (explode(';', $header) as $cookie) { + if (false === strpos($cookie, '=')) { + continue; + } + + $cookies[] = array_map(function ($item) { + return urldecode(trim($item, ' "')); + }, explode('=', $cookie, 2)); + } + + return $cookies; + } + + /** + * @param string $data + * + * @return string + */ + public static function encodeBase64Url($data) + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } +} diff --git a/src/Symfony/Component/Security/Csrf/TokenStorage/CookieTokenStorageFactory.php b/src/Symfony/Component/Security/Csrf/TokenStorage/CookieTokenStorageFactory.php new file mode 100644 index 0000000000000..2cd04c6b58125 --- /dev/null +++ b/src/Symfony/Component/Security/Csrf/TokenStorage/CookieTokenStorageFactory.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Csrf\TokenStorage; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Exception\InvalidArgumentException; + +/** + * Creates CSRF token storages based on the requests cookies. + * + * @author Oliver Hoff + */ +class CookieTokenStorageFactory implements TokenStorageFactoryInterface +{ + /** + * @var string + */ + private $secret; + + /** + * @var int + */ + private $ttl; + + /** + * @param string $secret + * @param int $ttl + */ + public function __construct($secret, $ttl = null) + { + $this->secret = (string) $secret; + $this->ttl = null === $ttl ? 60 * 60 : (int) $ttl; + + if ('' === $this->secret) { + throw new InvalidArgumentException('Secret must be a non-empty string'); + } + + if ($this->ttl < 60) { + throw new InvalidArgumentException('TTL must be an integer greater than or equal to 60'); + } + } + + /** + * {@inheritdoc} + */ + public function createTokenStorage(Request $request) + { + return new CookieTokenStorage($request->headers->get('Cookie'), $request->isSecure(), $this->secret, $this->ttl); + } +} diff --git a/src/Symfony/Component/Security/Csrf/TokenStorage/CookieTokenStorageListener.php b/src/Symfony/Component/Security/Csrf/TokenStorage/CookieTokenStorageListener.php new file mode 100644 index 0000000000000..bb81df6086a69 --- /dev/null +++ b/src/Symfony/Component/Security/Csrf/TokenStorage/CookieTokenStorageListener.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Csrf\TokenStorage; + +use Symfony\Component\HttpKernel\Event\FilterResponseEvent; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Checks the request's attributes for a CookieTokenStorage instance. If one is + * found, the cookies representing the storage's changeset are appended to the + * response headers. + * + * TODO where to put this class? + * + * @author Oliver Hoff + */ +class CookieTokenStorageListener implements EventSubscriberInterface +{ + /** + * @var string + */ + const DEFAULT_TOKEN_STORAGE_KEY = '_csrf_token_storage'; + + /** + * @var string + */ + private $tokenStorageKey; + + /** + * @param string|null $tokenStorageKey + */ + public function __construct($tokenStorageKey = null) + { + $this->tokenStorageKey = null === $tokenStorageKey ? self::DEFAULT_TOKEN_STORAGE_KEY : $tokenStorageKey; + } + + /** + * @param FilterResponseEvent $event + */ + public function onKernelResponse(FilterResponseEvent $event) + { + $storage = $event->getRequest()->attributes->get($this->tokenStorageKey); + if (!$storage instanceof CookieTokenStorage) { + return; + } + + $headers = $event->getResponse()->headers; + foreach ($storage->createCookies() as $cookie) { + $headers->setCookie($cookie); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() + { + return array( + KernelEvents::RESPONSE => array(array('onKernelResponse', 0)), + ); + } +} diff --git a/src/Symfony/Component/Security/Csrf/TokenStorage/RequestStackTokenStorage.php b/src/Symfony/Component/Security/Csrf/TokenStorage/RequestStackTokenStorage.php new file mode 100644 index 0000000000000..dae342e9adce0 --- /dev/null +++ b/src/Symfony/Component/Security/Csrf/TokenStorage/RequestStackTokenStorage.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Csrf\TokenStorage; + +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Security\Core\Exception\RuntimeException; +use Symfony\Component\Security\Core\Exception\UnexpectedValueException; + +/** + * Forwards token storage calls to a token storage stored in the master + * request's attributes. If the attributes don't hold a token storage yet, one + * is created and set into the attributes. + * + * @author Oliver Hoff + */ +class RequestStackTokenStorage extends AbstractTokenStorageProxy +{ + /** + * @var string + */ + const DEFAULT_TOKEN_STORAGE_KEY = '_csrf_token_storage'; + + /** + * @var RequestStack + */ + private $requestStack; + + /** + * @var TokenStorageFactoryInterface + */ + private $factory; + + /** + * @var string + */ + private $tokenStorageKey; + + /** + * @param RequestStack $requestStack + * @param TokenStorageFactoryInterface $factory + * @param string|null $tokenStorageKey + */ + public function __construct(RequestStack $requestStack, TokenStorageFactoryInterface $factory, $tokenStorageKey = null) + { + $this->requestStack = $requestStack; + $this->factory = $factory; + $this->tokenStorageKey = $tokenStorageKey === null ? self::DEFAULT_TOKEN_STORAGE_KEY : $tokenStorageKey; + } + + /** + * {@inheritdoc} + */ + public function getProxiedTokenStorage() + { + $request = $this->requestStack->getMasterRequest(); + + if (!$request) { + throw new RuntimeException('Not in a request context'); + } + + $storage = $request->attributes->get($this->tokenStorageKey); + + if ($storage instanceof TokenStorageInterface) { + return $storage; + } + + if (null !== $storage) { + throw new UnexpectedValueException(sprintf('Expected null or an implementation of "%s", got "%s"', TokenStorageInterface::class, is_object($storage) ? get_class($storage) : gettype($storage))); + } + + $storage = $this->factory->createTokenStorage($request); + $request->attributes->set($this->tokenStorageKey, $storage); + + return $storage; + } +} diff --git a/src/Symfony/Component/Security/Csrf/TokenStorage/SessionTokenStorageFactory.php b/src/Symfony/Component/Security/Csrf/TokenStorage/SessionTokenStorageFactory.php new file mode 100644 index 0000000000000..eab816a99d039 --- /dev/null +++ b/src/Symfony/Component/Security/Csrf/TokenStorage/SessionTokenStorageFactory.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Csrf\TokenStorage; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Exception\RuntimeException; + +/** + * Creates CSRF token storages based on the requests session. + * + * @author Oliver Hoff + */ +class SessionTokenStorageFactory implements TokenStorageFactoryInterface +{ + /** + * @var string + */ + private $namespace; + + /** + * @var string + */ + private $secureNamespace; + + /** + * @param string $namespace The namespace under which tokens are stored in the session + * @param string $secureNamespace The namespace under which tokens are stored in the session for secure connections + */ + public function __construct($namespace = null, $secureNamespace = null) + { + $this->namespace = $namespace === null ? SessionTokenStorage::SESSION_NAMESPACE : (string) $namespace; + $this->secureNamespace = $secureNamespace === null ? $this->namespace : (string) $secureNamespace; + } + + /** + * {@inheritdoc} + */ + public function createTokenStorage(Request $request) + { + $session = $request->getSession(); + if (!$session) { + throw new RuntimeException('Request has no session'); + } + + $namespace = $request->isSecure() ? $this->secureNamespace : $this->namespace; + + return new SessionTokenStorage($session, $namespace); + } +} diff --git a/src/Symfony/Component/Security/Csrf/TokenStorage/TokenStorageFactoryInterface.php b/src/Symfony/Component/Security/Csrf/TokenStorage/TokenStorageFactoryInterface.php new file mode 100644 index 0000000000000..ce3cd639048dc --- /dev/null +++ b/src/Symfony/Component/Security/Csrf/TokenStorage/TokenStorageFactoryInterface.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Csrf\TokenStorage; + +use Symfony\Component\HttpFoundation\Request; + +/** + * Creates CSRF token storages. + * + * @author Oliver Hoff + */ +interface TokenStorageFactoryInterface +{ + /** + * Creates a new token storage for the given request. + * + * @param Request $request + * + * @return TokenStorageInterface + */ + public function createTokenStorage(Request $request); +} diff --git a/src/Symfony/Component/Security/Csrf/composer.json b/src/Symfony/Component/Security/Csrf/composer.json index 4047fd5435a87..ef80422361989 100644 --- a/src/Symfony/Component/Security/Csrf/composer.json +++ b/src/Symfony/Component/Security/Csrf/composer.json @@ -25,7 +25,7 @@ "symfony/http-foundation": "~2.8|~3.0" }, "suggest": { - "symfony/http-foundation": "For using the class SessionTokenStorage." + "symfony/http-foundation": "For using SessionTokenStorage, CookieTokenStorage or RequestStackTokenStorage." }, "autoload": { "psr-4": { "Symfony\\Component\\Security\\Csrf\\": "" },