Aujourd'hui, nous allons réaliser une authentification HTTP directement depuis PHP, sans passer par un système de .htpasswd.
Pour ceux qui ne connaîtraient pas le terme, l'authentification HTTP c'est cette petite fenêtre qui s'affiche pour demander le login / mdp d'une personne et déterminer si elle peut consulter la page.
Habituellement, on réalise cela directement avec un module d'Apache : on indique que tel fichier doit être protégé, avec des options relativement poussées : pour chaque fichier / répertoire, on peut indiquer que l'accès nécessite une authentification, ou plus restrictif l'appartenance à un groupe.

Il s'agit d'une option utile, mais malheureusement un peu limitée : que faire par exemple dans le cas d'un URLRewriting qui fait tout pointer vers un même fichier, du type index.php?Page=PAGEDEMANDÉE ? Il y a bien une option pour cela, mais elle n'est pas accessible depuis les fichiers .htaccess…
Plus intéressant encore, que faire si on a aussi un module de login standard réalisé par un formulaire HTML ? Imaginez un admin qui se connecte depuis l'interface membre, puis veut rejoindre la partie admin du site protégée par identification HTTP : pourquoi devrait-il se reconnecter une nouvelle fois alors qu'il est déjà loggé ?
Enfin, la gestion des utilisateurs imposée par Apache n'est pas facile à maintenir puisqu'elle nécessite un fichier spécial (souvent nommé .htpasswd) : cette solution permet de s'affranchir de cette contrainte et d'utiliser un sytème de login avec une base de données / un service distant / ce qui vous plaît : c'est quand même plus évolutif !

Ici, je vais décrire un système d'authentification relativement basique ; de mon côté j'y ai adjoint un module simulant la gestion des membres et la gestions des accès DIGEST plus sécurisés. Libre à vous de vous renseigner si cela vous intéresse…

Pré-requis

Pour réaliser ce module, il vous faudra bien évidemment un serveur Apache. Il faudra aussi la possibilité de créer un fichier .htaccess et d'utiliser des RewriteRule pour contourner un problème lorsque PHP est installé en tant que script CGI – j'en reparle plus loin.

La théorie

Pour comprendre comment ce module fonctionne, il faut comprendre la théorie derrière.
Lorsqu'un navigateur demande à accéder à une page protégée, le serveur renvoie deux headers spécifiques :

HTTP/1.1 401 Authorization Required
WWW-Authenticate: Basic realm="Pages protégées"

Le premier indique que la page nécessite une autorisation (code HTTP 401), le second indique de quelle façon on doit procéder à l'authentification. Deux méthodes existents : l'authentification Basic – qui comme son nom l'indique, est simple à mettre en œuvre mais non protégée – et l'authentification Digest, plus complexe à réaliser mais plus sécurisée puisque le mot de passe ne transite pas en clair sur le réseau. La partie « realm » indique le texte qui sera affiché à l'utilisateur sur la boîte de login :

Exemple sous Firefox

Une fois ces deux headers envoyés, le serveur renvoie la page qui sera affichée si l'authentification échoue ; dans notre cas nous nous contenterons d'un texte sybillin « authentification échouée ».
De son côté, le navigateur, à la réception de ces headers, affiche une boîte de dialogue de connexion (le style change avec les navigateurs). Une fois ces données envoyées, le navigateur refait une demande pour consulter la page, en y ajoutant un header Authorization qui contient le login et le mot de passe encodé. Attention, encodé ne signifie pas crypté ! Comme je le disais, dans le cas d'une authentification de type Basic, il s'agit simplement de la concaténation du login, du symbole deux points et du mot de passe encodé en base 64 : ainsi, pour un pseudo « monlogin » et un mot de passe « monmdp », la chaîne « monlogin:monmdp » est encodée en base 64 (bW9ubG9naW46bW9ubWRw) puis ajoutée dans le header Authorization.

Retour au serveur maintenant, qui reçoit cette nouvelle demande. Il va la traiter, la comparer à une liste de couple login / mdp, puis l'accepter ou la refuser (le refus passant par le nouvel envoi des deux headers 401 et WWW-Authenticate).

Ce qu'il va falloir faire

Dans le cas qui nous intéresse, nous ne nous soucierons pas du côté navigateur puisque leurs concepteurs ont déjà codé ce côté.
Voici les étapes à faire côté serveur :

  • demander une authentification
  • récupérer le header renvoyé par le navigateur en réponse à notre question
  • le comparer à une liste d'utilisateurs autorisés et agir en conséquence.

