<?php
/**
 * @version $Id$
 * @author Thomas Crespin <thomas.crespin@sesamath.net>
 * @copyright Thomas Crespin 2009-2022
 * 
 * ****************************************************************************************************
 * SACoche <https://sacoche.sesamath.net> - Suivi d’Acquisitions de Compétences
 * © Thomas Crespin pour Sésamath <https://www.sesamath.net> - Tous droits réservés.
 * Logiciel placé sous la licence libre Affero GPL 3 <https://www.gnu.org/licenses/agpl-3.0.html>.
 * ****************************************************************************************************
 * 
 * Ce fichier est une partie de SACoche.
 * 
 * SACoche est un logiciel libre ; vous pouvez le redistribuer ou le modifier suivant les termes 
 * de la “GNU Affero General Public License” telle que publiée par la Free Software Foundation :
 * soit la version 3 de cette licence, soit (à votre gré) toute version ultérieure.
 * 
 * SACoche est distribué dans l’espoir qu’il vous sera utile, mais SANS AUCUNE GARANTIE :
 * sans même la garantie implicite de COMMERCIALISABILITÉ ni d’ADÉQUATION À UN OBJECTIF PARTICULIER.
 * Consultez la Licence Publique Générale GNU Affero pour plus de détails.
 * 
 * Vous devriez avoir reçu une copie de la Licence Publique Générale GNU Affero avec SACoche ;
 * si ce n’est pas le cas, consultez : <http://www.gnu.org/licenses/>.
 * 
 */


/**
 * Classe pour gérer les sessions et les jetons anti-CSRF.
 * La session est transmise via le cookie "$_COOKIE[SESSION_NOM]".
 */
class Session
{

  // //////////////////////////////////////////////////
  // Attributs
  // //////////////////////////////////////////////////

  private static $SESSION_ID         = NULL;
  public static  $_sso_redirect      = FALSE;
  private static $tab_droits_page    = array();
  public static  $tab_message_erreur = array();
  public static  $_CSRF_value        = '';

  // //////////////////////////////////////////////////
  // Méthodes publiques - Outils
  // //////////////////////////////////////////////////

  /*
   * Renvoyer l’IP
   * 
   * @return string
   */
  public static function get_IP()
  {
    /**
     * Si PHP est derrière un reverse proxy ou un load balancer, REMOTE_ADDR peut contenir l’ip du proxy.
     * Normalement, le proxy doit renseigner HTTP_X_REAL_IP, ou HTTP_X_FORWARDED_FOR.
     * Au CITIC, REMOTE_ADDR est correct, et chez OVH aussi (avec apache derrière nginx ou pas).
     */
    $ip = $_SERVER['REMOTE_ADDR'];
    if (isset($_SERVER['HTTP_X_REAL_IP']))
    {
      $ip = $_SERVER['HTTP_X_REAL_IP'];
    }
    elseif (isset($_SERVER['HTTP_X_FORWARDED_FOR']))
    {
      $forwarded = $_SERVER['HTTP_X_FORWARDED_FOR'];
      // on s’arrête à la 1re virgule si on en trouve une
      $tab_forwarded = preg_split("/,| /",$forwarded);
      $ip_candidate = $tab_forwarded[0];
      $ip_composantes = explode('.', $ip_candidate);
      // On vérifie que ça ressemble à une ip (au cas où on aurait une chaîne bizarre ou vide on garde notre REMOTE_ADDR)
      if ( (count($ip_composantes)==4) && (min($ip_composantes)>=0) && (max($ip_composantes)<256) && (max($ip_composantes)>0) )
      {
        $ip = $ip_candidate;
      }
    }
    // petit truc pour s’assurer d’avoir une IP valide
    return ($ip == long2ip(ip2long($ip))) ? $ip : '' ;
  }

  /*
   * Renvoyer une clef associée au navigateur et à la session en cours
   * 
   * @return string
   */
  public static function get_UserAgent()
  {
    return isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '' ;
  }

  // //////////////////////////////////////////////////
  // Méthodes publiques - Modification de variables de session
  // //////////////////////////////////////////////////

  /*
   * Le fait d’utiliser des méthodes pour cela permet :
   * - de lister les endroits du code où le contenu de la session est modifié
   * - et donc de mieux repérer où on peut appeler session_write_close() car la session ne sera plus modifiée
   * Préfixées par le caractère underscore afin de localiser tous les appels.
   * Ne pas oublier qu’il y a aussi des modifications directes par les librairies phpCAS et simpleSAMLphp.
   */

