<?php
declare(strict_types = 1);

/**
 * Gestion des connexions utilisateur.
 *
 * @license https://www.gnu.org/licenses/gpl-3.0.html
 * @link https://www.igalerie.org/
 */
class Auth
{
	/**
	 * L'utilisateur est-il connecté ?
	 *
	 * @var bool
	 */
	public static $connected = FALSE;

	/**
	 * Permissions de groupe.
	 *
	 * @var array
	 */
	public static $groupPerms = [];

	/**
	 * Identifiant. Par défaut : 2 (invité).
	 *
	 * @var int
	 */
	public static $id = 2;

	/**
	 * Informations.
	 *
	 * @var array
	 */
	public static $infos = [];

	/**
	 * L'utilisateur connecté est-il un administrateur ?
	 *
	 * @var bool
	 */
	public static $isAdmin = FALSE;

	/**
	 * Langue.
	 *
	 * @var string
	 */
	public static $lang;

	/**
	 * Pseudonyme.
	 *
	 * @var string
	 */
	public static $nickname;

	/**
	 * Cookie des préférences.
	 *
	 * @var Cookie
	 */
	public static $prefs;

	/**
	 * Cookie de session.
	 *
	 * @var Cookie
	 */
	public static $session;

	/**
	 * Fuseau horaire.
	 *
	 * @var string
	 */
	public static $tz;



	/**
	 * Jeton de session.
	 *
	 * @var string
	 */
	private static $_sessionToken = '';



	/**
	 * Authentification par cookie et récupération
	 * des informations du compte utilisateur.
	 *
	 * @return bool
	 *   Retourne TRUE si l'authentification a réussie.
	 */
	public static function cookie(): bool
	{
		// Récupération de l'identifiant de session que possède l'utilisateur.
		$session_token = self::getCookieSessionToken();

		// Récupération des informations de l'utilisateur.
		$sql_user_lastvstdt
			= "CASE WHEN user_lastvstdt IS NULL
					THEN '2000-01-01 00:00:00'
					ELSE user_lastvstdt END";
		$sql = "SELECT g.*,
					   u.*,
					   (TO_SECONDS(NOW()) - TO_SECONDS($sql_user_lastvstdt)) > 900 AS user_update
				  FROM {sessions} AS s,
				       {sessions_users} AS su,
					   {groups} AS g,
					   {users} AS u
				 WHERE u.user_id = su.user_id
				   AND su.session_id = s.session_id
				   AND u.group_id = g.group_id
				   AND user_status = '1'
				   AND session_token = ?
				   AND session_expire > NOW()";
		if (!DB::execute($sql, $session_token) || (!self::$infos = DB::fetchRow())
		|| (!Config::$params['users'] && self::$infos['group_admin'] != 1))
		{
			// Permissions de l'utilisateur "invité".
			if (Config::$params['users']
			&& DB::execute('SELECT group_perms FROM {groups} WHERE group_id = 2'))
			{
				self::$groupPerms = Utility::jsonDecode(DB::fetchVal());
			}
			return FALSE;
		}

		// Décodage des tableaux.
		self::$infos['user_prefs'] = Utility::jsonDecode(self::$infos['user_prefs']);
		if (!is_array(self::$infos['user_prefs']))
		{
			self::$infos['user_prefs'] = [];
		}

		// Permissions de groupe.
		self::$isAdmin = self::$infos['group_admin'] == 1;
		self::$groupPerms = Utility::jsonDecode(self::$infos['group_perms']);
		unset(self::$infos['group_perms']);

		// Utilisateur non authentifié.
		if (self::$infos['user_id'] == 2)
		{
			return FALSE;
		}

		// Un administrateur a toujours tous les droits.
		if (self::$isAdmin)
		{
			self::$groupPerms = Config::USERS_GROUP_PERMS_DEFAULT;
			self::$groupPerms['create_albums'] = 1;
			self::$groupPerms['create_albums_categories'] = 1;
			self::$groupPerms['create_albums_gallery_root'] = 1;
			self::$groupPerms['selection'] = 1;
			self::$groupPerms['upload'] = 1;

			// Vérification de l'adresse IP par liste blanche.
			if (!Security::checkAuthAdminIP())
			{
				die(App::httpResponse(403));
				return FALSE;
			}
		}

		// Seul un administrateur peut accéder à la partie d'administration.
		if (App::$scriptName == 'admin' && !self::$isAdmin)
		{
			return FALSE;
		}

		// Seul l'utilisateur avec l'identifiant 1 peut être superadmin.
		if (self::$infos['user_id'] != 1 && self::$infos['group_id'] == 1)
		{
			return FALSE;
		}
		self::$id = (int) self::$infos['user_id'];

		// Langue et fuseau horaire.
		self::$lang = self::$infos['user_lang'];
		self::$tz = self::$infos['user_tz'];

		// Pseudonyme.
		self::$nickname = User::getNickname(
			self::$infos['user_login'], self::$infos['user_nickname']
		);

		// Mise à jour de la date de dernière visite
		// et de la date d'expiration de la session.
		if (Config::$params['users_online'] || self::$infos['user_update'])
		{
			$sql = 'UPDATE {users}
					   SET user_lastvstdt = NOW(),
						   user_lastvstip = ?
					 WHERE user_id = ?';
			DB::execute($sql, [$_SERVER['REMOTE_ADDR'], self::$infos['user_id']]);
		}
		if (self::$infos['user_update'])
		{
			$session_expire = (int) CONF_SESSION_EXPIRE;
			$sql = "UPDATE {sessions}
					   SET session_expire = DATE_ADD(NOW(), INTERVAL $session_expire SECOND)
					 WHERE session_token = ?";
			DB::execute($sql, $session_token);
		}

		return self::$connected = TRUE;
	}