Le second point (récupération du header envoyé) ne va pas être si facile : en effet, si PHP est chargé non comme un module Apache mais comme un script CGI (ce qui est le cas dans la plupart des solutions mutualisées), il n'a plus accès aux headers envoyés par le navigateur effectuant la requête. Vous ne me croyez pas ? Et pourtant, testez donc les fonctions getheaders(), headers_list() ou apache_request_headers() : si PHP est installé en script CGI, vous aurez droit à une belle erreur.
Comment contourner ce problème ? Avec le .htaccess pardi ! Car lui est exécuté en tant que module Apache et a donc accès à toutes ces informations. Le plus simple consiste donc à créer une règle de réécriture qui se contente d'ajouter une variable d'environnement contenant la valeur d'Authorization et qui sera elle disponible depuis votre script :

#--------------------------------------------------
#Récupération du Header authenticate pour les pages d'administration
#--------------------------------------------------
RewriteRule ^administrator/(.*) - [E=LOGIN:%{HTTP:Authorization}]

Ici, je n'ajoute cette variable que pour les pages qui commencent par /adminstrator/, mais vous pouvez l'ajouter pour toutes vos pages si cela vous chante. Ensuite, j'indique le nom de la clé (LOGIN) et sa valeur (%{HTTP:Authorization}), qui correspond vous l'aurez deviné si vous avez déjà fait un peu de scripting au contenu du header Authorization.

Bien, nous avons maintenant tout ce qu'il nous faut pour créer la base : faisons donc un module qui demande un login / mdp à l'utilisateur, puis affiche la page une fois ces données entrées (mais sans rien vérifier, cela viendra plus tard).

J'en profite pour faire une note importante : dans mon cas, le fichier .htaccess ne crée pas directement une clé LOGIN dans le tableau $_SERVER, mais une clé REDIRECT_REDIRECT_LOGIN pour des raisons qui me restent inconnues. N'hésitez donc pas à vérifier que votre serveur fonctionne de la même manière, par exemple en consultant le résultat d'un print_r($_SERVER) depuis une page concernée par la règle du .htaccess.

/**
* But : réaliser une authentification HTTP en n'utilisant que PHP (sans passer par le .htaccess de Apache)
* Cette classe nécessite l'adjonction d'une règle dans le fichier .htaccess réglementant l'accès aux pages protégées :
* RewriteRule regexp_pages_protegees - [E=LOGIN:%{HTTP:Authorization}]
* @example
* //Depuis une page qui doit être protégée :
* Authenticate::login()
*
* //Suite du script, si l'authentification a échoué la fonction ne retournera jamais et cette partie ne sera pas exécutée.
*/
//Authenticate

class Authenticate
{
	/**
	* Le nombre d'essais tolérés avant de bannir l'utilisateur.
	* Attention, le ban fonctionne avec le système des sessions et il suffit donc de vider ses cookies pour rebénéficier de trois essais, n'hésitez pas à implémentez un ban par IP si la sécurité vous tient à coeur.
	*/
	const NB_ESSAIS_AVANT_BAN = 3;

	/**
	* Tente de connecter l'utilisateur qui veut voir la page.
	* Utilise une authentification HTTP.
	* @return :boolean true si l'identification a fonctionné ; si elle échoue le script s'arrête.
	*/
	public static function login(array $roles)
	{
		//Est-on connecté ?
		if(empty($_SERVER['REDIRECT_REDIRECT_LOGIN']))
			self::askForLogin();

		//Récupérer les infos de connexion entrées :
		$Login = base64_decode(substr($_SERVER['REDIRECT_REDIRECT_LOGIN'],5));

		$Infos = explode(':',$Login,2);//Limiter à 2 "explode" pour les mots de passe contenant le symbole ":".

		//À ce moment, $Infos est un tableau à deux valeurs : l'index 0 contient le login entré, et l'index 1 le mot de passe en clair.


		//************************************************************************
		//Suite du traitement... dans la suite de l'article !
		//************************************************************************

		//Fin de l'identification !
		return true;
	}

	/**
	* Envoie les headers demandant la connexion du visiteur.
	* Bloque après self::NB_ESSAIS_AVANT_BAN essais infructueux.
	*/
	private static function askForLogin()
	{
		if(!isset($_SESSION['NbEssaiConnexion']))
			$_SESSION['NbEssaiConnexion']=0;

		$_SESSION['NbEssaiConnexion']++;

		if($_SESSION['NbEssaiConnexion'] > self::NB_ESSAIS_AVANT_BAN && 0)
			exit("Bon, soyons franc... t'as aucune idée du mot de passe ! Passe ton chemin, le site a des pages intéressantes même quand on n'est pas admin ;)");
		else
		{
			header('HTTP/1.1 401 Authorization Required');
			header('WWW-Authenticate: Basic realm="Pages protégées"');

			exit("L'accès à ces pages est protégé.");
		}
	}
}