  /*
   * Modifier une variable de session
   * Le nombre d’arguments passés est variable selon la profondeur du tableau.
   * 
   * @param $string key1
   * @param $string key2
   * @param $string key...
   * @param $mixed  value
   * @return void
   */
  public static function _set()
  {
    // session_status() n’est pas disponible en PHP 5.3
    if( function_exists('session_status') && ( session_status() != PHP_SESSION_ACTIVE ) )
    {
      exit_error( 'Session close' /*titre*/ , 'Méthode Session::_set() invoquée alors que la session est fermée.' /*contenu*/ );
    }
    switch(func_num_args())
    {
      case 2:
        $_SESSION[func_get_arg(0)] = func_get_arg(1);
        break;
      case 3:
        $_SESSION[func_get_arg(0)][func_get_arg(1)] = func_get_arg(2);
        break;
      case 4:
        $_SESSION[func_get_arg(0)][func_get_arg(1)][func_get_arg(2)] = func_get_arg(3);
        break;
      default:
        exit_error( 'Erreur inattendue' /*titre*/ , 'Méthode Session::_set() invoquée avec un nombre imprévu d’arguments.' /*contenu*/ );
    }
  }

  /*
   * Supprimer une variable de session
   * @param $string key1
   * @param $string key2
   * @param $string key...
   * @param $mixed  value
   * @return void
   */
  public static function _unset()
  {
    switch(func_num_args())
    {
      case 1:
        unset($_SESSION[func_get_arg(0)]);
        break;
      // case 2:
        // unset($_SESSION[func_get_arg(0)][func_get_arg(1)]);
        // break;
      // case 3:
        // unset($_SESSION[func_get_arg(0)][func_get_arg(1)][func_get_arg(2)]);
        // break;
      default:
        exit_error( 'Erreur inattendue' /*titre*/ , 'Méthode Session::_unset() invoquée avec un nombre imprévu d’arguments.' /*contenu*/ );
    }
  }

  /*
   * Incrémente une variable de session
   * Le nombre d’arguments passés est variable selon la profondeur du tableau.
   * 
   * @param $string key1
   * @param $string key2
   * @param $string key...
   * @param $mixed  value
   * @return void
   */
  public static function _inc()
  {
    switch(func_num_args())
    {
      // case 1:
        // $_SESSION[func_get_arg(0)]++;
        // break;
      // case 2:
        // $_SESSION[func_get_arg(0)][func_get_arg(1)]++;
        // break;
      case 3:
        $_SESSION[func_get_arg(0)][func_get_arg(1)][func_get_arg(2)]++;
        break;
      default:
        exit_error( 'Erreur inattendue' /*titre*/ , 'Méthode Session::_inc() invoquée avec un nombre imprévu d’arguments.' /*contenu*/ );
    }
  }

  /*
   * Applique array_shift()
   * @param $string key
   * @return mixed
   */
  public static function _shift($key)
  {
    return array_shift($_SESSION[$key]);
  }

  // //////////////////////////////////////////////////
  // Méthodes privées (internes) - Gestion de la session
  // //////////////////////////////////////////////////

  /**
   * Indiquer l’Id de session.
   * Sa valeur est dans le cookie de session, sauf pour un appel de l’API en POST où ce n’est pas récupéré, il faut alors l’indiquer pour retrouver la session.
   *
   * @return void
   */
  private static function set_id($session_id)
  {
    if(isset($_COOKIE[SESSION_NOM]))
    {
      Session::$SESSION_ID = $_COOKIE[SESSION_NOM];
    }
    else if($session_id)
    {
      Session::$SESSION_ID = $session_id;
    }
  }

