Se connecter avec un certificat TLS à son application web
Les mots de passe c'est chiant. Personne ne les aime. A part en informatique, on n'utilise presque jamais des mots de passe. Imaginez ouvrir votre maison ou votre voiture en tapant un mot de passe sur un clavier (ou en déverrouillant un cadenas à code, c'est la même chose) et pensez à tous les problèmes qui peuvent en découler. Voila, les mots de passe c'est nul. Dans la vrai vie on utilise des clés.
Je vais vous parler de l'utilisation des certificats TLS, qui vont se comporter exactement comme des clés, pour vous identifier dans vos application. Nous allons voir comment mettre en place une authentification de ce type avec un cas pratique en utilisant php et nginx sur un serveur linux débian.
Avant de commencer
Autant le dire tout de suite, cet article va être assez technique et probablement un peu long. Mais ne vous inquiétez pas, en suivant bien pas à pas les différentes sections, vous serez en mesure d'implémenter un système d'authentification par certificats TLS dans n'importe quelle application web basée sur PHP.
Nous n'utiliserons ici que des fonctions disponible en PHP "vanilla". Seules des connaissances basiques en administration système linux et en développement PHP seront donc nécessaires.
Un certificat TLS ? Mais qu'est ce donc ?
Pour simplifier, c'est un fichier qui va permettre de crypter (ou chiffrer) des trucs qui peuvent être aussi bien des connections réseau que des fichiers. Ce fichier peut contenir des informations sur l'entité a qui il appartient, tel qu'un pseudo ou une adresse mail par exemple pour un utilisateur, ou bien un nom de domaine pour un serveur. C'est le cas par exemple des certificats fournis par les sites accessibles en HTTPS.
Pourquoi utiliser un certificat ?
L'intérêt d'un certificat c'est qu'il peut être signé (marqué et validé en quelque sorte) par un autre certificat auquel on fait confiance. Ce qui permettra par la suite que lorsqu'un utilisateur vous présentera son certificat, vous pourrez vérifier s'il a bien été validé par le votre.
En HTTPS il est possible au serveur web de demander au navigateur de lui transmettre le certificat personnel d'un utilisateur pour son domaine.
Ce certificat personnel sera donc à enregistrer dans le navigateur de l'utilisateur et sera transmis automatiquement, à chaque fois qu'il se connectera au serveur correspondant disposant du certificat de confiance.
Nous aurons donc tous les avantages d'une connexion HTTPS, avec en plus :
-
Plus besoin de taper de mot de passe pour s'authentifier.
-
Plus jamais de perte de session. L'authentification est permanente.
-
Plus besoin d'utiliser des sessions PHP coté serveur.
-
Plus besoin d'utiliser des cookies pour conserver l'authentification pendant la navigation.
-
Et la cerise sur le gâteau : Plusieurs certificats peuvent êtres associés à un même utilisateur.
Le dernier point est important. Un utilisateur pourra disposer de plusieurs certificats. Il pourra donc en avoir un sur son PC portable et un autre, différent, sur sont PC de bureau. Dans le cas ou son PC portable serait volé ou compromis, il suffira dans l'application de désactiver la clé correspondante. L'utilisateur pourra donc continuer a se connecter depuis sont PC de bureau tout en empêchant toute connexion depuis son PC portable devenu non légitime.
Nginx et php
Nginx est le serveur web qui va permettre de servir les pages de votre application/site web. C'est lui qui va recevoir les requêtes du navigateur du client. Il va servir les fichier statiques et rediriger les requêtes à destination des fichiers PHP via le processus php-fpm.
Installation
Installer le tout avec apt de manière classique (en root):
apt update
apt install openssl nginx php-fpm
Nginx : configuration
C'est nginx qui va servir de point d'arrivé pour la connexion TLS. Il va donc être en charge de vérifier si le client connecté en HTTPS utilise un certificat approuvé et connu. Si c'est le cas, il va falloir qu'il transmette les informations contenues dans le certificat à PHP. Si ce n'est pas le cas, il doit pouvoir quand meme servir des pages HTTPS et c'est l'application PHP qui se chargera de présenter un mode "déconnecté".
Pour l'exemple, notre site sera accessible à l'adresse https://mon-site-web.net et ses fichiers seront stockés à l'emplacement /srv/www/mon-site-web.net dans la machine qui l'héberge. Si vous voulez tester ca chez vous, pensez à creer une entrée DNS sur votre domaine ou à renseigner dans votre fichier hosts local (présent sur windows et sur linux) l'ip de votre serveur, afin de pouvoir joindre votre site avec son nom de domaine.
Voici comment configurer NGINX (prenez le temps de lire les commentaires):
# Ce bloc sert à rediriger les connexions entrantes HTTP
# vers les memes URL mais en HTTPS
server {
listen 80;
listen [::]:80;
server_name mon-site-web.net;
return 301 https://$host$request_uri;
}
server {
# SSL configuration
listen 443 ssl;
listen [::]:443 ssl;
# Certificat publique et clé privée du serveur.
# Necessaire pour etablir des connexion TLS (HTTPS).
# Peuvent etre générés gratuitement avec let's encrypt par exemple
ssl_certificate /etc/letsencrypt/live/mon-site-web.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mon-site-web.net/privkey.pem;
# Certificat de confiance de ce serveur. Il va servir a vérifier
# que le certificat envoyé par le navigateur client est authentique.
# Il n'y a aucun lien avec le certificat HTTPS ci dessus.
#
# L'option ssl_verify_client permet de demander au client s'il a un certificat valide.
# Le mode "optional" permet au client ne présentant pas de certificat valide, ou n'en possedant
# pas, de quand meme de naviguer en https sur notre site.
#
# Tant qu'on a pas encore générer le certificat de confiance de notre
# application on laisse les deux lignes en commentaire.
#ssl_client_certificate /srv/www/mon-site-web.net/local/certs/server.pem;
#ssl_verify_client optional;
root /srv/www/mon-site-web.net;
index index.php;
server_name mon-site.net;
# On sert les fichiers statiques
location / {
try_files $uri $uri/ =404;
}
#on sert les fichiers dynamiques
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php-fpm.sock;
# /!\ Variables SSL a déclarer absolument pour que Nginx puisse
# extraire les information du certificat client et les transmettre
# à notre application en PHP
fastcgi_param SSL_CLIENT_VERIFY $ssl_client_verify;
fastcgi_param SSL_CLIENT_I_DN $ssl_client_i_dn;
fastcgi_param SSL_CLIENT_S_DN $ssl_client_s_dn;
fastcgi_param SSL_CLIENT_M_SERIAL $ssl_client_serial;
}
}
Cette configuration est à placer dans /etc/nginx/site-enabled/mon-site-web.net.
Redemarrer ensuite nginx :
systemctl restart nginx
PHP
Cette partie va etre un peu plus longue. PHP va devoir gérer les points suivants :
- La génération du certificat de confiance maître utilisé par nginx.
- La base des utilisateurs avec leur niveau de droits eventuel.
- La generation des certificats des utilisateurs.
- La correspondance entre les certificats et les utilisateur.
- La signature (marquage comme valide) des certificats des utilisateurs par le certificat de confiance maître.
Génération du certificat de confiance maître.
Cette action est à effectuer une seule fois. Typiquement lors d'un processus d'installation de votre application/site web. Nous allons generer un certificat publique et une clé secrete qui servira à signer (rendre valide) les certificats des utilisateurs.
Afin d'éviter que la clé secrete ne puisse être lue par tout le monde, nous allons l'enregistrer en tant que variable dans un fichier PHP. De cette manière, même si quelqu'un fait une requete sur le fichier de clé secrete, le fichier sera executé en tant que PHP et n'affichera qu'une page blanche. Cette clé secrete sera en plus sécurisée par un password qu'on enregistrera dans un fichier de config.
Un exemple de code permettant de generer le certificat publique, la clé secrete et son password :
<?php
/**
* Classe gerant l'acces et la sauvegarde de la configuration
*/
class Config{
private static $conf_file = 'local/config.php';
private $conf;
public function __construct(){
if(!file_exists(self::$conf_file)){
$this->conf = [];
return;
}
include(self::$conf_file);
$this->conf = $config;
}
public function get($field){
if(!isset($this->conf[$field])){
return false;
}
return $this->conf[$field];
}
public function set($field, $valeur){
$this->conf[$field] = $valeur;
}
public function save(){
$contenuFicher = '<?php'.PHP_EOL.'$config = '.var_export($this->conf, true).';'.PHP_EOL.'?>';
file_put_contents(self::$conf_file, $contenuFicher, LOCK_EX);
}
}
/**
* Genere un password aléatoire.
*
* @return string Le password généré.
*/
function generate_ca_pass(){
return $string = substr(str_replace(['+', '/', '='], '', base64_encode(random_bytes(32))), 0, 32);
}
/**
* Generation de la paire de clés.
*
*
* @param string $CN FQDN du site. Ex : mon-site-web.net
* @param string $OU Le nom de l'organisation. Ex : mon-super-site ou super-site
* @param string $country Le code du pays en 2 lettres. Ex : FR
*/
function generate_root_ca_keypair($CN, $OU, $country){
$subject = array(
"commonName" => $CN,
"countryName" => $country,
"organizationalUnitName" => $OU,
"organizationName" => $CN,
"stateOrProvinceName" => $OU,
);
// Génère le password de la clé privée.
$ca_password = generate_ca_pass();
//Sauvegarde la clé dans la config.
$config = new Config();
$config->set('cert_pass', $ca_password);
$config->save();
// Génère une nouvelle de clé privée RSA 2048 bits
$private_key = openssl_pkey_new(array(
"private_key_type" => OPENSSL_KEYTYPE_RSA,
"private_key_bits" => 2048
));
// Génère une requête de signature de certificat
$csr = openssl_csr_new($subject, $private_key, array('digest_alg' => 'sha256'));
// Génère un certificat autosigné et l'enregistre dans un fichier
// on indique 1000 ans de validié ($days=365000), histoire d'avoir un peu de marge ^_^
$x509 = openssl_csr_sign($csr, null, $private_key, $days=365000, array('digest_alg' => 'sha256'));
openssl_x509_export_to_file($x509, 'local/certs/server.pem');
//Prépare la clé sous forme exportable en lui assignant le mot de passe généré.
$ecc_private= '';
openssl_pkey_export($private_key, $ecc_private, $ca_password);
//Ecrit la clé privée dans un fichier
$contenuFicher = '<?php'.PHP_EOL.'$privkey=\''.$ecc_private.'\''.PHP_EOL.'?>';
file_put_contents('local/certs/server.key.php', $contenuFicher, LOCK_EX);
}
# ==================== main =========================
#creations des dossiers
if(!file_exists('local')){
mkdir('local');
}
if(!file_exists('local/certs')){
mkdir('local/certs');
}
// Génération du certificat maitre et de sa clé privée.
generate_root_ca_keypair('mon-site-web.net', 'super-site', 'FR');
?>
Collez le code ci dessus dans un fichier (ex : install.php), puis faire une requête vers votre site pour generer la config et certificat racine de confiance. (ex https://mon-site-web.net/install.php).
Prenez garde à n'éxecuter qu'une fois cette requete car à chaque nouvelle execution, un nouveau certificat racine sera générer. Il vous faudra donc supprimer ou trouver un moyen de désactiver ce fichier apres son exacution.
A partir d'ici, vous devez décommenter les lignes concernant ssl_client_certificate et ssl_verify_client dans le fichier de configuration de Nginx. Redemarrer nginx pour prendre en compte la nouvelle configuration et ainsi activer la lecture du certificat client.
Génération du certificat des utilisateur.
Dans cette section nous ne verrons pas comment creer des utilisateurs pour votre site, ni comment utiliser une eventuelle base de donner pour le faire. Le choix vous appartient. Je pars du principe qu'un utilisateur doit disposer d'un identifiant numérique unique dans votre base de données.
Les certificats que nous allons générer pour les utilisateur ne contiendront donc que 2 élément :
- l'ID de l'utilisateur qui devra correspondre à un ID dans votre base de donnée.
- L'ID du certificat lui meme, car un utilisateur doit pouvoir posseder plusieurs certificats. On utilisera le champ x500uid du certificat.
- Optionnel : le nom de l'utilisateur ou son pseudo.
Il vous appartiendra de creer des tables dans votre base de donnée comme ceci :
- Table User : clé primaire id, Pseudo, etc.
- Table Certificat : clé primaire x500uid, clé étrangere id_certificat.
Les certificats des utilisateurs seront générés au format P12. C'est un format pratique qui contient le certificat et la clé privé en un seul fichier, le tout protéger par un mot de passe. Le mot de passe s'utilise seulement la première fois au moment d'importer le certificat dans votre navigateur.
Un exemple de code permettant de generer une certificats pour un utilisateur et lancant un téléchargement pour le récuperer :
<?php
// Attention !!!
// Coller ou inclure ici le code de la classe Config donnée plus haut dans cet article.
// Ca va permettre de récuperer le password du certificat racine pour pouvoir signer le certificat du client.
function generate_user_cert($username, $password, $user_id, $x500uid, $country, $site_CN, $site_OU){
$cacert = "file://certs/server.pem";
include("certs/server.key.php");
$config = new Config();
$ca_pass = $config->get('cert_pass');
$ca_private_key = array($privkey, $ca_pass);
$subject = array(
"commonName" => $username,
"countryName" => $country,
"organizationalUnitName" => $site_OU,
"x500UniqueIdentifier" => $x500uid,
"organizationName" => $site_CN,
"stateOrProvinceName" => $site_OU,
);
// Génère une nouvelle paire de clés privée (et publique)
$private_key = openssl_pkey_new(array(
"private_key_type" => OPENSSL_KEYTYPE_RSA,
"private_key_bits" => 2048
));
$csr = openssl_csr_new($subject, $private_key, array('digest_alg' => 'sha256'));
// Génère une requête de signature de certificat
$configArgs = array("x509_extensions" => "v3_req",'digest_alg' => 'sha256', "basicConstraints" => 'critical,CA:FALSE');
$serial = $user_id;
// on indique 1000 ans de validié ($days=365000), histoire d'avoir un peu de marge ^_^
$x509 = openssl_csr_sign($csr, $cacert, $ca_private_key, $days=365000, $configArgs, $serial);
// Génère un certificat PKCS12 contenant la chaine de validation + le certificat x509 + la clé privée
$p12 = '';
openssl_pkcs12_export($x509, $p12,$private_key, $password);
return $p12;
}
# ==================== main =========================
// A vous de creer un utilisateur dans votre base et de fournir les variables suivantes :
// $username et $user_id
// A vous de creer une entrée certificat valide dans votre base et de fournir la variable $x500uid
// A vous de demander a l'utilisateur quel password il veut pour sont certificat et de fournir la variabre $password.
// Attention : $password ne doit pas etre enregistré dans votre base.
// Il sera intégré dans le certificat et n'est pas necessaire coté serveur pour l'authentification.
$country = 'FR';
$site_CN = 'mon-site-web.net';
$site_OU = 'super-site' ;
$p12 = generate_user_cert($username, $password, $user_id, $x500uid, $country, $site_CN, $site_OU);
// on indique au navigateur client qu'un fichier p12 va lui etre envoyé.
header('Content-Type: application/pkcs-12');
header('Content-Disposition: attachment; filename="'.$username.'.p12";');
// lancement du telechargement du fichier.
echo($p12);
?>
Une fois ce code executé vous devriez avoir récupéré un fichier P12 au nom de l'utilisateur.
Vous allez pouvoir l'integrer à votre navigateur qui le présentera automatiquement à chaque requete vers votre site. Pour installer le certificat dans votre navigateur, aller dans Paramètres ➡️ confidentialité/vie privée et sécurité ➡️ Sécurité ➡️ Voir/Afficher les certificats. Cliquer sur le bouton Importer et entrer votre password. Une fois l'importation terminée, fermer et réouvrir votre navigateur.
Identifier les certificat des utilisateur dans votre application web
Voici un exemple de classe permettant de réaliser l'authentification des utilisateurs.
En plus d'indiquer si l'utilisateur est connecté, vous pouver également leur attribuer un niveau de droit d'acces à votre site (utilisateur, moderateur, admin, etc). Cette classe va etre chargée de récuperer les données décodées et transmises par nginx. Elle les comparera ensuite avec les données utilisateur enregistées en base de donnée.
Dans le code qui va suivre, les portions de code relatives aux classes Archivist et Archivable sont des abstractions perso que j'utilise pour acceder à la base de donnée. Je ne donne pas le code de ces classes ici pour eviter d'alourdir cet article déja très long. A vous de remplacer ces classes par votre propre systeme d'interrogation de base de donnée (PDO, mysql, etc).
<?php
class Identificator{
public static $USER_PERM_ADMIN = 3;
public static $USER_PERM_MODERATOR = 2;
public static $USER_PERM_BASE = 1;
public static $USER_PERM_NOTHING = 0;
public static function get_user_profile(){
$user = [];
if(self::is_user_certified()){
// Utilisation de hexdec : les serials sont en hexadecimal dans les certificat, on doit les reconvertir en decimal.
$user_id = hexdec($_SERVER['SSL_CLIENT_M_SERIAL']);
$dn_list = self::load_user_distinguished_name();
if(!isset($dn_list['x500UniqueIdentifier']) OR $dn_list['x500UniqueIdentifier']==''){
$user['connected']= false;
}else{
$user_found = self::get_user_from_database($user_id);
if($user_found){
$x500uid_list = self::get_user_certificate_x500uid_list_from_database($user_id);
if(in_array($dn_list['x500UniqueIdentifier'], $x500uid_list)){
$user['connected']= true;
$user['id']=(int)$user_id ;
$user['name']=$dn_list['CN'];
$user['x500uid']=$dn_list['x500UniqueIdentifier'];
$user['permission_level']=$user_found->get('permission_level');
}else{
$user['connected']= false;
}
}else{
$user['connected']= false;
}
}
}else{
$user['connected']= false;
}
return $user;
}
private static function is_user_certified(){
if( isset($_SERVER['SSL_CLIENT_VERIFY']) &&
isset($_SERVER['SSL_CLIENT_M_SERIAL']) &&
isset($_SERVER['SSL_CLIENT_S_DN']) &&
$_SERVER['SSL_CLIENT_VERIFY'] == "SUCCESS"
){
return true;
}
return false;
}
private static function load_user_distinguished_name(){
$DN = $_SERVER['SSL_CLIENT_S_DN'];
$dn_pairs = explode(',', $DN);
$dn_list = [];
foreach ($dn_pairs as $pair){
$key_value = explode('=', $pair);
$dn_list[$key_value[0]] = $key_value[1];
}
return $dn_list;
}
/**
* Return the user found in database.
*
* @return Archivable user or false if not found
*/
private static function get_user_from_database($user_id){
$arch = new Archivist();
$user = new Archivable('User');
$user->set('id', (int)$user_id );
$user_found = $arch->restore_first($user);
return $user_found;
}
private static function get_user_certificate_x500uid_list_from_database($user_id){
$x500uid_list = [];
$arch = new Archivist();
$certif_to_find = new Archivable('Certificate');
$certif_to_find->set('id_user', (int)$user_id );
$certifs_found = $arch->restore($certif_to_find);
foreach($certifs_found as $current_certif){
$x500uid_list[] = $current_certif->get('x500uid');
}
return $x500uid_list;
}
}
# ==================== main =========================
/*
A utiliser dans votre application
Les variables associées à $USER sont :
$USER['connected'] : boolean
=> si connected == true
$USER['name'] : String
$USER['id'] : int
$USER['permission_level'] : int
Permission level possible :
- Identifier::$USER_PERM_ADMIN = 3;
- Identifier::$USER_PERM_MODERATOR = 2;
- Identifier::$USER_PERM_BASE = 1;
- Identifier::$USER_PERM_NOTHING = 0;
*/
$USER =Identificator::get_user_profile();
?>
Si vous voulez désactiver le certificat d'un utilisateur, il vous suffit simplement de supprimer en base de donnée l'entrée avec le x500uid et l'id utilisateur correspondant. L'avantage de cette méthode c'est que vous n'aurez pas à gerer des liste de certificats révoqués (CRL).
Conclusion
Et voila, vous venez de créer votre propre infrastructure de gestion des clés, certes rustique, mais autonome et automatisée. Il ne vous reste plus qu'a créer les conditionnelles necessaire pour afficher le bon états de votre application en fonction du niveau d'acces de l'utilisateur.