<?php
declare(strict_types = 1);

/**
 * Méthodes pour sécuriser l'application.
 *
 * @license https://www.gnu.org/licenses/gpl-3.0.html
 * @link https://www.igalerie.org/
 */
class Security
{
	/**
	 * Jeton anti-CSRF.
	 *
	 * @var string
	 */
	private static $_antiCSRFToken = '';



	/**
	 * Gestion du jeton anti-CSRF.
	 *
	 * @return bool
	 */
	public static function antiCSRFToken(): bool
	{
		// Si aucune donnée en POST, inutile de vérifier le jeton.
		if (!$_POST)
		{
			$valid = TRUE;
		}

		// Vérification du jeton.
		else if (Utility::checkPost('anticsrf', '`^[a-z\d]{40}$`i')
		&& $_POST['anticsrf'] === Auth::$session->read('anticsrf'))
		{
			$valid = TRUE;
		}

		// Si le jeton n'est pas valide, on efface toutes les données en POST.
		else
		{
			$_FILES = [];
			$_POST = [];
			$valid = FALSE;
			if (CONF_DEV_MODE)
			{
				trigger_error('Invalid antiCSRF token.', E_USER_WARNING);
			}
		}

		// Génère un nouveau jeton si le précédent a expiré.
		if (((int) Auth::$session->read('anticsrf_expire') - time()) < 1)
		{
			self::$_antiCSRFToken = self::token('sha1');
			Auth::$session->add('anticsrf', self::$_antiCSRFToken);
		}
		else
		{
			self::$_antiCSRFToken = Auth::$session->read('anticsrf');
		}

		// Enregistre une nouvelle date d'expiration du jeton dans le cookie.
		Auth::$session->add('anticsrf_expire',
			(string) (time() + CONF_ANTICSRF_TOKEN_EXPIRE));

		return $valid;
	}

	/**
	 * Détermine si une attaque par force brute a été détectée,
	 * en limitant le nombre d'actions $action par jour depuis une même IP.
	 *
	 * Le nombre maximum de tentatives par jour correspond au
	 * paramètre de configuration CONF_BRUTE_FORCE_MAX_ATTEMPT
	 * et doit être compris entre 3 et 20.
	 *
	 * @param string $action
	 *
	 * @return bool
	 */
	public static function bruteForce(string $action): bool
	{
		$sql = 'SELECT COUNT(*)
				  FROM {users_logs}
				 WHERE log_ip = ?
				   AND log_action LIKE ?
				   AND (TO_SECONDS(NOW()) - TO_SECONDS(log_date)) < 86400';
		if (!DB::execute($sql, [$_SERVER['REMOTE_ADDR'], $action]))
		{
			return FALSE;
		}
		$count = (int) DB::fetchVal();

		$max_attempt = (int) CONF_BRUTE_FORCE_MAX_ATTEMPT;
		if ($max_attempt < 3)
		{
			$max_attempt = 3;
		}
		if ($max_attempt > 20)
		{
			$max_attempt = 20;
		}

		return $count >= $max_attempt;
	}

	/**
	 * Vérifie que l'adresse IP d'un administrateur est sur liste blanche.
	 *
	 * @return bool
	 */
	public static function checkAuthAdminIP(): bool
	{
		if (defined('CONF_AUTH_ADMIN_IP') && !Utility::isEmpty(CONF_AUTH_ADMIN_IP)
		&& !in_array($_SERVER['REMOTE_ADDR'], explode(' ', CONF_AUTH_ADMIN_IP)))
		{
			return FALSE;
		}

		return TRUE;
	}

	/**
	 * Retourne une clé de sécurité pour protéger l'accès aux fichiers.
	 *
	 * @param array $params
	 *   Paramètres identifiants de façon unique le fichier.
	 * @param string $date
	 *   Date, pour limiter la durée de validité de la clé.
	 *
	 * @return string
	 */
	public static function fileKeyHash(array $params, string $date = ''): string
	{
		if (!CONF_FILE_KEY)
		{
			return md5('This file is in free access.');
		}

		return md5(implode('|', array_merge($params,
		[
			Auth::getCookieSessionToken(),
			CONF_ACCESS_KEY,
			$date ? $date : date('Y-m-d')
		])));
	}