  /**
   * Paramétrage de la session appelé avant son ouverture.
   * Y a pas de constructeur statique en PHP...
   *
   * @return void
   */
  private static function param()
  {
    // Header à envoyer avant toute génération de cookie pour mettre en place une stratégie de confidentialité compacte.
    // La plateforme pour les préférences de confidentialité est un standart W3C nommé en anglais "Platform for Privacy Preferences" d’où "P3P".
    // Permet d’éviter des soucis avec IE si son paramétrage de confidentialité des cookies est en position haute.
    // @see http://www.webmaster-hub.com/topic/3754-cookies-et-strategie-de-confidentialite/
    // @see http://www.yoyodesign.org/doc/w3c/p3p1/#compact_policies
    // @see http://www.yoyodesign.org/doc/w3c/p3p1/#ref_file_policyref
    // @see http://www.symantec.com/region/fr/resources/protocole.html
    // @see http://fr.php.net/manual/fr/function.setcookie.php#102352
    header('P3P:policyref="'.URL_DIR_SACOCHE.'p3p.xml",CP="NON DSP COR CURa OUR NOR UNI"');
    // Pour un cookie multi-domaines ; paramètres {lifetime ; path ; domain ; secure ; httponly }
    // Pour éviter l’avertissement "Le cookie sera bientôt rejeté car son attribut « SameSite » est défini sur « None » ou une valeur invalide et il n’a pas l’attribut « secure »."
    // - l’ajout du 4e param "secure" à TRUE ne fonctionne que si protocole HTTPS forcé
    // - attribut « SameSite » à "Lax" si sous-domaines ou "Strict" si restreint au domaine
    // Attention, si on précise un domaine, par exemple "sacoche.sesamath.net", alors il est enregistré par PHP comme étant ".sacoche.sesamath.net" (pour inclure de force les sous-domaines...)
    // et du coup le paramétrage "Strict" ne retrouve pas le cookie, du coup on reste à "Lax" par précaution.
    // Paramètres aussi définis dans la classe Cookie et dans le fichier javascript de gestion des cookies
    $lifetime = 0; // par défaut
    $path     = '/'; // par défaut
    $domain   = ''; // par défaut
    $secure   = FALSE;
    $httponly = FALSE;
    $samesite = 'Lax'; // @see https://developer.mozilla.org/fr/docs/Web/HTTP/Headers/Set-Cookie/SameSite
    if(version_compare(PHP_VERSION,'7.3','<'))
    {
      session_set_cookie_params( $lifetime , $path.'; samesite='.$samesite , $domain , $secure , $httponly );
    }
    else
    {
      session_set_cookie_params( array(
        'lifetime' => $lifetime,
        'path'     => $path,
        'domain'   => $domain,
        'secure'   => $secure,
        'httponly' => $httponly,
        'samesite' => $samesite,
      ) );
    }
    // Nom du cookie de session
    session_name(SESSION_NOM);
    // avec nocache, php va ajouter les headers pour empêcher la mise en cache (Expires & co)
    // (cache du navigateur, de varnish ou un autre proxy, mais varnish ne met pas en cache
    // le php car y'a des cookies, sauf conf particulière dans un cas précis)
    // faudrait changer ça si on est dans un contexte non authentifié
    // et que l'on ne demande pas d'authentification, mais on ouvre la session et on en sait encore rien
    // faudrait le modifier plus tard si SSO ne fait pas de redirection et que l'on est pas authentifié
    // Donc on met ça par défaut, et l'appli pourra donner un contrordre plus tard si elle veut
    session_cache_limiter('nocache');
  }

  /*
   * Ouvrir une session existante
   * 
   * @return void
   */
  private static function open_old()
  {
    Session::param();
    // Vérification de l’id de session afin d’éviter :
    // "PHP Warning:  session_start(): The session id is too long or contains illegal characters, valid characters are a-z, A-Z, 0-9 and '-,' in ..."
    // Cet id est :
    // - soit formé automatiquement avec uniqid().md5() donc 13+32 caractères alphanumériques
    // - soit un jeton d’api imposé avec $_SESSION['BASE'].'i'.mb_substr(...,0,64) donc 66 à environ 71 caractères alphanumériques
    //
    // Au 12/12/2025 sans savoir pourquoi je me suis retrouvé avec des identifiants de session sur 32 caractères.
    // Comme si le système ne tenait pas compte du nom imposé... ou comme si la session était ouverte avant autrement...
    // Du coup j’ai abaissé la limite de vérification à 32 caractères.
    if( !preg_match('/^[A-Za-z0-9]{32,75}$/',Session::$SESSION_ID) ) 
    {
      Session::destroy(FALSE); // FALSE Sinon on repasse par ici en boucle
      exit_error( 'Ouverture de session' /*titre*/ , 'L’identifiant de session n’est pas conforme.<br>Valeur du cookie de session modifiée manuellement ?' /*contenu*/ );
    }
    session_id(Session::$SESSION_ID);
    if( !session_start() )
    {
      exit_error( 'Ouverture de session' /*titre*/ , 'La session n’a pu être démarrée.<br>Le disque dur du serveur hébergeant SACoche serait-il plein ?' /*contenu*/ );
    }
  }

  /*
   * Ouvrir une nouvelle session
   *
   * @return bool
   */
  private static function open_new()
  {
    Session::param();
    // Utiliser l’option préfixe ou entropie de uniqid() insère un '.' qui peut provoquer une erreur disant que les seuls caractères autorisés sont a-z, A-Z, 0-9 et -
    $ID = Session::$SESSION_ID ? Session::$SESSION_ID : uniqid().md5('grain_de_sable'.mt_rand()) ;
    session_id($ID);
    return session_start();
  }

