<?php

/**
 * This file is part of CodeIgniter 4 framework.
 *
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

namespace CodeIgniter\Security;

use CodeIgniter\Cookie\Cookie;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\Request;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\I18n\Time;
use CodeIgniter\Security\Exceptions\SecurityException;
use CodeIgniter\Session\Session;
use Config\App;
use Config\Cookie as CookieConfig;
use Config\Security as SecurityConfig;
use Config\Services;
use ErrorException;
use InvalidArgumentException;
use LogicException;

/**
 * Class Security
 *
 * Provides methods that help protect your site against
 * Cross-Site Request Forgery attacks.
 */
class Security implements SecurityInterface
{
    public const CSRF_PROTECTION_COOKIE  = 'cookie';
    public const CSRF_PROTECTION_SESSION = 'session';
    protected const CSRF_HASH_BYTES      = 16;

    /**
     * CSRF Protection Method
     *
     * Protection Method for Cross Site Request Forgery protection.
     *
     * @var string 'cookie' or 'session'
     */
    protected $csrfProtection = self::CSRF_PROTECTION_COOKIE;

    /**
     * CSRF Token Randomization
     *
     * @var bool
     */
    protected $tokenRandomize = false;

    /**
     * CSRF Hash (without randomization)
     *
     * Random hash for Cross Site Request Forgery protection.
     *
     * @var string|null
     */
    protected $hash;

    /**
     * CSRF Token Name
     *
     * Token name for Cross Site Request Forgery protection.
     *
     * @var string
     */
    protected $tokenName = 'csrf_token_name';

    /**
     * CSRF Header Name
     *
     * Header name for Cross Site Request Forgery protection.
     *
     * @var string
     */
    protected $headerName = 'X-CSRF-TOKEN';

    /**
     * The CSRF Cookie instance.
     *
     * @var Cookie
     */
    protected $cookie;

    /**
     * CSRF Cookie Name (with Prefix)
     *
     * Cookie name for Cross Site Request Forgery protection.
     *
     * @var string
     */
    protected $cookieName = 'csrf_cookie_name';

    /**
     * CSRF Expires
     *
     * Expiration time for Cross Site Request Forgery protection cookie.
     *
     * Defaults to two hours (in seconds).
     *
     * @var int
     */
    protected $expires = 7200;

    /**
     * CSRF Regenerate
     *
     * Regenerate CSRF Token on every request.
     *
     * @var bool
     */
    protected $regenerate = true;

    /**
     * CSRF Redirect
     *
     * Redirect to previous page with error on failure.
     *
     * @var bool
     */
    protected $redirect = false;

    /**
     * CSRF SameSite
     *
     * Setting for CSRF SameSite cookie token.
     *
     * Allowed values are: None - Lax - Strict - ''.
     *
     * Defaults to `Lax` as recommended in this link:
     *
     * @see https://portswigger.net/web-security/csrf/samesite-cookies
     *
     * @var string
     *
     * @deprecated `Config\Cookie` $samesite property is used.
     */
    protected $samesite = Cookie::SAMESITE_LAX;

    private IncomingRequest $request;

    /**
     * CSRF Cookie Name without Prefix
     */
    private ?string $rawCookieName = null;

    /**
     * Session instance.
     */
    private ?Session $session = null;

    /**
     * CSRF Hash in Request Cookie
     *
     * The cookie value is always CSRF hash (without randomization) even if
     * $tokenRandomize is true.
     */
    private ?string $hashInCookie = null;

    /**
     * Constructor.
     *
     * Stores our configuration and fires off the init() method to setup
     * initial state.
     */
    public function __construct(App $config)
    {
        /** @var SecurityConfig|null $security */
        $security = config('Security');

        // Store CSRF-related configurations
        if ($security instanceof SecurityConfig) {
            $this->csrfProtection = $security->csrfProtection ?? $this->csrfProtection;
            $this->tokenName      = $security->tokenName ?? $this->tokenName;
            $this->headerName     = $security->headerName ?? $this->headerName;
            $this->regenerate     = $security->regenerate ?? $this->regenerate;
            $this->redirect       = $security->redirect ?? $this->redirect;
            $this->rawCookieName  = $security->cookieName ?? $this->rawCookieName;
            $this->expires        = $security->expires ?? $this->expires;
            $this->tokenRandomize = $security->tokenRandomize ?? $this->tokenRandomize;
        } else {
            // `Config/Security.php` is absence
            $this->tokenName     = $config->CSRFTokenName ?? $this->tokenName;
            $this->headerName    = $config->CSRFHeaderName ?? $this->headerName;
            $this->regenerate    = $config->CSRFRegenerate ?? $this->regenerate;
            $this->rawCookieName = $config->CSRFCookieName ?? $this->rawCookieName;
            $this->expires       = $config->CSRFExpire ?? $this->expires;
            $this->redirect      = $config->CSRFRedirect ?? $this->redirect;
        }

        if ($this->isCSRFCookie()) {
            $this->configureCookie($config);
        } else {
            // Session based CSRF protection
            $this->configureSession();
        }

        $this->request      = Services::request();
        $this->hashInCookie = $this->request->getCookie($this->cookieName);

        $this->restoreHash();
        if ($this->hash === null) {
            $this->generateHash();
        }
    }