Et pour appeler cette fonction depuis une page à protéger, on utilisera :

Authenticate::login()
//Suite du script, si l'authentification a échoué la fonction ne retournera jamais et cette partie ne sera pas exécutée.

Vérification de l'utilisateur

Notre script est certes fonctionnel, mais son utilité est limitée. Il faut maintenant comparer les valeurs entrées (et temporairement stockées dans $Infos) avec une liste de valeurs pré-stockées. Toutes les solutions se valent : vous pouvez vérifier que l'utilisateur existe dans votre base de données, dans un fichier, sur un site externe… pour ma part, je vais garder la solution d'Apache pour une plus grande compatibilité (je peux rebasculer sur un système standard sans modifier mes utilisateurs).

Nous allons donc créer un fichier qui contient sur chaque ligne un utilisateur, suivi du caractère « : » et du résultat de la fonction crypt() appliquée au mot de passe.

Exemple :

unmembre:$1$clFQ3b8L$x2UCNXH6orWwjjTPBTy901
unautremembre:$1$tbPau6wb$hUBhFBZXC5RgxSUiU8yCf.
encoreunmembre:$1$KOV/gGi8$HnfwhPz.aPT8iUdRIQ4RF1

Petite note sur la fonction crypt()

Il faut préciser que crypt est une fonction à sens unique ; il est strictement impossible de remonter à la valeur originale depuis le résultat. Ainsi, si votre fichier venait à être volé ou hacké, le pirate ne pourrait absolument rien faire des données (sauf en déduire le nom des utilisateurs associés à votre système, ce qui n'est quand même pas négligeable).

Il est intéressant de constater que crypt renvoie un résultat différent à chaque fois. Essayez le code suivant :

echo crypt('lol');//$1$m1Mw4kay$btjntvPUq.sUZdFGyhxUH/
echo crypt('lol');//$1$kIPmctOo$kO82XwbkFa7pNW5QKxRNR0
echo crypt('lol');//$1$VqxN3L6j$pjAhEtlKSpqRAz2bHv/CI.

En fait, c'est parce qu'un grain de sel (salt) est généré à chaque fois pour augmenter l'entropie du résultat et améliorer encore la sécurité. Mais, me direz vous, dans ce cas comment savoir si la valeur entrée par l'utilisateur est correcte puisqu'on ne pourra pas comparer if(crypt($mdp) == $valeur_du_fichier) ? C'est une très bonne question qui m'a moi aussi embêté, avant que je ne découvre (aidé par K-Lu) que le sel utilisé était stocké dans la valeur cryptée, et ce sur les deux premiers caractères. Et ça tombe bien : la fonction crypt() prend un deuxième argument qui permet de forcer l'utilisation d'un sel spécifique, et qui dans l'algorithme qui nous intéresse (chiffrement DES standard) se concentre sur les deux premiers caractères. Autrement dit, notre test sera (accrochez-vous bien !) : if(crypt($mdp,$valeur_du_fichier) == $valeur_du_fichier) : si la valeur entrée cryptée avec le même sel que la valeur en mémoire correspond à la valeur en mémoire, alors le mot de passe est correct. C'est tordu, n'hésitez pas à relire ce paragraphe et la documentation de la fonction… ou à laisser tomber et appliquer bêtement ;)

Cette particularité nous empêche cependant de faire un simple rechercher dans le fichier, il va falloir récupérer tous les couples disponibles et faire le test.

Nous allons donc ajouter une constante à notre classe, qui contiendra l'URL vers le fichier contenant les utilisateurs autorisés :

/**
* Chemin vers le fichier contenant les différents membres, au format apache (i.e. login:mdp_crypté)
* NOTE: Le chemin n'est pas obligatoirement absolu !
* NOTE: Pensez à empêcher le visionnage de ce fichier depuis un navigateur !
*/
const ADMINS="/homepages/htdocs/administrator/.htpasswd";
?>

Puis c'est du code tout bête (à insérer à la suite du premier code, en lieu et place du //Suite du traitement... dans la suite de l'article !) :

//Récupérer tous les utilisateurs dans un tableau ;
$Liste = file(self::ADMINS,FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);

foreach($Liste as $Membre)
{
	if($Membre[0]=='#')
		continue;//Sauter les commentaires

	//Récupérer le couple login / mdp
	$Membre = explode(':',$Membre);
	if($Membre[0]==$Infos[0])
		break;
}

