<?php

/**
 * This file is part of the 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\HTTP\IncomingRequest;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\Security\Exceptions\SecurityException;
use Config\App;

/**
 * Class Security
 *
 * Provides methods that help protect your site against
 * Cross-Site Request Forgery attacks.
 */
class Security implements SecurityInterface
{
	/**
	 * CSRF Hash
	 *
	 * Random hash for Cross Site Request Forgery protection cookie
	 *
	 * @var string|null
	 */
	protected $hash = null;

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

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

	/**
	 * CSRF Cookie Name
	 *
	 * Cookie name for Cross Site Request Forgery protection cookie.
	 *
	 * @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 integer
	 */
	protected $expires = 7200;

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

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

	/**
	 * 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 'Lax'|'None'|'Strict'
	 */
	protected $samesite = 'Lax';

	//--------------------------------------------------------------------

	/**
	 * Constructor.
	 *
	 * Stores our configuration and fires off the init() method to setup
	 * initial state.
	 *
	 * @param App $config
	 *
	 * @throws SecurityException
	 */
	public function __construct($config)
	{
		$security = config('Security');
		// Store CSRF-related configurations
		$this->tokenName  = $security->tokenName ?? $config->CSRFTokenName ?? $this->tokenName;
		$this->headerName = $security->headerName ?? $config->CSRFHeaderName ?? $this->headerName;
		$this->cookieName = $security->cookieName ?? $config->CSRFCookieName ?? $this->cookieName;
		$this->expires    = $security->expires ?? $config->CSRFExpire ?? $this->expires;
		$this->regenerate = $security->regenerate ?? $config->CSRFRegenerate ?? $this->regenerate;
		$this->samesite   = $security->samesite ?? $config->CSRFSameSite ?? $this->samesite;

		if (! in_array(strtolower($this->samesite), ['none', 'lax', 'strict', ''], true))
		{
			throw SecurityException::forInvalidSameSite($this->samesite);
		}

		if (isset($config->cookiePrefix))
		{
			$this->cookieName = $config->cookiePrefix . $this->cookieName;
		}

		$this->generateHash();
	}

	//--------------------------------------------------------------------

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

	//--------------------------------------------------------------------

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

	//--------------------------------------------------------------------

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

	//--------------------------------------------------------------------

	/**
	 * CSRF Verify
	 *
	 * @param RequestInterface $request
	 *
	 * @return $this|false
	 *
	 * @throws SecurityException
	 */
	public function verify(RequestInterface $request)
	{
		// If it's not a POST request we will set the CSRF cookie.
		if (strtoupper($_SERVER['REQUEST_METHOD']) !== 'POST')
		{
			return $this->sendCookie($request);
		}

		// Does the token exist in POST, HEADER or optionally php:://input - json data.
		if ($request->hasHeader($this->headerName) && ! empty($request->getHeader($this->headerName)->getValue()))
		{
			$tokenName = $request->getHeader($this->headerName)->getValue();
		}
		else
		{
			$json = json_decode($request->getBody());

			if (! empty($request->getBody()) && ! empty($json) && json_last_error() === JSON_ERROR_NONE)
			{
				$tokenName = $json->{$this->tokenName} ?? null;
			}
			else
			{
				$tokenName = null;
			}
		}

		$token = $_POST[$this->tokenName] ?? $tokenName;

		// Does the tokens exist in both the POST/POSTed JSON and COOKIE arrays and match?
		if (! isset($token, $_COOKIE[$this->cookieName]) || $token !== $_COOKIE[$this->cookieName])
		{
			throw SecurityException::forDisallowedAction();
		}

		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));
		}

		// Regenerate on every submission?
		if ($this->regenerate)
		{
			// Nothing should last forever.
			$this->hash = null;
			unset($_COOKIE[$this->cookieName]);
		}

		$this->generateHash();
		$this->sendCookie($request);

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

		return $this;
	}

	//--------------------------------------------------------------------

	/**
	 * Returns the CSRF Hash.
	 *
	 * @return string|null
	 */
	public function getHash(): ?string
	{
		return $this->hash;
	}

	//--------------------------------------------------------------------

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

	//--------------------------------------------------------------------

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

	//--------------------------------------------------------------------

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

	//--------------------------------------------------------------------

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

	//--------------------------------------------------------------------

	/**
	 * Check if request should be redirect on failure.
	 *
	 * @return boolean
	 */
	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 boolean $relativePath Whether to preserve paths
	 *
	 * @return string
	 */
	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);
	}

	//--------------------------------------------------------------------

	/**
	 * Generates the CSRF Hash.
	 *
	 * @return string
	 */
	protected function generateHash(): string
	{
		if (is_null($this->hash))
		{
			// If the cookie exists we will use its value.
			// We don't necessarily want to regenerate it with
			// each page load since a page could contain embedded
			// sub-pages causing this feature to fail
			if (isset($_COOKIE[$this->cookieName])
				&& is_string($_COOKIE[$this->cookieName])
				&& preg_match('#^[0-9a-f]{32}$#iS', $_COOKIE[$this->cookieName]) === 1
			)
			{
				return $this->hash = $_COOKIE[$this->cookieName];
			}

			$this->hash = bin2hex(random_bytes(16));
		}

		return $this->hash;
	}

	//--------------------------------------------------------------------

	/**
	 * CSRF Send Cookie
	 *
	 * @param RequestInterface $request
	 *
	 * @return             Security|false
	 * @codeCoverageIgnore
	 */
	protected function sendCookie(RequestInterface $request)
	{
		$config = new App();

		$expires = $this->isExpired() ? $this->expires : time() + $this->expires;
		$path    = $config->cookiePath ?? '/';
		$domain  = $config->cookieDomain ?? '';
		$secure  = $config->cookieSecure ?? false;

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

		if (PHP_VERSION_ID < 70300)
		{
			// In PHP < 7.3.0, there is a "hacky" way to set the samesite parameter
			$samesite = '';

			if (! empty($this->samesite))
			{
				$samesite = '; samesite=' . $this->samesite;
			}

			setcookie($this->cookieName, $this->hash, $expires, $path . $samesite, $domain, $secure, true);
		}
		else
		{
			// PHP 7.3 adds another function signature allowing setting of samesite
			$params = [
				'expires'  => $expires,
				'path'     => $path,
				'domain'   => $domain,
				'secure'   => $secure,
				'httponly' => true, // Enforce HTTP only cookie for security
			];

			if (! empty($this->samesite))
			{
				$params['samesite'] = $this->samesite;
			}

			// @phpstan-ignore-next-line @todo ignore to be removed in 4.1 with rector 0.9
			setcookie($this->cookieName, $this->hash, $params);
		}

		log_message('info', 'CSRF cookie sent.');

		return $this;
	}
}