    private function isCSRFCookie(): bool
    {
        return $this->csrfProtection === self::CSRF_PROTECTION_COOKIE;
    }

    private function configureSession(): void
    {
        $this->session = Services::session();
    }

    private function configureCookie(App $config): void
    {
        /** @var CookieConfig|null $cookie */
        $cookie = config('Cookie');

        if ($cookie instanceof CookieConfig) {
            $cookiePrefix     = $cookie->prefix;
            $this->cookieName = $cookiePrefix . $this->rawCookieName;
            Cookie::setDefaults($cookie);
        } else {
            // `Config/Cookie.php` is absence
            $cookiePrefix     = $config->cookiePrefix;
            $this->cookieName = $cookiePrefix . $this->rawCookieName;
        }
    }

    /**
     * CSRF Verify
     *
     * @return $this|false
     *
     * @throws SecurityException
     *
     * @deprecated Use `CodeIgniter\Security\Security::verify()` instead of using this method.
     *
     * @codeCoverageIgnore
     */
    public function CSRFVerify(RequestInterface $request)
    {
        return $this->verify($request);
    }

    /**
     * Returns the CSRF Token.
     *
     * @deprecated Use `CodeIgniter\Security\Security::getHash()` instead of using this method.
     *
     * @codeCoverageIgnore
     */
    public function getCSRFHash(): ?string
    {
        return $this->getHash();
    }

    /**
     * Returns the CSRF Token Name.
     *
     * @deprecated Use `CodeIgniter\Security\Security::getTokenName()` instead of using this method.
     *
     * @codeCoverageIgnore
     */
    public function getCSRFTokenName(): string
    {
        return $this->getTokenName();
    }

    /**
     * CSRF Verify
     *
     * @return $this
     *
     * @throws SecurityException
     */
    public function verify(RequestInterface $request)
    {
        // Protects POST, PUT, DELETE, PATCH
        $method           = strtoupper($request->getMethod());
        $methodsToProtect = ['POST', 'PUT', 'DELETE', 'PATCH'];
        if (! in_array($method, $methodsToProtect, true)) {
            return $this;
        }

        $postedToken = $this->getPostedToken($request);

        try {
            $token = ($postedToken !== null && $this->tokenRandomize)
                ? $this->derandomize($postedToken) : $postedToken;
        } catch (InvalidArgumentException $e) {
            $token = null;
        }

        // Do the tokens match?
        if (! isset($token, $this->hash) || ! hash_equals($this->hash, $token)) {
            throw SecurityException::forDisallowedAction();
        }

        $this->removeTokenInRequest($request);

        if ($this->regenerate) {
            $this->generateHash();
        }

        log_message('info', 'CSRF token verified.');

        return $this;
    }

    /**
     * Remove token in POST or JSON request data
     */
    private function removeTokenInRequest(RequestInterface $request): void
    {
        assert($request instanceof Request);

        $json = json_decode($request->getBody() ?? '');

        if (isset($_POST[$this->tokenName])) {
            // We kill this since we're done and we don't want to pollute the POST array.
            unset($_POST[$this->tokenName]);
            $request->setGlobal('post', $_POST);
        } elseif (isset($json->{$this->tokenName})) {
            // We kill this since we're done and we don't want to pollute the JSON data.
            unset($json->{$this->tokenName});
            $request->setBody(json_encode($json));
        }
    }

    private function getPostedToken(RequestInterface $request): ?string
    {
        assert($request instanceof IncomingRequest);

        // Does the token exist in POST, HEADER or optionally php:://input - json data.

        if ($tokenValue = $request->getPost($this->tokenName)) {
            return $tokenValue;
        }

        if ($request->hasHeader($this->headerName) && ! empty($request->header($this->headerName)->getValue())) {
            return $request->header($this->headerName)->getValue();
        }

        $body = (string) $request->getBody();
        $json = json_decode($body);

        if ($body !== '' && ! empty($json) && json_last_error() === JSON_ERROR_NONE) {
            return $json->{$this->tokenName} ?? null;
        }

        return null;
    }

    /**
     * Returns the CSRF Token.
     */
    public function getHash(): ?string
    {
        return $this->tokenRandomize ? $this->randomize($this->hash) : $this->hash;
    }

    /**
     * Randomize hash to avoid BREACH attacks.
     *
     * @params string $hash CSRF hash
     *
     * @return string CSRF token
     */
    protected function randomize(string $hash): string
    {
        $keyBinary  = random_bytes(static::CSRF_HASH_BYTES);
        $hashBinary = hex2bin($hash);

        if ($hashBinary === false) {
            throw new LogicException('$hash is invalid: ' . $hash);
        }

        return bin2hex(($hashBinary ^ $keyBinary) . $keyBinary);
    }