	/**
	 * Vérifie que les paramètres $params correspondent à la clé $key.
	 *
	 * @param array $params
	 *   Paramètres identifiants de façon unique le fichier.
	 * @param string $key
	 *   Clé à vérifier.
	 *
	 * @return bool
	 */
	public static function fileKeyVerify(array $params, string $key): bool
	{
		if (!CONF_FILE_KEY)
		{
			return TRUE;
		}

		return self::fileKeyHash($params) == $key
			|| self::fileKeyHash($params, date('Y-m-d', time() - 7200)) == $key;
	}

	/**
	 * Filtre les paramètres GET.
	 *
	 * @param array $gets
	 *
	 * @return void
	 */
	public static function filterGET(array $gets): void
	{
		$_REQUEST = [];
		foreach ($_GET as $name => &$value)
		{
			if (!is_string($value) || !in_array($name, $gets))
			{
				unset($_GET[$name]);
				continue;
			}
			$value = substr($value, 0, 500);
			$value = str_replace(['<', '>', '&', '%', '"', "'", "\\"], '?', $value);
			$value = preg_replace("`[\n\r\s\t]+`", '_', $value);
			$value = Utility::deleteInvisibleChars($value);
			if ($name == 'q')
			{
				$value = preg_replace_callback('`(\d+\-)([^/]+)`', function(array $m): string
				{
					return $m[1] . App::getURLName($m[2]);
				},
				$value);
			}
		}
	}

	/**
	 * Retourne le jeton anti-CSRF.
	 *
	 * @return string
	 */
	public static function getAntiCSRFToken(): string
	{
		return self::$_antiCSRFToken;
	}

	/**
	 * Hache une chaîne pour l'en-tête "Content-Security-Policy".
	 *
	 * @param string $str
	 *   Chaîne à hacher.
	 * @param string $algo
	 *   Nom de l'algorithme de hachage à utiliser parmi
	 *   les trois suivants : sha256, sha384 ou sha512.
	 *
	 * @return string
	 */
	public static function getCSPHash(string $str, string $algo = 'sha256'): string
	{
		if (!in_array($algo, ['sha256', 'sha384', 'sha512']))
		{
			$algo = 'sha256';
		}
		return $algo . '-' . base64_encode(hash($algo, $str, TRUE));
	}

	/**
	 * Hache un fichier pour permettre le contrôle d'intégrité
	 * d'une sous-ressource (SubResource Integrity - SRI).
	 *
	 * @param string $file
	 *   Fichier à hacher.
	 * @param string $algo
	 *   Nom de l'algorithme de hachage à utiliser parmi
	 *   les trois suivants : sha256, sha384 ou sha512.
	 *
	 * @return string
	 */
	public static function getSRIHash(string $file, string $algo = 'sha256'): string
	{
		if (!in_array($algo, ['sha256', 'sha384', 'sha512']))
		{
			$algo = 'sha256';
		}
		return $algo . '-' . base64_encode(hash_file($algo, $file, TRUE));
	}

	/**
	 * Retourne un "nonce" pour Content-Security-Policy.
	 *
	 * @return string
	 */
	public static function getCSPNonce(): string
	{
		return base64_encode(self::key(24));
	}

	/**
	 * Gestion des en-têtes HTTP.
	 *
	 * @return void
	 */
	public static function headers(): void
	{
		header('Content-Security-Policy: ' . self::_csp());
		header('Cross-Origin-Embedder-Policy: credentialless');
		header('Cross-Origin-Opener-Policy: same-origin');
		header('Cross-Origin-Resource-Policy: same-origin');
		header('Permissions-Policy: camera=(), display-capture=(), '
			. 'fullscreen=(self), geolocation=(), microphone=(), '
			. 'publickey-credentials-get=(), web-share=()');
		header('Referrer-Policy: strict-origin-when-cross-origin');
		if (GALLERY_HTTPS)
		{
			header('Strict-Transport-Security: max-age=63072000');
		}
		header('X-Content-Type-Options: nosniff');
		header_remove('X-Powered-By');
	}

	/**
	 * Génère une chaîne de caractères aléatoire avec
	 * des caractères alphanumériques uniquement.
	 *
	 * @param int $length
	 *
	 * @return string
	 */
	public static function key(int $length = 12): string
	{
		$chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
		for ($i = 0, $key = ''; $i < $length; $i++)
		{
			$key .= $chars[random_int(0, 61)];
		}
		return $key;
	}

