<?php

/**
 * Slim Framework (https://slimframework.com)
 *
 * @license https://github.com/slimphp/Slim-Csrf/blob/master/LICENSE.md (MIT License)
 */

declare(strict_types=1);

namespace Slim\Csrf;

use ArrayAccess;
use Countable;
use Exception;
use Iterator;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use RuntimeException;

use function array_key_exists;
use function array_shift;
use function base64_decode;
use function base64_encode;
use function bin2hex;
use function call_user_func;
use function count;
use function end;
use function hash_equals;
use function in_array;
use function is_array;
use function is_callable;
use function is_int;
use function key;
use function random_bytes;
use function reset;
use function rtrim;
use function session_status;
use function strlen;
use function substr;
use function uniqid;

use const PHP_SESSION_ACTIVE;

class Guard implements MiddlewareInterface
{
    protected ResponseFactoryInterface $responseFactory;

    /**
     * Prefix for CSRF parameters (omit trailing "_" underscore)
     */
    protected string $prefix;

    /**
     * CSRF storage
     *
     * Should be either an array or an object. If an object is used, then it must
     * implement ArrayAccess and should implement Countable and Iterator
     * if storage limit enforcement is required.
     *
     * @var array|ArrayAccess
     */
    protected $storage;

    /**
     * Number of elements to store in the storage array
     */
    protected int $storageLimit;

    /**
     * CSRF Strength
     */
    protected int $strength;

    /**
     * Callable to be executed if the CSRF validation fails
     * It must return a ResponseInterface
     *
     * @var callable|null
     */
    protected $failureHandler;

    /**
     * Determines whether or not we should persist the token throughout the duration of the user's session.
     *
     * For security, Slim-Csrf will *always* reset the token if there is a validation error.
     * @var bool True to use the same token throughout the session (unless there is a validation error),
     * false to get a new token with each request.
     */
    protected bool $persistentTokenMode;

    /**
     * Stores the latest key-pair generated by the class
     */
    protected ?array $keyPair = null;

    /**
     * @param  ResponseFactoryInterface $responseFactory
     * @param  string                   $prefix
     * @param  array|ArrayAccess|null   $storage
     * @param  callable|null            $failureHandler
     * @param  int                      $storageLimit
     * @param  int                      $strength
     * @param  bool                     $persistentTokenMode
     *
     * @throws RuntimeException if the session cannot be found
     */
    public function __construct(
        ResponseFactoryInterface $responseFactory,
        string $prefix = 'csrf',
        &$storage = null,
        ?callable $failureHandler = null,
        int $storageLimit = 200,
        int $strength = 16,
        bool $persistentTokenMode = false
    ) {
        if ($strength < 16) {
            throw new RuntimeException('CSRF middleware instantiation failed. Minimum strength is 16.');
        }

        $this->responseFactory = $responseFactory;
        $this->prefix = rtrim($prefix, '_');
        $this->strength = $strength;

        $this->setStorage($storage);
        $this->setFailureHandler($failureHandler);
        $this->setStorageLimit($storageLimit);
        $this->setPersistentTokenMode($persistentTokenMode);
    }

    /**
     * @param  array|ArrayAccess|null $storage
     *
     * @return self
     *
     * @throws RuntimeException
     */
    public function setStorage(&$storage = null): self
    {
        if (
            is_array($storage)
            || ($storage instanceof ArrayAccess && $storage instanceof Countable && $storage instanceof Iterator)
        ) {
            $this->storage = &$storage;
            return $this;
        }

        if (session_status() !== PHP_SESSION_ACTIVE) {
            throw new RuntimeException(
                'Invalid CSRF storage. ' .
                'Use session_start() before instantiating the Guard middleware or provide array storage.'
            );
        }

        if (!array_key_exists($this->prefix, $_SESSION) || !is_array($_SESSION[$this->prefix])) {
            $_SESSION[$this->prefix] = [];
        }

        $this->storage = &$_SESSION[$this->prefix];
        return $this;
    }

    /**
     * @param  callable|null $failureHandler Value to set
     *
     * @return self
     */
    public function setFailureHandler(?callable $failureHandler): self
    {
        $this->failureHandler = $failureHandler;
        return $this;
    }

    /**
     * @param  bool $persistentTokenMode True to use the same token throughout the session
     * (unless there is a validation error), false to get a new token with each request.
     *
     * @return self
     */
    public function setPersistentTokenMode(bool $persistentTokenMode): self
    {
        $this->persistentTokenMode = $persistentTokenMode;
        return $this;
    }