    /**
     * Derandomize the token.
     *
     * @params string $token CSRF token
     *
     * @return string CSRF hash
     *
     * @throws InvalidArgumentException "hex2bin(): Hexadecimal input string must have an even length"
     */
    protected function derandomize(string $token): string
    {
        $key   = substr($token, -static::CSRF_HASH_BYTES * 2);
        $value = substr($token, 0, static::CSRF_HASH_BYTES * 2);

        try {
            return bin2hex(hex2bin($value) ^ hex2bin($key));
        } catch (ErrorException $e) {
            // "hex2bin(): Hexadecimal input string must have an even length"
            throw new InvalidArgumentException($e->getMessage());
        }
    }

    /**
     * Returns the CSRF Token Name.
     */
    public function getTokenName(): string
    {
        return $this->tokenName;
    }

    /**
     * Returns the CSRF Header Name.
     */
    public function getHeaderName(): string
    {
        return $this->headerName;
    }

    /**
     * Returns the CSRF Cookie Name.
     */
    public function getCookieName(): string
    {
        return $this->cookieName;
    }

    /**
     * Check if CSRF cookie is expired.
     *
     * @deprecated
     *
     * @codeCoverageIgnore
     */
    public function isExpired(): bool
    {
        return $this->cookie->isExpired();
    }

    /**
     * Check if request should be redirect on failure.
     */
    public function shouldRedirect(): bool
    {
        return $this->redirect;
    }

    /**
     * Sanitize Filename
     *
     * Tries to sanitize filenames in order to prevent directory traversal attempts
     * and other security threats, which is particularly useful for files that
     * were supplied via user input.
     *
     * If it is acceptable for the user input to include relative paths,
     * e.g. file/in/some/approved/folder.txt, you can set the second optional
     * parameter, $relative_path to TRUE.
     *
     * @param string $str          Input file name
     * @param bool   $relativePath Whether to preserve paths
     */
    public function sanitizeFilename(string $str, bool $relativePath = false): string
    {
        // List of sanitize filename strings
        $bad = [
            '../',
            '<!--',
            '-->',
            '<',
            '>',
            "'",
            '"',
            '&',
            '$',
            '#',
            '{',
            '}',
            '[',
            ']',
            '=',
            ';',
            '?',
            '%20',
            '%22',
            '%3c',
            '%253c',
            '%3e',
            '%0e',
            '%28',
            '%29',
            '%2528',
            '%26',
            '%24',
            '%3f',
            '%3b',
            '%3d',
        ];

        if (! $relativePath) {
            $bad[] = './';
            $bad[] = '/';
        }

        $str = remove_invisible_characters($str, false);

        do {
            $old = $str;
            $str = str_replace($bad, '', $str);
        } while ($old !== $str);

        return stripslashes($str);
    }

    /**
     * Restore hash from Session or Cookie
     */
    private function restoreHash(): void
    {
        if ($this->isCSRFCookie()) {
            if ($this->isHashInCookie()) {
                $this->hash = $this->hashInCookie;
            }
        } elseif ($this->session->has($this->tokenName)) {
            // Session based CSRF protection
            $this->hash = $this->session->get($this->tokenName);
        }
    }

    /**
     * Generates (Regenerates) the CSRF Hash.
     */
    public function generateHash(): string
    {
        $this->hash = bin2hex(random_bytes(static::CSRF_HASH_BYTES));

        if ($this->isCSRFCookie()) {
            $this->saveHashInCookie();
        } else {
            // Session based CSRF protection
            $this->saveHashInSession();
        }

        return $this->hash;
    }

    private function isHashInCookie(): bool
    {
        if ($this->hashInCookie === null) {
            return false;
        }

        $length  = static::CSRF_HASH_BYTES * 2;
        $pattern = '#^[0-9a-f]{' . $length . '}$#iS';

        return preg_match($pattern, $this->hashInCookie) === 1;
    }

    private function saveHashInCookie(): void
    {
        $this->cookie = new Cookie(
            $this->rawCookieName,
            $this->hash,
            [
                'expires' => $this->expires === 0 ? 0 : Time::now()->getTimestamp() + $this->expires,
            ]
        );

        $response = Services::response();
        $response->setCookie($this->cookie);
    }

    /**
     * CSRF Send Cookie
     *
     * @return false|Security
     *
     * @deprecated Set cookies to Response object instead.
     */
    protected function sendCookie(RequestInterface $request)
    {
        assert($request instanceof IncomingRequest);

        if ($this->cookie->isSecure() && ! $request->isSecure()) {
            return false;
        }

        $this->doSendCookie();
        log_message('info', 'CSRF cookie sent.');

        return $this;
    }

    /**
     * Actual dispatching of cookies.
     * Extracted for this to be unit tested.
     *
     * @codeCoverageIgnore
     *
     * @deprecated Set cookies to Response object instead.
     */
    protected function doSendCookie(): void
    {
        cookies([$this->cookie], false)->dispatch();
    }

    private function saveHashInSession(): void
    {
        $this->session->set($this->tokenName, $this->hash);
    }
}
