silverstripe-framework/src/Security/Confirmation/Storage.php

448 lines
11 KiB
PHP

<?php
namespace SilverStripe\Security\Confirmation;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Cookie;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\Session;
use SilverStripe\Security\SecurityToken;
/**
* Confirmation Storage implemented on top of SilverStripe Session and Cookie
*
* The storage keeps the information about the items requiring
* confirmation and their status (confirmed or not) in Session
*
* User data, such as the original request parameters, may be kept in
* Cookie so that session storage cannot be exhausted easily by a malicious user
*/
class Storage
{
const HASH_ALGO = 'sha512';
/**
* @var \SilverStripe\Control\Session
*/
protected $session;
/**
* Identifier of the storage within the session
*
* @var string
*/
protected $id;
/**
* @param Session $session active session
* @param string $id Unique storage identifier within the session
* @param bool $new Cleanup the storage
*/
public function __construct(Session $session, $id, $new = true)
{
$id = trim((string) $id);
if (!strlen($id)) {
throw new \InvalidArgumentException('Storage ID must not be empty');
}
$this->session = $session;
$this->id = $id;
if ($new) {
$this->cleanup();
}
}
/**
* Remove all the data from the storage
* Cleans up Session and Cookie related to this storage
*/
public function cleanup()
{
Cookie::force_expiry($this->getCookieKey());
$this->session->clear($this->getNamespace());
}
/**
* Gets user input data (usually POST array), checks all the items in the storage
* has been confirmed and marks them as such.
*
* @param array $data User input to look at for items. Usually POST array
*
* @return bool whether all items have been confirmed
*/
public function confirm($data)
{
foreach ($this->getItems() as $item) {
$key = base64_encode($this->getTokenHash($item));
if (!isset($data[$key]) || $data[$key] !== '1') {
return false;
}
$item->confirm();
$this->putItem($item);
}
return true;
}
/**
* Returns the dictionary with the item hashes
*
* The {@see SilverStripe\Security\Confirmation\Storage::confirm} function
* expects exactly same dictionary as its argument for successful confirmation
*
* Keys of the dictionary are salted item token hashes
* All values are the string "1" constantly
*
* @return array
*/
public function getHashedItems()
{
$items = [];
foreach ($this->getItems() as $item) {
$hash = base64_encode($this->getTokenHash($item));
$items[$hash] = '1';
}
return $items;
}
/**
* Returns salted and hashed version of the item token
*
* @param Item $item
*
* @return string
*/
public function getTokenHash(Item $item)
{
$token = $item->getToken();
$salt = $this->getSessionSalt();
$salted = $salt . $token;
return hash(static::HASH_ALGO, $salted, true);
}
/**
* Returns the unique cookie key generated from the session salt
*
* @return string
*/
public function getCookieKey()
{
$salt = $this->getSessionSalt();
return bin2hex(hash(static::HASH_ALGO, $salt . 'cookie key', true));
}
/**
* Returns a unique token to use as a CSRF token
*
* @return string
*/
public function getCsrfToken()
{
$salt = $this->getSessionSalt();
return base64_encode(hash(static::HASH_ALGO, $salt . 'csrf token', true));
}
/**
* Returns the salt generated for the current session
*
* @return string
*/
public function getSessionSalt()
{
$key = $this->getNamespace('salt');
if (!$salt = $this->session->get($key)) {
$salt = $this->generateSalt();
$this->session->set($key, $salt);
}
return $salt;
}
/**
* Returns randomly generated salt
*
* @return string
*/
protected function generateSalt()
{
return random_bytes(64);
}
/**
* Adds a new object to the list of confirmation items
* Replaces the item if there is already one with the same token
*
* @param Item $item Item requiring confirmation
*
* @return $this
*/
public function putItem(Item $item)
{
$key = $this->getNamespace('items');
$items = $this->session->get($key) ?: [];
$token = $this->getTokenHash($item);
$items[$token] = $item;
$this->session->set($key, $items);
return $this;
}
/**
* Returns the list of registered confirmation items
*
* @return Item[]
*/
public function getItems()
{
return $this->session->get($this->getNamespace('items')) ?: [];
}
/**
* Look up an item by its token key
*
* @param string $key Item token key
*
* @return null|Item
*/
public function getItem($key)
{
foreach ($this->getItems() as $item) {
if ($item->getToken() === $key) {
return $item;
}
}
}
/**
* This request should be performed on success
* Usually the original request which triggered the confirmation
*
* @param HTTPRequest $request
*
* @return $this
*/
public function setSuccessRequest(HTTPRequest $request)
{
$url = Controller::join_links(Director::baseURL(), $request->getURL(true));
$this->setSuccessUrl($url);
$httpMethod = $request->httpMethod();
$this->session->set($this->getNamespace('httpMethod'), $httpMethod);
if ($httpMethod === 'POST') {
$checksum = $this->setSuccessPostVars($request->postVars());
$this->session->set($this->getNamespace('postChecksum'), $checksum);
}
}
/**
* Save the post data in the storage (browser Cookies by default)
* Returns the control checksum of the data preserved
*
* Keeps data in Cookies to avoid potential DDoS targeting
* session storage exhaustion
*
* @param array $data
*
* @return string checksum
*/
protected function setSuccessPostVars(array $data)
{
$checksum = hash_init(static::HASH_ALGO);
$cookieData = [];
foreach ($data as $key => $val) {
$key = strval($key);
$val = strval($val);
hash_update($checksum, $key);
hash_update($checksum, $val);
$cookieData[] = [$key, $val];
}
$checksum = hash_final($checksum, true);
$cookieData = json_encode($cookieData, 0, 2);
$cookieKey = $this->getCookieKey();
Cookie::set($cookieKey, $cookieData, 0);
return $checksum;
}
/**
* Returns HTTP method of the success request
*
* @return string
*/
public function getHttpMethod()
{
return $this->session->get($this->getNamespace('httpMethod'));
}
/**
* Returns the list of success request post parameters
*
* Returns null if no parameters was persisted initially or
* if the checksum is incorrect.
*
* WARNING! If HTTP Method is POST and this function returns null,
* you MUST assume the Cookie parameter either has been forged or
* expired.
*
* @return array|null
*/
public function getSuccessPostVars()
{
$controlChecksum = $this->session->get($this->getNamespace('postChecksum'));
if (!$controlChecksum) {
return null;
}
$cookieKey = $this->getCookieKey();
$cookieData = Cookie::get($cookieKey);
if (!$cookieData) {
return null;
}
$cookieData = json_decode($cookieData, true, 3);
if (!is_array($cookieData)) {
return null;
}
$checksum = hash_init(static::HASH_ALGO);
$data = [];
foreach ($cookieData as $pair) {
if (!isset($pair[0]) || !isset($pair[1])) {
return null;
}
$key = $pair[0];
$val = $pair[1];
hash_update($checksum, $key);
hash_update($checksum, $val);
$data[$key] = $val;
}
$checksum = hash_final($checksum, true);
if ($checksum !== $controlChecksum) {
return null;
}
return $data;
}
/**
* The URL the form should redirect to on success
*
* @param string $url Success URL
*
* @return $this
*/
public function setSuccessUrl($url)
{
$this->session->set($this->getNamespace('successUrl'), $url);
return $this;
}
/**
* Returns the URL registered by {@see self::setSuccessUrl} as a success redirect target
*
* @return string
*/
public function getSuccessUrl()
{
return $this->session->get($this->getNamespace('successUrl'));
}
/**
* The URL the form should redirect to on failure
*
* @param string $url Failure URL
*
* @return $this
*/
public function setFailureUrl($url)
{
$this->session->set($this->getNamespace('failureUrl'), $url);
return $this;
}
/**
* Returns the URL registered by {@see self::setFailureUrl} as a success redirect target
*
* @return string
*/
public function getFailureUrl()
{
return $this->session->get($this->getNamespace('failureUrl'));
}
/**
* Check all items to be confirmed in the storage
*
* @param Item[] $items List of items to be checked
*
* @return bool
*/
public function check(array $items)
{
foreach ($items as $itemToConfirm) {
foreach ($this->getItems() as $item) {
if ($item->getToken() !== $itemToConfirm->getToken()) {
continue;
}
if ($item->isConfirmed()) {
continue 2;
}
break;
}
return false;
}
return true;
}
/**
* Returns the namespace of the storage in the session
*
* @param string|null $key Optional key within the storage
*
* @return string
*/
protected function getNamespace($key = null)
{
return sprintf(
'%s.%s%s',
str_replace('\\', '.', __CLASS__),
$this->id,
$key ? '.' . $key : ''
);
}
}