  /*
   * Initialiser une session ouverte
   * 
   * @return void
   */
  private static function init()
  {
    $_SESSION = array();
    // Infos pour détecter les vols de session.
    Session::_set('SESSION_ID'            , session_id() );
    Session::_set('SESSION_IP'            , Session::get_IP() );
    Session::_set('SESSION_UA'            , Session::get_UserAgent() );
    // Numéro de la base
    Session::_set('BASE'                  , 0 );
    // Données associées au profil de l’utilisateur.
    Session::_set('USER_PROFIL_SIGLE'     , 'OUT' );
    Session::_set('USER_PROFIL_TYPE'      , 'public' );
    Session::_set('USER_PROFIL_NOM_COURT' , 'non connecté' );
    Session::_set('USER_PROFIL_NOM_LONG'  , 'utilisateur non connecté' );
    Session::_set('USER_DUREE_INACTIVITE' , 0 );
    // Données personnelles de l’utilisateur.
    Session::_set('USER_ID'               , 0 );
    Session::_set('USER_NOM'              , '-' );
    Session::_set('USER_PRENOM'           , '-' );
    // Données associées à l’établissement.
    Session::_set('SESAMATH_ID'           , 0 );
    Session::_set('CONNEXION_MODE'        , 'normal' );
    // Informations navigateur.
    Session::_set('BROWSER' , Browser::caracteristiques_navigateur() );
  }

  /*
   * Rediriger vers l’authentification SSO si détecté.
   * 
   * Si HTML : message d’erreur mis en session qui provoquera un retour en page d’accueil.
   * Si AJAX : sortir de suite avec un message d’erreur.
   * 
   * @param string $message
   * @return void
   */
  private static function exit_sauf_SSO($message)
  {
    $test_get = isset($_GET['sso']) ? TRUE : FALSE ; // $test_get =    ( isset($_GET['sso']) && ( isset($_GET['base']) || isset($_GET['id']) || isset($_GET['uai']) || isset($_COOKIE[COOKIE_MEMOGET]) || (HEBERGEUR_INSTALLATION=='mono-structure') ) ) ? TRUE : FALSE ;
    $test_cookie = ( ( isset($_COOKIE[COOKIE_STRUCTURE]) || (HEBERGEUR_INSTALLATION=='mono-structure') ) && isset($_COOKIE[COOKIE_AUTHMODE]) && ($_COOKIE[COOKIE_AUTHMODE]!='normal') ) ? TRUE : FALSE ;
    // si html
    if(SACoche=='index')
    {
      if( $test_get || $test_cookie )
      {
        // La redirection SSO se fera plus tard, une fois les paramètres SQL chargés, le test de blocage de l’accès effectué, etc.
        Session::$_sso_redirect = TRUE;
      }
      else
      {
        // accès direct à une page réservée, onglets incompatibles ouverts, inactivité, disque plein, chemin invalide, ...
        Session::$tab_message_erreur[] = $message.' Veuillez vous (re)connecter.';
      }
    }
    // si api
    else if(SACoche=='api')
    {
      Json::api_end( 401 /* Unauthorized */ , $message.' Rappeler le service de connexion.' );
    }
    // si ajax
    else
    {
      $conseil = ( $test_get || $test_cookie ) ? ' Veuillez actualiser la page.' : ' Veuillez vous (re)connecter.' ;
      exit_error( 'Session perdue / expirée' /*titre*/ , $message.$conseil /*contenu*/ );
    }
  }

  /*
   * Essayer de détecter un éventuel vol de session (c’est cependant difficile de récupérer le cookie d’un tiers, voire impossible avec les autres protections dont SACoche bénéficie).
   * 
   * @return array | NULL
   */
  private static function TestAnomalieSession()
  {
    // Test sur l’identifiant de session (mais je ne vois pas comment il pourrait y avoir une modification à ce niveau)
    $ID_old = $_SESSION['SESSION_ID'];
    $ID_new = session_id();
    if($ID_old != $ID_new)
    {
      return array( 'session différente' , $ID_old , $ID_new );
    }
    // Test sur l’IP
    if(empty($_SESSION['ETABLISSEMENT']['IP_VARIABLE']))
    {
      $IP_old = $_SESSION['SESSION_IP'];
      $IP_new = Session::get_IP();
      if( $IP_old && ($IP_old != $IP_new) )
      {
        return array( 'adresse IP différente' , $IP_old , $IP_new );
      }
    }
    // Test sur le navigateur (une mise à jour du navigateur en cours de navigation peut déclencher ceci)
    $UA_old = $_SESSION['SESSION_UA'];
    $UA_new = Session::get_UserAgent();
    if($UA_old != $UA_new)
    {
      $UA_old = ( Outil::pourcentage_commun( $UA_old , $UA_new ) > 90 ) ? $UA_old : 'Chaîne non dévoilée par sécurité.' ;
      return array( 'navigateur différent' , $UA_old , $UA_new );
    }
    // OK
    return NULL;
  }

  // //////////////////////////////////////////////////
  // Méthodes publiques - Gestion de la session
  // //////////////////////////////////////////////////

  /*
   * Lancer en cascade les processus pour repartir avec une nouvelle session
   * Rendue publique car appelée directement lors du basculement d’un compte à un autre
   * 
   * @param bool   $memo_GET   Pour réinjecter les paramètres après authentification SACoche (pour une authentification SSO, c’est déjà automatique)
   * @return void
   */
  public static function destroy__open_new__init($memo_GET)
  {
    $get = ( $memo_GET && !empty($_GET) && (SACoche!='ajax') ) ? $_GET : NULL ;
    Session::destroy();
    Session::open_new();
    Session::init();
    Session::_set( 'MEMO_GET' , $get );
  }