//A-t-il entré le bon mot de passe ?
//Pour cela, on crypte en salant avec le hash en mémoire, et on compare avec ce même hash : les résultats doivent être similaires.
//Pour rappel :
//$Infos[0] = login entré, $Infos[1] = mdp entré ; $Membre[0] = membre du fichier, $Membre[1] = mdp du fichier
if($Membre[0]==$Infos[0] && crypt($Infos[1],$Membre[1])==$Membre[1])
	$_SERVER['REMOTE_USER'] = $Infos[0];//C'est correct, placer une variable retenant le membre.
else
	self::askForLogin();//C'est un échec, redemander l'authentification

Vous aurez noté que pour plus de souplesse, j'ai autorisé les lignes vides et les commentaires (#) dans le fichier, toujours pour rester compatible avec la solution Apache par défaut. De même, en cas de succès je crée une ligne dans le tableau $_SERVER contenant le membre connecté (c'est encore une fois le comportement qu'aurait Apache avec une authentification standard).

Et voilà, nous avons notre module fonctionnel. N'hésitez pas à y adjoindre une gestion des groupes et de l'authentification Digest !

Le code complet :

/**
* But : réaliser une authentification HTTP en n'utilisant que PHP (sans passer par le .htaccess de Apache)
* Cette classe nécessite l'adjonction d'une règle dans le fichier .htaccess réglementant l'accès aux pages protéges :
* RewriteRule regexp_pages_protegees - [E=LOGIN:%{HTTP:Authorization}]
* @example
* //Depuis une page qui doit être protégée :
* Authenticate::login()
*
* //Suite du script, si l'authentification a échoué la fonction ne retournera jamais et cette partie ne sera pas exécutée.
*/
//Authenticate

class Authenticate
{
	/**
	* Chemin vers le fichier contenant les différents membres, au format apache (i.e. login:mdp_crypté)
	*/
	const ADMINS="/homepages/38/d222425658/htdocs/Omnilogie/admin/raw/.admin";

	/**
	* Le nombre d'essais tolérés avant de bannir l'utilisateur.
	* Attention, le ban fonctionne avec le système des sessions et il suffit donc de vider ses cookies pour rebénéficier de trois essais, n'hésitez pas à implémentez un ban par IP si la sécurité vous tient à coeur.
	*/
	const NB_ESSAIS_AVANT_BAN = 3;

	/**
	* Tente de connecter l'utilisateur qui veut voir la page.
	* Utilise une authentification HTTP.
	* @return :boolean true si l'identification a fonctionné ; si elle échoue le script s'arrête.
	*/
	public static function login(array $roles)
	{
		//Est-on connecté ?
		if(empty($_SERVER['REDIRECT_REDIRECT_LOGIN']))
			self::askForLogin();

		//Récupérer les infos de connexion entrées :
		$Login = base64_decode(substr($_SERVER['REDIRECT_REDIRECT_LOGIN'],5));

		$Infos = explode(':',$Login,2);

		//Récupérer le mot de passe de l'utilisateur :
		$Liste = file(self::ADMINS,FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
		foreach($Liste as $Membre)
		{
			if($Membre[0]=='#')
				continue;//Sauter les commentaires

			$Membre = explode(':',$Membre);
			if($Membre[0]==$Infos[0])
				break;
		}

		//A-t-il entré le bon mot de passe ?
		//Pour cela, on crypte en salant avec le hash en mémoire, et on compare avec ce même hash : les résultats doivent être similaires.
		if($Membre[0]==$Infos[0] && crypt($Infos[1],$Membre[1])==$Membre[1])
			$_SERVER['REMOTE_USER'] = $Infos[0];
		else
			self::askForLogin();

		//Fin de l'identification !
		return true;
	}

	/**
	* Envoie les headers demandant la connexion du visiteur.
	* Bloque après self::NB_ESSAIS_AVANT_BAN essais infructueux.
	*/
	private static function askForLogin()
	{
		if(!isset($_SESSION['NbEssaiConnexion']))
			$_SESSION['NbEssaiConnexion']=0;

		$_SESSION['NbEssaiConnexion']++;

		if($_SESSION['NbEssaiConnexion'] > self::NB_ESSAIS_AVANT_BAN && 0)
			exit("Bon, soyons franc... t'as aucune idée du mot de passe ! Passe ton chemin, le site a des pages intéressantes même quand on n'est pas admin ;)");
		else
		{
			header('HTTP/1.1 401 Authorization Required');
			header('WWW-Authenticate: Basic realm="Pages protégées"');

			exit("L'accès à ces pages est protégé.");
		}
	}
}

Bonnes authentifications ;)