	/**
	 * Hachage d'un mot de passe.
	 *
	 * @param string $password
	 *
	 * @return string
	 */
	public static function passwordHash(string $password): string
	{
		return password_hash(self::_passwordPepper($password), PASSWORD_DEFAULT);
	}

	/**
	 * Vérification d'un mot de passe.
	 *
	 * @param string $password
	 * @param string $hash
	 * @param object $callback
	 *
	 * @return bool
	 */
	public static function passwordVerify(string $password,
	string $hash, ?object $callback = NULL): bool
	{
		if (password_verify(self::_passwordPepper($password), $hash))
		{
			if (password_needs_rehash($hash, PASSWORD_DEFAULT) && $callback)
			{
				$callback(self::passwordHash($password));
			}
			return TRUE;
		}
		if (password_verify($password, $hash))
		{
			if ($callback)
			{
				$callback(self::passwordHash($password));
			}
			return TRUE;
		}
		return FALSE;
	}

	/**
	 * Simule la vérification d'un mot de passe.
	 *
	 * @return void
	 */
	public static function passwordVerifySimulate(): void
	{
		if (DB::execute('SELECT user_password FROM {users} WHERE user_id = 1'))
		{
			$hash = (string) DB::fetchVal();
			password_verify(self::_passwordPepper('password'), $hash);
			password_verify('password', $hash);
		}
	}

	/**
	 * Génère un jeton de manière aléatoire.
	 *
	 * @param string $algo
	 *   Algorithme de hachage à utiliser.
	 *
	 * @return string
	 */
	public static function token(string $algo = 'sha1'): string
	{
		for ($i = 0, $key = ''; $i < 128; $i++)
		{
			$key .= chr(random_int(33, 126));
		}
		return hash_hmac($algo, uniqid($key, TRUE), (string) (time() * random_int(8, 888)));
	}



	/**
	 * Fabrication de l'en-tête "Content-Security-Policy".
	 *
	 * @return string
	 */
	private static function _csp(): string
	{
		$frame_ancestors = defined('CONF_HTTP_CSP_FRAME_ANCESTORS')
			? (string) CONF_HTTP_CSP_FRAME_ANCESTORS
			: '';
		$frame_ancestors = self::_cspHosts($frame_ancestors);

		$hosts = defined('CONF_HTTP_CSP_HOSTS')
			? (string) CONF_HTTP_CSP_HOSTS
			: '';
		$hosts = self::_cspHosts($hosts,
		[
			'https://server.arcgisonline.com',
			'https://unpkg.com',
			'https://*.openstreetmap.fr',
			'https://*.openstreetmap.org'
		]);

		$nonce = "'nonce-" . CSP_NONCE . "'";

		$csp =
		[
			"base-uri 'none'",
			"connect-src 'self' $hosts",
			"default-src 'none'",
			"font-src 'self'",
			"form-action 'self'",
			"frame-ancestors 'self' $frame_ancestors",
			"img-src 'self' blob: data: $hosts",
			"media-src 'self'",
			"script-src 'self' $nonce $hosts",
			"style-src 'self' $nonce $hosts"
		];

		if (CONF_DEBUG_MODE || CONF_DEV_MODE)
		{
			$report = GALLERY_HOST . CONF_GALLERY_PATH . '/csp_report.php';
			$csp[] = "report-to $report";
			$csp[] = "report-uri $report";
		}

		return implode('; ', $csp);
	}

	/**
	 * Retourne une liste formatée de sources autorisées pour la CSP.
	 *
	 * @param string $conf
	 * @param array $hosts
	 *
	 * @return string
	 */
	private static function _cspHosts(string $conf, array $hosts = []): string
	{
		foreach (explode(' ', $conf) as &$value)
		{
			if ($value && strlen($value) <= 256)
			{
				$hosts[] = Utility::deleteInvisibleChars($value);
			}
		}
		return implode(' ', $hosts);
	}

	/**
	 * Ajoute du poivre à un mot de passe.
	 *
	 * @param string $password
	 *
	 * @return string
	 */
	private static function _passwordPepper(string $password): string
	{
		return hash_hmac('sha256', $password, CONF_PASSWORD_PEPPER);
	}
}
?>