  /**
   * Récupérer le tableau des droits d’accès à une page donnée.
   * Le fait de lister les droits d’accès de chaque page empêche de surcroit l’exploitation d’une vulnérabilité "include PHP" (https://www.cert.ssi.gouv.fr/alerte/CERTA-2003-ALE-003/).
   * 
   * @param string $page
   * @return bool
   */
  public static function recuperer_droit_acces($page)
  {
    // Pour des raison de clarté / maintenance, il est préférable d’externaliser ce tableau dans un fichier.
    require(CHEMIN_DOSSIER_INCLUDE.'tableau_droits.php');
    if(isset($tab_droits_par_page[$page]))
    {
      Session::$tab_droits_page = $tab_droits_par_page[$page];
      return TRUE;
    }
    else
    {
      // La page n’a pas de droit défini, elle ne sera donc pas chargée ; on renseigne toutefois Session::$tab_droits_page pour ne pas provoquer d’erreur.
      Session::$tab_droits_page = $tab_droits_profil_tous;
      return FALSE;
    }
  }

  /**
   * Vérifier le droit d’accès à une page donnée pour le profil transmis.
   * recuperer_droit_acces() doit avoir été préalablement appelé.
   * 
   * @param string $profil
   * @return bool
   */
  public static function verifier_droit_acces($profil)
  {
    return Session::$tab_droits_page[$profil];
  }

  /*
   * Détruire une session existante
   * Remarque: pour obliger à une reconnexion sans détruire la session (donc les infos des fournisseurs de SSO), il suffit de faire $_SESSION['USER_PROFIL_SIGLE'] = 'OUT';
   * 
   * @param bool   is_initialized   TRUE par défaut ; FALSE pour éviter "Warning: session_destroy(): Trying to destroy uninitialized session"
   * @return void
   */
  public static function destroy($is_initialized=TRUE)
  {
    // Pas besoin de session_start() car la session a déjà été ouverte avant appel à cette fonction.
    $_SESSION = array();
    session_unset();
    Cookie::effacer(session_name());
    if($is_initialized)
    {
      session_destroy();
    }
  }