    /**
     * @param  int $storageLimit Value to set
     *
     * @return self
     */
    public function setStorageLimit(int $storageLimit): self
    {
        $this->storageLimit = $storageLimit;
        return $this;
    }

    /**
     * @return string
     *
     * @throws Exception
     */
    protected function createToken(): string
    {
        return bin2hex(random_bytes($this->strength));
    }

    /**
     * @return array
     *
     * @throws Exception
     */
    public function generateToken(): array
    {
        // Generate new CSRF token
        $name = uniqid($this->prefix);
        $value = $this->createToken();
        $this->saveTokenToStorage($name, $value);


        $this->keyPair = [
            $this->getTokenNameKey() => $name,
            $this->getTokenValueKey() => $this->maskToken($value)
        ];
        $this->enforceStorageLimit();
        return $this->keyPair;
    }

    /**
     * Validate CSRF token from current request against token value
     * stored in $_SESSION or user provided storage
     *
     * @param  string $name  CSRF name
     * @param  string $value CSRF token value
     *
     * @return bool
     */
    public function validateToken(string $name, string $value): bool
    {
        if (!isset($this->storage[$name])) {
            return false;
        }

        $token = $this->storage[$name];

        return hash_equals($token, $this->unmaskToken($value));
    }

    /**
     * @return string|null
     */
    public function getTokenName(): ?string
    {
        return $this->keyPair[$this->getTokenNameKey()] ?? null;
    }

    /**
     * @return string|null
     */
    public function getTokenValue(): ?string
    {
        return $this->keyPair[$this->getTokenValueKey()] ?? null;
    }

    /**
     * @return string
     */
    public function getTokenNameKey(): string
    {
        return $this->prefix . '_name';
    }

    /**
     * @return string
     */
    public function getTokenValueKey(): string
    {
        return $this->prefix . '_value';
    }

    /**
     * @return bool
     */
    public function getPersistentTokenMode(): bool
    {
        return $this->persistentTokenMode;
    }

    /**
     * @return string[]|null
     *
     * @throws Exception
     */
    protected function getLastKeyPair(): ?array
    {
        if (
            (is_array($this->storage) && empty($this->storage))
            || ($this->storage instanceof Countable && count($this->storage) < 1)
        ) {
            return null;
        }

        $name = null;
        $value = null;
        if (is_array($this->storage)) {
            end($this->storage);
            $name = key($this->storage);
            $value = $this->storage[$name];
            reset($this->storage);
        } elseif ($this->storage instanceof Iterator) {
            $this->storage->rewind();
            while ($this->storage->valid()) {
                $name = $this->storage->key();
                $value = $this->storage->current();
                $this->storage->next();
            }
            $this->storage->rewind();
        }

        return $name !== null && $value !== null
            ? [
                $this->getTokenNameKey() => $name,
                $this->getTokenValueKey() => $this->maskToken($value)
            ]
            : null;
    }

    /**
     * Load the most recent key pair in storage.
     *
     * @return bool `true` if there was a key pair to load in storage, false otherwise.
     *
     * @throws Exception
     */
    protected function loadLastKeyPair(): bool
    {
        return (bool) $this->keyPair = $this->getLastKeyPair();
    }

    /**
     * @param  string $name  CSRF token name
     * @param  string $value CSRF token value
     *
     * @return void
     */
    protected function saveTokenToStorage(string $name, string $value): void
    {
        $this->storage[$name] = $value;
    }

    /**
     * Remove token from storage
     *
     * @param  string $name CSRF token name
     *
     * @return void
     */
    public function removeTokenFromStorage(string $name): void
    {
        $this->storage[$name] = '';
        unset($this->storage[$name]);
    }

    /**
     * Remove the oldest tokens from the storage array so that there
     * are never more than storageLimit tokens in the array.
     *
     * This is required as a token is generated every request and so
     * most will never be used.
     *
     * @return void
     */
    protected function enforceStorageLimit(): void
    {
        if (
            $this->storageLimit === 0
            || (
                !is_array($this->storage)
                && !($this->storage instanceof Countable && $this->storage instanceof Iterator)
            )
        ) {
            return;
        }

        if (is_array($this->storage)) {
            while (count($this->storage) > $this->storageLimit) {
                array_shift($this->storage);
            }
            return;
        }

        if ($this->storage instanceof Iterator) {
            while (count($this->storage) > $this->storageLimit) {
                $this->storage->rewind();
                unset($this->storage[$this->storage->key()]);
            }
        }
    }

