Backoffice sécurisée en PHP
[30 mn de lecture - paru le 11/5/2006 6:13:45 PM - Public : Confirmé]
|
   
|
Auteur
1. Sécurisation de la session
1.1. Avant propos
Toutes les backoffices permettent de réaliser un certain nombre d'opérations sur des données personnelles ou partagées. Toutes ces opérations sont basées sur la confiance que porte l'application envers son utilisateur. En effet, une fois un utilisateur identifié, il acquiert auprès de l'application un certain degré de confiance en fonction de son niveau de privilège (administrateur, modérateur, membre, etc..)
Si, au cours de la session de l'utilisateur identifié, son identité est usurpée ou si par certains procédés, l'utilisateur se voit conduit a initier des opérations à son insu, la confiance que l'application porte à l'utilisateur met alors en péril l'intégrité de ses données.
De plus, l'arrivée massive du concept Web 2.0 conduit à l'augmentation des fonctionnalités des applications et de leur interactivité avec les utilisateurs, cela accroît donc dans la foulée le nombre potentiel de failles et d'exploitations possibles.
1.2. Table de la base de données
Dans cet article, nous allons utiliser la table suivante pour gérer nos utilisateurs.
Le champ id est un numéro automatique généré à la création d'utilisateur.
Le champ login est une chaîne de moins de 30 caractères.
Le champ password est une chaîne de 40 caractères fixes car il contiendra les hash SHA1 des passwords des utilisateurs.
Le champ profile définit le niveau de privilège de l'utilisateur et prend les valeurs 'admin' ou 'member'.
CREATE TABLE Users(
id INT NOT NULL PRIMARY KEY AUTO_INCREMENT ,
username VARCHAR( 30 ) ,
password CHAR( 40 ),
email VARCHAR( 100 ),
profile ENUM('admin','member')
);
1.3. Mécanismes de base de la backoffice/session
Nous allons utiliser pour cette application la classe suivante. Toutes les fonctionnalités additionnelles de sécurité viendront se greffer sur cette base :
/*
* Classe Backoffice
* Englobe la gestion des sessions et de l'authentification
*/
class Backoffice
{
/*
* Méthode publique init()
* Vérifie que les entêtes HTTP ne sont pas déjà envoyée et qu'une connexion mysql est ouverte
* Initialise la session PHP
* Prend en charge la soumission du formulaire de login
*/
public function init()
{
if(headers_sent()) throw new Exception("Les entêtes HTTP ont déjà été envoyées", 1);
if(!@mysql_real_escape_string("1")) throw new Exception("Aucune connexion MySQL n'a été ouverte");
// Initialise la session php
session_start();
// Authentifie l'utilisateur si le formulaire a été soumis
if(isset($_POST['login_user']) && isset($_POST['login_pass'])) {
if(!$this->login($_POST['login_user'], $_POST['login_pass'])) throw new Exception("Le nom d'utilisateur ou le mot de passe est incorrect.", 2);
} else if(isset($_GET['deco'])) $this->close();
}
/*
* Méthode privée login()
* Authentifie une utilisateur dans la backoffice à partir du couple username/password
* Valeur de retour : booléen
*/
private function login($username, $password)
{
$req = "SELECT id, email, profile FROM Users WHERE username = '".mysql_real_escape_string($username)."' AND password = '".mysql_real_escape_string(sha1($password))."'";
$sql = mysql_query($req) or die(mysql_error());
$userdata = mysql_fetch_array($sql);
if($userdata === false) return false;
$_SESSION['backoffice_user'] = new User($userdata['id'], $login, $userdata['email'], $userdata['profile']);
return true;
}
/*
* Méthode privée close()
* Ferme la session active */ private function close() { @session_unset(); @session_destroy(); }
/*
* Méthode publique restrictAccess()
* Restreint l'accès aux utilisateurs identifiés dont le rôle fait partie de la liste passée en paramètre
* Argument facultatif $role : chaine ou tableau de chaines contenant les noms des rôles autorisés
* Renvoit des exceptions
*/
public function restrictAccess($profile = null)
{
// Si la variable currentUser de la session est vide, l'utilisateur n'est pas authentifié
if(is_null($_SESSION['backoffice_user'])) throw new Exception("Vous n'êtes pas authentifié", 11);
// Si $role est null, aucun profil spécial n'est requis, on s'arrête là
if(is_null($profile)) return;
// On transforme le role en tableau si ce n'est pas déjà le cas
if(!is_array($profile)) $profile = array($profile);
// Si le role de l'utilisateur authentifié ne fait pas partie de la liste, on renvoit une erreur
if(!in_array($_SESSION['backoffice_user']->profile, $profile)) throw new Exception("Vous n'avez pas les privilèges suffisants", 12);
}
/*
* Méthode publique getUser
* Renvoit un objet User représentant l'utilisateur authentifié (ou null)
*/
public function getUser()
{
return !isset($_SESSION['backoffice_user']) ? null : clone $_SESSION['backoffice_user'];
}
}
?>
Nous utiliserons aussi une class User pour encapsuler les données de nos utilisateurs :
/* * Classe User * Encapsule les différentes informations d'un utilisateur */ class User { public $id; public $name; public $email; public $profile; public function __construct($id, $name, $email, $profile) { $this->id = $id; $this->username = $name; $this->email = $email; $this->profile = $profile; } }
1.4. Connexion sécurisée
La connexion sécurisée permet de rendre impossible l'interception des informations transmises entre le navigateurs des utilisateurs et le serveur de la backoffice. Les informations sensibles sont les mots de passe transférés lors de l'identification et les identifiants de sesssions transférés tout au long de la session.
Voici le code permettant de refuser l'accès si la connexion n'est pas sécurisée ou trop peu sécurisée:
if(!isset($_SERVER['SSL_PROTOCOL']) || !in_array($_SERVER['SSL_PROTOCOL'], Array('SSLv3','TLSv1'))) throw new Exception("La connexion n'est pas ou pas assez sécurisée");
1.5. Limitation du nombre de tentatives de connexions
Limiter le nombre de tentatives de connexion, permet d'éviter les attaques par "brut force". Lorsque le nombre de tentatives est atteint il est possible de bloquer soit l'IP soit le compte de l'utilisateur. Dans cet exemple, nous allons bloquer les adresses IP.
Afin de ne prendre en considération le fait que de nombreuses personnes peuvent être derrière le même proxy, c'est la couple adresse IP (proxy) et adresse IP "forwardée" qui va pris en compte.
Pour gérer ce module, nous allons ajouter une nouvelle table dans la base de données pour garder une trace des accès et de leur nombre.
CREATE TABLE ipaccess (
access_ip VARCHAR(15) NOT NULL,
access_proxy VARCHAR(15) NOT NULL,
access_date DATETIME,
access_nb TINYINT,
PRIMARY KEY (access_ip, access_proxy)
);
Nous créons aussi une nouvelle classe gérant cette fonctionnalité, afin de simplifier son intégration dans notre classe de backoffice :
class Ipaccess
{
private $ip;
private $proxy;
public $nbtry;
public $maxtry;
public $blocktime;
public function __construct($maxtry = 5, $blocktime = 5)
{
$this->proxy = mysql_real_escape_string(isset($_SERVER['HTTP_X_FORWARDED_FOR']) ? $_SERVER['HTTP_X_FORWARDED_FOR'] : '0.0.0.0');
$this->ip = mysql_real_escape_string($_SERVER['REMOTE_ADDR']);
$this->maxtry = $maxtry;
$this->blocktime = $blocktime;
// Nettoie la base des vieux enregistrements
mysql_query('DELETE FROM ipaccess WHERE access_date < (NOW() - INTERVAL '.$this->blocktime.' MINUTE)');
// Récupère le nombre d'accès
$sql = mysql_query("SELECT access_nb FROM ipaccess WHERE access_ip = '{$this->ip}' AND access_proxy = '{$this->proxy}'");
$this->nbtry = ($res = mysql_fetch_array($sql)) ? $res[0] : 0;
}
public function isInRange() {
return $this->nbtry < $this->maxtry;
}
public function increment() {
if($this->nbtry > 0)
mysql_query("UPDATE ipaccess SET access_nb = access_nb + 1, access_date = NOW() WHERE access_ip = '{$this->ip}' AND access_proxy = '{$this->proxy}'");
else
mysql_query("INSERT INTO ipaccess (access_ip, access_proxy, access_date, access_nb)
VALUES ('{$this->ip}', '{$this->proxy}', NOW(), 1)");
$this->nbtry++;
}
public function clean() {
mysql_query("DELETE FROM ipaccess WHERE access_ip = '{$this->ip}' AND access_proxy = '{$this->proxy}'");
}
}
Ainsi, nous pouvons réécrire la portion de code de la méthode Backoffice::init() prenant en charge le login :
if(isset($_POST['login_user']) && isset($_POST['login_pass'])) { $ipacc = new Ipaccess(); if(!$ipacc->isInRange()) throw new Exception("Toutes vos tentatives d'identification ont échouées, vous ne pouvez plus vous identifier pendant ".$ipacc->blocktime." minutes."); if(!$this->login($_POST['login_user'], $_POST['login_pass'])) { $ipacc->increment(); throw new Exception("Le nom d'utilisateur ou le mot de passe est incorrect.<br/>Tentative {$ipacc->nbtry}/{$ipacc->maxtry}", 3); } $ipacc->clean(); }
1.6. Durée de validité de la session
Afin de garantir la sécurité de vos utilisateurs, il convient de limiter la durée des sessions sur les backoffices. PHP ne garde en mémoire les sessions que lors d'un certain laps de temps définit par la directive de configuration session.gc_maxlifetime qui est par défaut de 1440 secondes soit 24 minutes. Ce temps est relativement grand, nous allons donc gérer en interne une limitation plus courte sans modifier la configuration de PHP.
Pour gérer ce paramètre, nous allons utiliser une variable de session stockant le timestamp du dernier accès à l'ouverture de session, dans la méthode Backoffice::login
$_SESSION['backoffice_lastaccess'] = time();
Enfin, dans la méthode Backoffice::restrictAccess, après la vérification de l'existence de la variable de session backoffice_user, nous allons rajouter les lignes suivantes qui vont cloturer la session si celle-ci a expirée.
// Vérifie que la session n'a pas plus de 10 minutes if(time() - $_SESSION['backoffice_lastaccess'] > 600) { $this->close(); throw new Exception("Votre session a expiré", 13); }
1.7. Contrôle du client (ip, navigateur)
Afin d'éviter tout vol de session, nous allons enregistrer à l'ouverture de la session quelques informations sur le client qui ne sont pas censées changer au cours de la connexion. Nous prendrons pour base l'adresse IP, l'adresse du proxy, et le navigateur utilisé.
Pour cela nous allons créer une nouvelle classe qui va nous permettre de stocker puis comparer facilement ces informations.
/* * Classe Client * Encapsule les différentes informations du client */ class Client { public $ip; public $proxy; public $browser;
public function __construct() { $this->browser = $_SERVER['HTTP_USER_AGENT']; $this->ip = $_SERVER['REMOTE_ADDR']; $this->proxy = isset($_SERVER['HTTP_X_FORWARDED_FOR']) ? $_SERVER['HTTP_X_FORWARDED_FOR'] : '0.0.0.0'; } }
Nous allons enregistrer cet objet dans une variable de session dans la méthode Backoffice::login() afin de pouvoir s'y référer tout au long de la session.
$_SESSION['backoffice_client'] = new Client();
Ensuite, nous allons comparer cet objet à celui généré lors de chaque appel de Backoffice::restrictAccess. En cas d'incohérence, nous cloturons la sessions et refusons l'accès au document.
// Vérifie que le client est toujours le même
if($_SESSION['backoffice_client'] != new Client()) {
$this->close();
throw new Exception("Pour des raisons de sécurité, votre session a été cloturée, merci de vous ré-identifier", 14);
}
1.8 Changement de l'identifiant de session
Enfin, nous allons changer l'identifiant de session après l'authentification de l'utilisateur dans le but de rendre inutilisable l'ancien identifiant de session dans le cas ou il ait été intercepté.
Pour cela nous rajoutons le code suivant à la fin de la méthode Backoffice::login()
// Regénération du numéro de session et effacement de la session précédente
session_regenerate_id(true);
|