  /*
   * Rechercher une session existante et gérer les différents cas possibles.
   * Session::$tab_droits_page a déjà été renseigné lors de l’appel à Session::recuperer_droit_acces()
   * 
   * @param string $session_id   pour imposer l’identifiant de session : voir set_id()
   * @return void | exit ! (sur une string si ajax, une page html, ou modification $PAGE pour process SSO)
   */
  public static function execute($session_id=NULL)
  {
    Session::set_id($session_id);
    if(!isset(Session::$SESSION_ID))
    {
      // 1. Aucune session transmise
      Session::open_new();
      Session::init();
      if(!Session::verifier_droit_acces('public'))
      {
        // 1.1. Demande d’accès à une page réservée (donc besoin d’identification) : session perdue / expirée, ou demande d’accès direct (lien profond) -> redirection pour une nouvelle identification
        Session::_set( 'MEMO_GET' , $_GET ); // On mémorise $_GET pour un lien profond hors SSO, mais pas d’initialisation de session sinon la redirection avec le SSO tourne en boucle.
        Session::exit_sauf_SSO('Session absente / perdue / expirée / incompatible.'); // Si SSO au prochain coup on ne passera plus par là.
      }
      else
      {
        // 1.2 Accès à une page publique : RAS
      }
    }
    else
    {
      // 2. id de session transmis
      Session::open_old();
      if(!isset($_SESSION['USER_PROFIL_SIGLE']))
      {
        // 2.1. Pas de session retrouvée (sinon cette variable serait renseignée)
        if(!Session::verifier_droit_acces('public'))
        {
          // 2.1.1. Session perdue ou expirée et demande d’accès à une page réservée : redirection pour une nouvelle identification
          Session::destroy__open_new__init( TRUE /*memo_GET*/ );
          Session::exit_sauf_SSO('Session absente / perdue / expirée / incompatible.'); // On peut initialiser la session avant car si SSO au prochain coup on ne passera plus par là.
        }
        else
        {
          // 2.1.2. Session perdue ou expirée et page publique : création d’une nouvelle session, pas de message d’alerte pour indiquer que la session est perdue
          Session::destroy__open_new__init( TRUE /*memo_GET*/ );
        }
      }
      elseif($_SESSION['USER_PROFIL_SIGLE'] == 'OUT')
      {
        // 2.2. Session retrouvée, utilisateur non identifié
        if(!Session::verifier_droit_acces('public'))
        {
          // 2.2.1. Espace non identifié => Espace identifié : redirection pour identification
          Session::_set( 'MEMO_GET' , $_GET ); // On mémorise $_GET pour un lien profond hors SSO, mais pas d’initialisation de session sinon la redirection avec le SSO tourne en boucle.
          Session::exit_sauf_SSO('Authentification manquante ou perdue (onglets incompatibles ouverts ?).');
        }
        else
        {
          // 2.2.2. Espace non identifié => Espace non identifié : RAS
        }
      }
      // On ne teste un vol de session que pour les utilisateurs identifiés car un établissement peut paramétrer d’éviter cette vérification
      elseif( $tab_info_pb = Session::TestAnomalieSession() )
      {
        // 2.3. Session retrouvée, mais pb détecté (IP changée, navigateur différent)
        list( $msg_pb , $avant , $apres ) = $tab_info_pb;
        // Enregistrement du détail
        $fichier_nom = 'session_anomalie_'.$_SESSION['BASE'].'_'.$_SESSION['SESSION_ID'].'.txt';
        $fichier_contenu = 'Appel anormal : '.$msg_pb.'.'."\r\n\r\n".'Avant : '.$avant."\r\n".'Après : '.$apres."\r\n";
        FileSystem::ecrire_fichier( CHEMIN_DOSSIER_EXPORT.$fichier_nom , $fichier_contenu );
        // Game over
        Session::destroy__open_new__init( TRUE /*memo_GET*/ );
        Session::exit_sauf_SSO('Appel anormal : '.$msg_pb.' (<a href="'.URL_DIR_EXPORT.$fichier_nom.'" target="_blank" rel="noopener noreferrer">détail</a>).');
      }
      else
      {
        // 2.4. Session retrouvée, utilisateur identifié
        if(Session::verifier_droit_acces($_SESSION['USER_PROFIL_TYPE']))
        {
          // 2.4.1. Espace identifié => Espace identifié identique : RAS
        }
        elseif(Session::verifier_droit_acces('public'))
        {
          // 2.4.2. Espace identifié => Espace non identifié : création d’une nouvelle session vierge, pas de message d’alerte pour indiquer que la session est perdue
          // A un moment il fallait tester que ce n’était pas un appel ajax, pour éviter une déconnexion si appel au calendrier qui était dans l’espace public, mais ce n’est plus le cas...
          Session::destroy__open_new__init( FALSE /*memo_GET*/ );
        }
        elseif(!Session::verifier_droit_acces('public')) // (forcément)
        {
          // 2.4.3. Espace identifié => Autre espace identifié incompatible : redirection pour une nouvelle identification
          // Pas de redirection SSO sinon on tourne en boucle (il faudrait faire une déconnexion SSO préalable).
          Session::destroy__open_new__init( FALSE /*memo_GET*/ ); // FALSE car sinon on peut tourner en boucle (toujours redirigé vers une page qui ne correspond pas au profil utilisé)
          Session::exit_sauf_SSO('Appel incompatible avec votre identification actuelle.');
        }
      }
    }
  }

  // //////////////////////////////////////////////////
  // Fermeture de session (mais pas destruction, juste écriture et libération des données pour éviter un verrouillage en écriture)
  // //////////////////////////////////////////////////

  /*
   * Tableau avec les pages appelées par ajax.php pour lesquelles on a encore besoin d’écrire en session
   * Remarque : on choisit cette méthode car c’est la liste la plus restreinte :)
   */
  private static $tab_ajax_avec_ecriture = array
  (
    'maj_base_complementaire', // Attention, il ne s’agit pas d’une vraie page, mais cette situation existe
    'administrateur_administrateur',
    'administrateur_dump',
    'administrateur_etabl_algorithme',
    'administrateur_etabl_autorisations',
    'administrateur_etabl_connexion',
    'administrateur_etabl_deconnexion',
    'administrateur_etabl_duree_inactivite',
    'administrateur_etabl_identite',
    'administrateur_etabl_login',
    'administrateur_etabl_notes_acquis',
    'administrateur_fichier_identifiant',
    'administrateur_fichier_user',
    'administrateur_interfacage_omogen',
    'administrateur_nettoyage',
    'administrateur_user_recherche',
    'compte_accueil',
    'compte_cnil',
    'compte_api',
    'compte_daltonisme',
    'compte_email',
    'compte_langue',
    'compte_menus_raccourcis',
    'compte_switch',
    'evaluation_crcn',       // appel à Outil::verif_eleves_prof()
    'evaluation_gestion',    // appel à Outil::verif_eleves_prof() (mais pas seulement)
    'evaluation_ponctuelle', // appel à Outil::verif_eleves_prof()
    'evaluation_module_externe',
    'evaluation_voir',
    'export_fichier',
    'livret_ap',
    'livret_epi',
    'livret_parcours',
    'livret_edition',   // Appel placé dans les fichiers code_livret_*.php ; attention aussi aux appels à Outil::recuperer_seuils_livret()
    'livret_export',    // Appel placé dans les fichiers code_livret_*.php ; attention aussi aux appels à Outil::recuperer_seuils_livret()
    'officiel_accueil', // Appel placé dans les fichiers code_officiel_*.php
    'officiel_reglages_configuration',
    'officiel_reglages_mise_en_page',
    'officiel_reglages_voir_archives',
    'partenaire_parametrages',
    'professeur_module_externe',
    'professeur_plan_classe', // appel à Outil::verif_eleves_prof()
    'public_accueil',
    'public_contact_admin',
    'public_identifiants_perdus',
    'public_stop_mail',
    'releve_bilan_chronologique', // Appel placé dans le fichier ajax inclus
    'releve_graphique',           // Appel placé dans le fichier ajax inclus
    'releve_grille_referentiel',  // Appel placé dans le fichier ajax inclus + appel à Outil::verif_eleves_prof()
    'releve_items',               // Appel placé dans le fichier ajax inclus + appel à Outil::verif_eleves_prof()
    'releve_recherche',           // Appel placé dans le fichier ajax inclus + appel à Outil::verif_eleves_prof()
    'releve_socle2016',           // Appel placé dans le fichier ajax inclus + appel à Outil::verif_eleves_prof()
    'releve_synthese',            // Appel placé dans le fichier ajax inclus + appel à Outil::verif_eleves_prof()
    'webmestre_identite_installation',
    'webmestre_maintenance',
    'webmestre_structure_transfert',
  );