	/**
	 * Authentification par formulaire.
	 *
	 * @param string $login
	 *   Identifiant de connexion.
	 * @param string $password
	 *   Mot de passe.
	 * @param bool $remember
	 *   Option pour se "souvenir" de l'utilisateur.
	 * @param string $cause
	 *   Cause du rejet si l'authentification a échouée.
	 *
	 * @return bool
	 *   Retourne TRUE si l'authentification a réussie,
	 *   FALSE sinon.
	 */
	public static function form(string $login, string $password,
	bool $remember = FALSE, string &$cause = ''): bool
	{
		if (self::$connected)
		{
			$cause = 'already_connected';
			return FALSE;
		}

		// Filtrage des données.
		if (strlen($password) > User::COLUMNS_LENGTH_MAX['password']
		|| strlen($login) > User::COLUMNS_LENGTH_MAX['login']
		|| !preg_match('`^' . User::LOGIN_PATTERN . '$`i', $login))
		{
			$cause = 'data_format';
			return FALSE;
		}

		// Vérification de l'adresse IP par liste noire.
		if (App::blacklists('login_' . App::$scriptName,
		'', '', '', ['login' => $login]) !== TRUE)
		{
			$cause = 'blacklist';
			return FALSE;
		}

		// Prévention contre les attaques par force brute.
		if (Security::bruteForce('login\_%\_rejected'))
		{
			$cause = 'brute_force';
			return FALSE;
		}

		// Récupération des informations utiles de l'utilisateur.
		$sql = 'SELECT user_id,
					   user_crtdt,
					   user_password,
					   user_lang,
					   user_tz,
					   user_status,
					   group_admin
				  FROM {users}
			 LEFT JOIN {groups} USING (group_id)
				 WHERE group_id != 2
				   AND user_status IN ("0", "1")
				   AND LOWER(user_login) = LOWER(?)
				   AND user_id != 2';
		if (!DB::execute($sql, $login))
		{
			$cause = 'db_error';
			return FALSE;
		}
		$user_infos = DB::fetchRow();

		// Vérifications du compte et du mot de passe.
		$callback = function(string $new_password) use ($user_infos): void
		{
			DB::execute(
				'UPDATE {users} SET user_password = ? WHERE user_id = ?',
				[$new_password, $user_infos['user_id']]
			);
		};
		$wrong_data = function() use (&$cause, $login)
		{
			App::logActivity('login_' . App::$scriptName . '_rejected', '', ['login' => $login]);
			$cause = 'wrong_data';
			return FALSE;
		};
		if (empty($user_infos['user_id'])
		|| (App::$scriptName == 'admin' && $user_infos['group_admin'] != 1))
		{
			Security::passwordVerifySimulate();
			return $wrong_data();
		}
		if (!Security::passwordVerify($password, $user_infos['user_password'], $callback))
		{
			return $wrong_data();
		}

		// Vérification de l'état.
		if (!$user_infos['user_status'])
		{
			// Log d'activité.
			App::logActivity(
				'login_' . App::$scriptName . '_rejected_deactivated',
				'', ['login' => $login], $user_infos['user_id']
			);

			$cause = 'deactivated';
			return FALSE;
		}

		// Vérification de l'adresse IP des administrateurs par liste blanche.
		if ($user_infos['group_admin'] == 1 && !Security::checkAuthAdminIP())
		{
			// Log d'activité.
			App::logActivity(
				'login_' . App::$scriptName . '_rejected_whitelist_ips',
				'', ['login' => $login, 'IP' => $_SERVER['REMOTE_ADDR']],
				$user_infos['user_id']
			);

			$cause = 'whitelist';
			return FALSE;
		}

		// Début de la transaction.
		if (!($in_transaction = DB::inTransaction()) && !DB::beginTransaction())
		{
			$cause = 'db_error';
			return FALSE;
		}

		self::$id = (int) $user_infos['user_id'];
		self::$isAdmin = $user_infos['group_admin'] == 1;

		// Nouvel identifiant de session.
		$session = self::getNewSessionToken();
		if (!$session['id'])
		{
			$cause = 'db_error';
			return FALSE;
		}

		// On associe l'identifiant de session avec l'utilisateur.
		$sql = 'INSERT IGNORE INTO {sessions_users} (session_id, user_id) VALUES (?, ?)';
		if (!DB::execute($sql, [$session['id'], self::$id]))
		{
			$cause = 'db_error';
			return FALSE;
		}

		// Mise à jour du cookie de session.
		self::$session->add('session_token', $session['token']);

		// Log d'activité.
		App::logActivity('login_' . App::$scriptName, '', ['login' => $login]);

		// Exécution de la transaction.
		if (!$in_transaction && !DB::commitTransaction())
		{
			$cause = 'db_error';
			return FALSE;
		}

		// Expiration du cookie de session.
		self::$session->add(
			'session_expire', $remember ? (string) CONF_COOKIE_SESSION_EXPIRE : '0'
		);

		return TRUE;
	}

