<?php


/**
 * La classe Bouncer représente le videur à l'entrée du site.
 * Il est chargé d'empêcher d'entrer tous ceux qui ne s'identifient pas correctement !
 * Il effectue également les vérifications liées à la session, et est aussi chargé
 * de déterminer quelle langue parle l'utilisateur.
 */
 
 define ("STATUS_GOOD_STANDING",			0);
 define ("STATUS_ACCOUNT_NOT_ACTIVATED",	1);
 define ("STATUS_NEEDS_MANUAL_VALIDATION",	2);
 define ("STATUS_LOCKED_ACCOUNT",			3);
 
 include_once "DatabaseHelper.php";
 include_once "ErrorHandler.php";
 include_once "Dictionary.php";
 
 class Bouncer
 {
	private $errorCode = FATAL_UNKNOWN_ERROR;
	private $userID = -1;
	private $userName = '';
	private $browserHeaders = array();
	
	public function __construct() 
	{
		$this->browserHeaders = apache_request_headers();
	}
	
	/**
	 * Analyse la validité d'une session. Si elle est invalide, la termine et en recrée une 
	 * nouvelle, à un niveau sans privilège (utilisateur anonyme)
	 *
	 * Une session est déclarée valide si le code de contrôle fourni dans le POST
	 * correspond à la valeur de hachage SID + Agent navigateur + ID + login
	 */
	public function validateSession()
	{
		session_name('RRI');
		session_start();
		$validated = false;
		if (isset($_SESSION['userId']) && isset($_SESSION['userName'])) {
			// Session avec privilège utilisateur
			if (isset($_COOKIE['RRSynchro'])) {
				// protection contre le détournement de session,  on définit des jetons de synchro :
				//  - un côté serveur contient un contenu aléatoire
				//  - un cookie côté client contient un code de contrôle correspondant
				//    à un hash du SID + le contenu de cette variable +
				//    un identifiant du navigateur + le user ID + le nom utilisateur
				// Sur chargement d'une nouvelle page, le hash recalculé à partir des valeurs stockées
				// sur le serveur doit correspondre au cookie.
				$identifier = $this->getSynchroTokenContents();
				$validated = ($identifier == $_COOKIE['RRSynchro']);
			}
			if (!$validated) {
			
				// s'il n'y a pas de correspondance, on soupçonne une tentative de vol de session
				// => on la termine abruptement, et on enregistre un incident
				$this->terminateSession();
				try {
					$dbHelper = new DatabaseHelper();
					$dbHelper->open();
					ErrorHandler::getInstance()->setParameters($_POST['synchroToken']);
					$errorCode = FATAL_SESSION_CHECK_FAILED;
					$message = ErrorHandler::getInstance()->getAdminErrorMessage($errorCode);
					$dbHelper->recordIncident($_SESSION['userId'], $_SESSION['userName'], $errorCode, $message, 0, 0, 0, 0, 0);
					
				} catch (Exception $exc) {
					// vu que c'est une tentative de log qui a échoué, on ne va pas pouvoir logger l'exception non plus
					// TODO : trouver une manière de gérer la situation
				}
				$dbHelper->close();

				session_start();
			}
		} else {
			// session anonyme sans privilèges : pas de risques
			// on dévalide les deux variables, au cas où l'une serait mise mais pas l'autre (tentative d'intrusion)
			unset($_SESSION['userId']);
			unset($_SESSION['userName']);	
			unset($_SESSION['options']);			
		}
		$this->setSynchroTokens();
		$this->setSessionLanguage();
	}
	
	/**
	 * Vide et détruit la session en cours.
	 */
	public function terminateSession()
	{
		$_SESSION = array();
		if (isset($_COOKIE[session_name()])) {
			setcookie(session_name(), '', time()-86400, '/');
		}
		if (isset($_COOKIE['RRSynchro'])) {
			setcookie('RRSynchro', '', time()-86400, '/');
		}
		session_destroy();
	}
	
	/**
	 * Vérifie que l'utilisateur est autorisé à se connecter à partir des informations
	 * (login, mot de passe) fournies. Si oui, enregistre l'ID utilisateur dans la session 
	 * et renvoie SUCCESS.
	 * Dans tous les autres cas, renvoie un code d'erreur 
	 *  - le login ou le mot de passe ne respectent pas les critères du site
	 *  - le nom est inconnu en base
	 *  - le nom existe en double dans la base
	 *  - le mot de passe ne correspond pas
	 *  - le statut du compte (activation, verrouillage) n'autorise pas la connexion 
	 *
	 * Les paramètres d'entrée n'ont pas à être vérifiés préalablement.
	 */
	public function challengeAccess($uncheckedUsername, $uncheckedPassword) 
	{
		$status = FATAL_LOGIN_FAILED;
		$this->userID = -1;
		// nom d'utilisateur non conforme : on ne va pas plus loin
		if (strlen($uncheckedUsername)<3 || strlen($uncheckedUsername)>30) {
			return FATAL_LOGIN_FAILED;
		}
		if (preg_match('/[^- \'\w]/', $uncheckedUsername, $matches))  {
			return FATAL_LOGIN_FAILED;
		}
		// mot de passe non conforme : pareil
		if (strlen($uncheckedPassword)<6) {
			return FATAL_LOGIN_FAILED;
		}
		
		$validUsername = $uncheckedUsername;
		$validPass = $uncheckedPassword;
		try {
			$dbHelper = new DatabaseHelper();
			$dbHelper->open();
			$status = $this->internalDatabaseCheck ($dbHelper, $validUsername, $validPass);
			
			if (SUCCESS == $status) {
				// on change de SID à chaque montée de privilège pour éviter une fixation de session
				session_regenerate_id();
				if ($this->userID > -1) {
					$_SESSION['userId']	= $this->userID;
					$_SESSION['userName'] = $this->userName;
					$_SESSION['options'] = $this->options;
				} else {
					unset($_SESSION['userId']);
					unset($_SESSION['userName']);
					unset($_SESSION['options']);
				}
				// et on remet les jetons de synchro à jour une fois les id/name dans la session
				unset($_SESSION['synchro']);
				$this->setSynchroTokens();
			}
			
		} catch (Exception $exc) {
			$userId = $this->userID;
			$userName = $validUsername;
			$status = $exc->getCode();
			$errorMessage = ErrorHandler::getInstance()->getAdminErrorMessage($status).' from exception : '.$exc->getMessage();
			$dbHelper->recordIncident($userId, $userName, $status, $errorMessage, 0, 0, 0, 0, '');
			
			// pour le joueur, on renvoie un code d'erreur générique
			$status = FATAL_LOGIN_ABORTED;
		}
		$dbHelper->close();

		return $status;
	}
	
	/**
	 * Renvoie la chaîne d'identification à passer dans le paramètre 'synchroToken'
	 * de la variable POST. Cette chaîne sera testée dans l'appel à validateSession() 
	 * qui sera fait au chargement de la page suivante.
	 *
	 * L'identifiant est un hash du SID + un identifiant du navigateur + le user ID
	 * + le nom utilisateur. En cas de changement de SID, d'utilisation d'un autre 
	 * navigateur, ou d'identification différente, la session pourra être invalidée.
	 */
	public function getSynchroTokenContents()
	{
		$identifier = '/'.session_id().'/';
		if (NULL != $this->browserHeaders) {
			$identifier.=$this->browserHeaders['User-Agent'].'/';
		}
		$identifier.=$_SESSION['synchro'].'/';
		$identifier.=isset($_SESSION['userId']) ? ($_SESSION['userId'].'/') : '-1/';
		$identifier.=isset($_SESSION['userName']) ? ($_SESSION['userName'].'/') : '?/';
				
		return sha1($identifier);
	}
	
	/**
	 * Renvoie l'identifiant du joueur, une fois celui-ci reconnu. -1 sinon.
	 */
	public function getUserID() 
	{
		return $this->userID;
	}
	
	/**
	 * Renvoie le nom (validé) du joueur, une fois celui-ci reconnu. Vide sinon.
	 */
	public function getUserName() 
	{
		return $this->userName;
	}
	
	/**
	 *
	 */
	public function getErrorCode()
	{
		return $this->errorCode;
	}
	
	/**
	 * Appelle la base de données pour vérifier que le couple login/mot de passe (ici en clair)
	 * fournis correspondent bien à un compte du jeu.
	 * Ces deux champs doivent avoir été validés (en termes de format) précédemment.
	 *
	 * Si oui, renseigne la variable membre userID et renvoie SUCCESS.
	 * Sinon, renvoie un code d'erreur.
	 */
	private function internalDatabaseCheck ($dbHelper, $validUsername, $validPass)
	{
		$this->userID = -1;
		$result = $dbHelper->getUserCredentials($validUsername);
			
		$pwHash = hash_hmac('sha256', $validPass, $result['SALT'], true);
		if ($pwHash != $result['PW']) {
			// mauvais mot de passe
			// TODO : faire attendre de plus en plus longtemps si les échecs se multiplient
			return FATAL_LOGIN_FAILED;
		}
		
		// On vérifie que l'état du compte permet la connexion : compte activé, non verrouillé
		if (STATUS_ACCOUNT_NOT_ACTIVATED == $result['STATUS']) {
			return FATAL_ACCOUNT_NOT_ACTIVATED;
		}
			
		if (STATUS_NEEDS_MANUAL_VALIDATION == $result['STATUS']) {
			return FATAL_ACCOUNT_NOT_ACTIVATED;
		}
		
		if (STATUS_LOCKED_ACCOUNT == $result['STATUS']) {
			return FATAL_ACCOUNT_LOCKED;
		}

		if (STATUS_GOOD_STANDING != $result['STATUS']) {
			return FATAL_LOGIN_FAILED;
		}
		
		$this->userID = $result['ID'];
		$this->userName = $validUsername;
		$this->options = $result['OPTIONS'];
		
		return SUCCESS;
	}
	
	/**
	 * Définit les jetons de synchronisation entre serveur et client.
	 * Côté serveur : variable de session 'synchro' consistant en une chaîne aléatoire
	 * Côté client : cookie 'RRSynchro' qui est un hash de la variable serveur et d'autres éléments
	 */
	private function setSynchroTokens()
	{
		if (!isset($_SESSION['synchro'])) {
			// TODO : niveau de protection supplémentaire, changer la valeur de la synchro à chaque page
			$_SESSION['synchro'] = sha1('rr'.mt_rand());
		}
		setcookie('RRSynchro', $this->getSynchroTokenContents(), time()+3600, '/');
	}
	
	/**
	 * Définit la langue qui sera utilisée pour l'IHM
	 *  - si l'utilisateur a cliqué sur un drapeau (variable "lang" dans le GET), on la prend en premier
	 *  - sinon, si la langue est déja définie dans la session, on la garde
	 *  - sinon, on regarde si le cookie existe
	 *  - sinon, on prend la langue par défaut du browser (HTTP-Accept-Language)
	 *
	 * Si une langue est trouvée, elle est vérifiée par rapport à la liste des langues connues pour éviter
	 * les attaques à ce niveau. Si la vérification échoue, ou si aucune langue n'est détectée via
	 * les préférences et le navigateur, on garde la langue par défaut de la classe Dictionary (anglais)
	 */
	private function setSessionLanguage()
	{
		if (isset($_GET['lang'])) {
			if (Dictionary::getInstance()->setLanguage($_GET['lang'])) {
				return;
			}
		}

		if (isset($_SESSION['language'])) {
			if (Dictionary::getInstance()->setLanguage($_SESSION['language'])) {
				return;
			}
		}

		if (isset($_COOKIE['RRLanguage'])) {
			if (Dictionary::getInstance()->setLanguage($_COOKIE['RRLanguage'])) {
				return;
			}
		}
		
		$negociated = $this->negotiateLanguage();
		Dictionary::getInstance()->setLanguage($negociated);
	}
	
	/**
	 * Determine, from the values of HTTP_ACCEPT_LANGUAGE and the list
	 * of languages currently in the Dictionary, the one most suited to the user
	 *
	 * Code copied and adapted from the page http://fr2.php.net/http_negotiate_language
	 * (anonymous post)
	 * Reimplementation of http_negotiate_language in case PECL extension is not available.
	 */
	private function negotiateLanguage () 
	{
		$supported = Dictionary::getInstance()->getSupportedLanguages();
		$http_accept_language = $_SERVER['HTTP_ACCEPT_LANGUAGE'];

		// standard  for HTTP_ACCEPT_LANGUAGE is defined under
		// http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.4
		// pattern to find is therefore something like this:
		//    1#( language-range [ ";" "q" "=" qvalue ] )
		// where:
		//    language-range  = ( ( 1*8ALPHA *( "-" 1*8ALPHA ) ) | "*" )
		//    qvalue         = ( "0" [ "." 0*3DIGIT ] )
		//            | ( "1" [ "." 0*3("0") ] )
		preg_match_all("/([[:alpha:]]{1,8})(-([[:alpha:]|-]{1,8}))?" .
					   "(\s*;\s*q\s*=\s*(1\.0{0,3}|0\.\d{0,3}))?\s*(,|$)/i",
					   $http_accept_language, $hits, PREG_SET_ORDER);

		// default language (in case of no hits) is the first in the array
		$bestlang = $supported[0];
		$bestqval = 0;

		foreach ($hits as $arr) {
			// read data from the array of this hit
			$langprefix = strtolower ($arr[1]);
			if (!empty($arr[3])) {
				$langrange = strtolower ($arr[3]);
				$language = $langprefix . "-" . $langrange;
			}
			else $language = $langprefix;
			$qvalue = 1.0;
			if (!empty($arr[5])) $qvalue = floatval($arr[5]);
		 
			// find q-maximal language 
			if (in_array($language, $supported) && ($qvalue > $bestqval)) {
				$bestlang = $language;
				$bestqval = $qvalue;
			}
			// if no direct hit, try the prefix only but decrease q-value by 10% (as http_negotiate_language does)
			else if (in_array($langprefix, $supported) && (($qvalue*0.9) > $bestqval)) {
				$bestlang = $langprefix;
				$bestqval = $qvalue*0.9;
			}
		}
		return $bestlang;
	}
	
 }

?>