  /*
   * Écrit les données de session et la ferme
   * 
   * @see http://fr.php.net/manual/fr/function.session-write-close.php
   * @see https://github.com/php-memcached-dev/php-memcached/issues/269#issuecomment-271092621
   * 
   * Part d’un bon principe, mais très délicat à mettre en place.
   * Car il est difficile de savoir si on n’aura plus besoin d’écrire en session, les scripts s’incluent les uns les autres, peuvent être modifiés ensuite...
   * C’est comme fermer la connexion à la base de données avant la fin du script pour y limiter le nb de connexion simultanées... j’en ai de mauvais souvenirs.
   * 
   * Enfin, l’analyse des logs d’accès suite à des erreurs "session_start(): Unable to clear session lock record" indique qu’il s’agit d’appels multiples "GET /sacoche/?sso=xxx HTTP/1.1"
   * Et dans ce cas c’est la librairie phpCAS (ou simpleSAMLphp) qui gère les allers-retours...
   * 
   * Du coup, seulement utilisé avec parcimonie et sans pouvoir espérer un impact important...
   * 
   * @param string $page   Ne rien passer pour forcer la fermeture
   * @return void
   */
  public static function write_close($page=NULL)
  {
    if( is_null($page) || !in_array($page,Session::$tab_ajax_avec_ecriture) )
    {
      session_write_close();
    }
  }

  // //////////////////////////////////////////////////
  // CSRF
  //
  // @see http://fr.wikipedia.org/wiki/Cross-site_request_forgery
  // @see http://www.siteduzero.com/tutoriel-3-157576-securisation-des-failles-csrf.html
  // //////////////////////////////////////////////////

  // //////////////////////////////////////////////////
  // Attributs
  // //////////////////////////////////////////////////

  /*
   * Tableau avec les pages pour lesquelles une vérification de jeton n’est pas effectuée lors d’un appel AJAX pour contrer les attaques de type CSRF
   * Remarque : plutôt que de lister les pages qui en ont besoin, on liste les pages qui n’en ont pas besoin, car leur liste est plus restreinte :)
   */
  private static $tab_sans_verif_csrf = array
  (
    // appel depuis plusieurs pages + pas de vérif utile
    '_load_arborescence',
    '_maj_listing_professeurs',
    '_maj_select_directeurs',
    '_maj_select_eleves',
    '_maj_select_eval',
    '_maj_select_groupes_prof',
    '_maj_select_items',
    '_maj_select_livret',
    '_maj_select_matieres',
    '_maj_select_matieres_famille',
    '_maj_select_matieres_prof',
    '_maj_select_niveaux',
    '_maj_select_niveaux_famille',
    '_maj_select_officiel_periode',
    '_maj_select_parents',
    '_maj_select_professeurs',
    '_maj_select_professeurs_directeurs',
    '_maj_select_profs_groupe',
    '_maj_select_structure_origine',
    '_maj_select_structures',
    'calque_date_calendrier',
    'compte_selection_items',
    'conserver_session_active',
    'evaluation_demande_eleve_ajout',
    'evaluation_ponctuelle_prof_ajout',
    'evaluation_ponctuelle_prof_modification',
    'fermer_session',
    'maj_base_complementaire',
    // sans objet (sans besoin d’identification)
    // + si la session a expiré alors elle est réinitialisée de façon transparente lors de l’appel ajax mais forcément le jeton de session n’est pas retrouvé
    // + par ailleurs ces pages testent $_SESSION['FORCEBRUTE'][$PAGE] et affichent un message approprié en cas de manque
    'public_accueil',
    'public_contact_admin',
    'public_identifiants_perdus',
    // sans objet car pas de formulaire
    'force_download',
    'public_nouveau_mdp',
    'public_sso_login',
    'public_sso_logout',
    'releve_html',
    'webservices',
  );