	/**
	 * Retourne le jeton de session que possède l'utilisateur.
	 * S'il n'en possède pas, on en génère un nouveau.
	 *
	 * @return string
	 */
	public static function getCookieSessionToken(): string
	{
		if (!self::$_sessionToken)
		{
			$session_token = self::$session->read('session_token');
			if (preg_match('`^[a-z\d]{40}$`i', $session_token))
			{
				self::$_sessionToken = $session_token;
			}
			else
			{
				self::$session->add('session_token',
					self::$_sessionToken = Security::token('sha1'));
			}
		}

		return self::$_sessionToken;
	}

	/**
	 * Génère un nouveau jeton de session et
	 * retourne les informations utiles de la session.
	 *
	 * Cette méthode doit être appelée pour chaque authentification
	 * (compte utilisateur ou mot de passe de catégorie),
	 * afin d'éviter les attaques par fixation de session.
	 *
	 * @return array
	 */
	public static function getNewSessionToken(): array
	{
		$session_id = 0;
		$session_token = self::getCookieSessionToken();
		$session_valid = FALSE;

		// L'utilisateur possède-t-il déjà un jeton de session valide ?
		$sql = 'SELECT session_id
				  FROM {sessions}
				 WHERE session_token = ?
				   AND session_expire > NOW()';
		if (!DB::execute($sql, $session_token))
		{
			goto end;
		}
		if (isset(($i = DB::fetchRow())['session_id']))
		{
			$session_valid = TRUE;
			$session_id = $i['session_id'];
		}

		// Nouveau jeton de session.
		$new_session_token = Security::token('sha1');

		// Si l'utilisateur possède un jeton de session valide,
		// on remplace l'actuel par le nouveau.
		if ($session_valid)
		{
			$sql = 'UPDATE {sessions} SET session_token = ? WHERE session_token = ?';
			$params = [$new_session_token, $session_token];
			if (!DB::execute($sql, $params) || DB::rowCount() != 1)
			{
				goto end;
			}
		}

		// Sinon, on insère le nouveau dans la base de données.
		else
		{
			$session_expire = (int) CONF_SESSION_EXPIRE;
			$sql = "INSERT INTO {sessions} (session_token, session_expire)
						 VALUES (?, DATE_ADD(NOW(), INTERVAL $session_expire SECOND))";
			$params = [$new_session_token];
			$seq = ['table' => '{sessions}', 'column' => 'session_id'];
			if (!DB::execute($sql, $params, $seq) || DB::rowCount() != 1)
			{
				goto end;
			}
			$session_id = DB::lastInsertId();
		}

		$session_token = $new_session_token;

		end:
		return
		[
			'id' => $session_id,
			'token' => $session_token
		];
	}

	/**
	 * Déconnexion de l'utilisateur.
	 *
	 * @return bool
	 */
	public static function logout(): bool
	{
		if (!self::$connected || !DB::beginTransaction())
		{
			return FALSE;
		}

		$sql = 'DELETE
				  FROM {sessions_users}
				 WHERE session_id =
				   (SELECT session_id
					  FROM {sessions}
					 WHERE session_token = ?
					   AND session_expire > NOW())
				   AND user_id = ?';
		if (!DB::execute($sql, [self::getCookieSessionToken(), self::$id]))
		{
			return FALSE;
		}

		App::logActivity('logout_' . App::$scriptName);

		return DB::commitTransaction();
	}

	/**
	 * Gestion des cookies.
	 *
	 * @return void
	 */
	public static function sessionHandler(): void
	{
		// Initialisation des cookies.
		self::$prefs = new Cookie(CONF_COOKIE_PREFS_NAME);
		self::$session = new Cookie(CONF_COOKIE_SESSION_NAME);

		// Écriture des cookies.
		header_register_callback(function(): void
		{
			if (is_object(self::$prefs))
			{
				self::$prefs->write((int) CONF_COOKIE_PREFS_EXPIRE);
				self::$prefs = NULL;
			}
			if (is_object(self::$session))
			{
				self::$session->write((int) self::$session->read('session_expire'));
				self::$session = NULL;
			}
		});
	}
}
?>