    /**
     * @param  ServerRequestInterface $request
     *
     * @return ServerRequestInterface
     *
     * @throws Exception
     */
    public function appendNewTokenToRequest(ServerRequestInterface $request): ServerRequestInterface
    {
        $token = $this->generateToken();
        return $this->appendTokenToRequest($request, $token);
    }

    /**
     * @param  ServerRequestInterface $request
     * @param  array $pair
     *
     * @return ServerRequestInterface
     */
    protected function appendTokenToRequest(ServerRequestInterface $request, array $pair): ServerRequestInterface
    {
        $name = $this->getTokenNameKey();
        $value = $this->getTokenValueKey();
        return $request
            ->withAttribute($name, $pair[$name])
            ->withAttribute($value, $pair[$value]);
    }

    /**
     * @param  ServerRequestInterface  $request
     * @param  RequestHandlerInterface $handler
     *
     * @return ResponseInterface
     *
     * @throws Exception
     */
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $body = $request->getParsedBody();
        $name = null;
        $value = null;

        if (is_array($body)) {
            $name = $body[$this->getTokenNameKey()] ?? null;
            $value = $body[$this->getTokenValueKey()] ?? null;
        }

        if ($name === null && $value === null) {
            // DELETE request may not have a request body. Supply token by headers
            $name = $request->getHeader($this->getTokenNameKey())[0] ?? null;
            $value = $request->getHeader($this->getTokenValueKey())[0] ?? null;
        }

        if (in_array($request->getMethod(), ['POST', 'PUT', 'DELETE', 'PATCH'])) {
            $isValid = $this->validateToken((string) $name, (string) $value);
            if ($isValid && !$this->persistentTokenMode) {
                // successfully validated token, so delete it if not in persistentTokenMode
                $this->removeTokenFromStorage($name);
            }

            if ($name === null || $value === null || !$isValid) {
                $request = $this->appendNewTokenToRequest($request);
                return $this->handleFailure($request, $handler);
            }
        } else {
            // Method is GET/OPTIONS/HEAD/etc, so do not accept the token in the body of this request
            if ($name !== null) {
                $this->enforceStorageLimit();
                return $this->handleFailure($request, $handler);
            }
        }

        if (!$this->persistentTokenMode || !$this->loadLastKeyPair()) {
            $request = $this->appendNewTokenToRequest($request);
        } else {
            $pair = $this->loadLastKeyPair() ? $this->keyPair : $this->generateToken();
            $request = $this->appendTokenToRequest($request, $pair);
        }
        $this->enforceStorageLimit();

        return $handler->handle($request);
    }

    /**
     * @param  ServerRequestInterface  $request
     * @param  RequestHandlerInterface $handler
     *
     * @return ResponseInterface
     */
    public function handleFailure(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        if (!is_callable($this->failureHandler)) {
            $response = $this->responseFactory->createResponse();
            $body = $response->getBody();
            $body->write('Failed CSRF check!');
            return $response
                ->withStatus(400)
                ->withHeader('Content-Type', 'text/plain')
                ->withBody($body);
        }

        return call_user_func($this->failureHandler, $request, $handler);
    }

    /**
     * Mask token using random key and doing a XOR between this key and provided token. It will prevent BREACH attack.
     *
     * @param string $token Token to mask
     *
     * @return string Masked token, base64 encoded
     *
     * @throws Exception
     */
    private function maskToken(string $token): string
    {
        // Key length need to be the same as the length of the token
        $key = random_bytes(strlen($token));
        return base64_encode($key . ($key ^ $token));
    }

    /**
     * @param string $maskedToken Masked token, base64 encoded
     *
     * @return string Unmasked token
     *
     * @see maskToken()
     */
    private function unmaskToken(string $maskedToken): string
    {
        $decoded = base64_decode($maskedToken, true);
        if ($decoded === false) {
            return '';
        }
        $tokenLength = strlen($decoded) / 2;
        // If $tokenLength is not an int value, token is invalid (token length and key length need to be the same)
        if (!is_int($tokenLength)) {
            return '';
        }

        $key = substr($decoded, 0, $tokenLength);
        $decodedMaskedToken = substr($decoded, $tokenLength, $tokenLength);

        return $key ^ $decodedMaskedToken;
    }
}