  /*
   * Tableau avec les pages pour lesquelles un ou plusieurs jetons supplémentaires doivent être enregistrés car elles postent vers des pages supplémentaires.
   */
  private static $tab_pages_csrf_multi = array
  (
    'evaluation_demande_professeur' => array( 'evaluation_ponctuelle' ),
    'evaluation_ponctuelle'         => array( 'releve_items' ),
    'evaluation_gestion'            => array( 'calque_voir_photo','compte_catalogue_appreciations' ),
    'livret_edition'                => array( 'calque_voir_photo','compte_catalogue_appreciations' ),
    'livret_liaisons'               => array( 'export_fichier' ),
    'officiel_accueil'              => array( 'calque_voir_photo','compte_catalogue_appreciations' ),
    'releve_bilan_chronologique'    => array( 'calque_voir_photo' ),
    'releve_graphique'              => array( 'calque_voir_photo' ),
  );

  // //////////////////////////////////////////////////
  // Méthodes privées (internes) - Gestion CSRF
  // //////////////////////////////////////////////////

  /*
   * Tester si une page est dispensée de contrôle CSRF
   * 
   * @param string
   * @return bool
   */
  private static function page_avec_jeton_CSRF($page)
  {
    return (in_array($page,Session::$tab_sans_verif_csrf)) ? FALSE : TRUE ;
  }

  // //////////////////////////////////////////////////
  // Méthodes publiques - Gestion CSRF
  // //////////////////////////////////////////////////

  /*
   * Générer un jeton CSRF pour une page donnée (le met en session).
   * Inutile d’essayer de le fixer uniquement sur l’IP ou la Session car pour ce type d’attaque c’est le navigateur de l’utilisateur qui est utilisé.
   * On est donc contraint d’utiliser un élément aléatoire ou indicateur de temps.
   * Pour éviter de fausses alertes si utilisation de plusieurs onglets d’une même page, on ne retient pas qu’un seul jeton par page, mais un par affichage de page.
   * La session doit être ouverte.
   * 
   * @param string $page
   * @return void
   */
  public static function generer_jeton_anti_CSRF($page)
  {
    if(Session::page_avec_jeton_CSRF($page))
    {
      // Valeur qui sera écrite dans la page pour que javascript l’envoie
      Session::$_CSRF_value = uniqid(); 
      // Pour les appels ajax de la page concernée
      Session::_set( 'CSRF' , Session::$_CSRF_value.'.'.$page , TRUE );
      // Pour d’éventuels appels vers des pages complémentaires
      if(isset(Session::$tab_pages_csrf_multi[$page]))
      {
        foreach(Session::$tab_pages_csrf_multi[$page] as $autre_page)
        {
          Session::_set( 'CSRF' , Session::$_CSRF_value.'.'.$autre_page , TRUE );
        }
      }
    }
  }

  /**
   * Appelé par ajax.php pour vérifier un jeton CSRF lors d’un appel ajax (soumission de données) d’une page donnée (vérifie sa valeur en session, quitte si pb).
   * Peut être aussi potentiellement appelé par de rares pages PHP s’envoyant un formulaire sans passer par AJAX (seule officiel_accueil.php est concernée au 10/2012).
   * On utilise REQUEST car c’est tranmis en POST si ajax maison mais en GET si utilisation de jquery.form.js.
   * On teste aussi la présence de données en POST car s’il n’y en a pas alors :
   * - ce peut être à cause de l’upload d’un trop gros fichier qui fait que les variables postées n’arrivent pas
   * - dans ce cas, il n’y a pas vraiment de risque CSRF, puisque aucune (mauvaise) donnée postée n’est traitée
   * La session doit être ouverte.
   *
   * @param string $page
   * @return void
   */
  public static function verifier_jeton_anti_CSRF($page)
  {
    if(Session::page_avec_jeton_CSRF($page))
    {
      if( ( empty($_REQUEST['csrf']) || empty($_SESSION['CSRF'][$_REQUEST['csrf'].'.'.$page]) ) && !empty($_POST) )
      {
        $explication = (substr($page,0,7)!='public_') ? 'Plusieurs onglets ouverts avec des sessions incompatibles ?' : 'Session perdue ?' ;
        exit_error( 'Alerte CSRF' /*titre*/ , 'Jeton CSRF invalide.<br>'.$explication /*contenu*/ );
      }
    }
  }

}
?>