Les JWT offrent une alternative sans session pour conserver la trace d’une authentification réussie.
Contrairement aux sessions, ou un fichier (par session) est créé sur le serveur, les jetons JWT permettent un fonctionnement du serveur “sans état” (stateless).
Avec session
La session devrait expirer après une certaine période d’inactivité. A chaque requête, la fin de validité peut-être prolongée.
Avec jeton (JWT)
Le serveur ne stocke plus d’état relatif à l’utilisateur :
- Lorsque l’authentification est réussie, le serveur génère un jeton (JWT) contenant les données d’identité (adresse de courriel, nom d’utilisateur…) et le signe (algorithme HMAC) avec un secret qu’il est seul à connaître.
- En réponse, il envoie le jeton au client, par exemple dans un cookie.
A chaque requête HTTP :
- Le client envoie son jeton au serveur.
- Le serveur vérifie la signature du jeton (pour s’assurer qu’il n’a pas été falsifié) et accède aux données qu’il contient (identité…).
Attention :
- Le jeton ne doit contenir aucun secret (il n’est pas chiffré) ; tout le monde peut le lire, mais seul celui qui connait le “mot de passe” peut générer une signature valide qui sera reconnue par le serveur.
- Le mot de passe qui sert à signer les jetons ne doit pas être versionné.
Une stratégie consiste à utiliser une variable d’environnement (définie par
exemple dans un fichier
.htaccess).
Remarque : le jeton peut également être enregistré dans le stockage local
du navigateur, et envoyé en entête (Authorization: Bearer), mais cette solution
requiert l’usage du Javascript.
Une bibliothèque de fonctions contenant la classe utilitaire JWT est mise
à disposition. L’étude de son code permet de comprendre la structure d’un
jeton et le mécanisme de signature / vérification.
<?php
/**
* JSON Web Tokens (JWT) minimalist library
* Frank ENDRES, teacher (first dot last at ac-polynesie.pf) - 2023-12, Education Nationale
* Licence: [CeCILL-C](http://www.cecill.info/licences/Licence_CeCILL-C_V1-en.txt)
*
* Usage:
* - require 'path/to/this/file.php';
*
* provided static methods:
* - JWT::encode(string $secret, string $fieldname, string $value, int $timeout = 600, string $issuer = $_SERVER['SERVER_NAME']) -> string
* - JWT::decode(string $secret, string $jwt) -> ?object (payload with 'exp', 'iss' and 'fieldname' fields)
* - JWT::setcookie(string $secret, string $fieldname, string $value, int $timeout = JWT::TIMEOUT, array $options = []) : void
* - JWT::getcookie(string $secret, string $name) : mixed
* - JWT::delcookie(string $fieldname, array $options = []) : void
**/
class JWT {
const TIMEOUT = 6000; //one hour
const ALGORITHM = 'sha256';
const HEADER = '{"alg":"HS256","typ":"JWT"}';
const COOKIE_OPTIONS = ['secure' => true, 'httponly' => true, 'samesite' => 'Strict'];
/**
* encodes a JWT
* payload fields are "exp", "iss" and the provided fieldname
* @param secret the secret key for HMAC
* @param fieldname the name of the value ('login', 'email', ... for example)
* @param value the value of the fieldname
* @param timeout the JWT timeout
* @param issuer the FQDN of the JWT issuer
* @return the JSON Web Token
**/
static function encode(string $secret, string $fieldname, string $value,
int $timeout = self::TIMEOUT, string $issuer = ''): string {
if ('' == $issuer) {
$issuer = $_SERVER['SERVER_NAME'];
}
$payload = '{"iss":"' . $issuer . '","exp":' . (time() + $timeout) . ',"' . $fieldname . '":"' . $value . '"}';
$b64u_header = rtrim(strtr(base64_encode(self::HEADER), '+/', '-_'), '='); //base64_url
$b64u_payload = rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
$signature = hash_hmac(self::ALGORITHM, $b64u_header . '.' . $b64u_payload, $secret, true);
$b64u_signature = rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
$jwt = $b64u_header . '.' . $b64u_payload . '.' . $b64u_signature;
return $jwt;
}
/**
* decodes a JWT
* @param secret the secret key for HMAC
* @param jwt the JSON Web Token to decode
* @return payload object or null if invalid
**/
static function decode(string $secret, string $jwt) : ?object {
$payload = null;
$parts = explode('.', $jwt, 3);
if (3 == sizeof($parts)) {
$b64u_header = $parts[0];
$b64u_payload = $parts[1];
$b64u_token_signature = $parts[2];
$expected_signature = hash_hmac(self::ALGORITHM, $b64u_header . '.' . $b64u_payload, $secret, true);
$b64u_expected_signature = rtrim(strtr(base64_encode($expected_signature), '+/', '-_'), '=');
if ($b64u_token_signature == $b64u_expected_signature) {
$rest = strlen($b64u_payload) % 4;
$padding = $rest == 0 ? '' : str_repeat('=', 4 - $rest);
$payload = json_decode(base64_decode(strtr($b64u_payload, '-_', '+/') . $padding));
}
}
return $payload;
}
/**
* encodes a JWT and sets it as cookie
* payload fields are "exp", "iss" and the provided fieldname
* @param secret the secret key for HMAC
* @param fieldname the name of the value ('login', 'email', ... for example) and the cookie
* @param value the value of the fieldname
* @param timeout the JWT timeout
* @param options cookie options; defaults to httponly, secure and samesite=strict
**/
static function setcookie(string $secret, string $fieldname, string $value,
int $timeout = self::TIMEOUT, array $options = self::COOKIE_OPTIONS) : void {
$jwt = self::encode($secret, $fieldname, $value, $timeout);
$options['expires'] = time() + $timeout;
setcookie($fieldname, $jwt, $options);
}
/**
* deletes a JWT cookie
* @param fieldname the name of the cookie to delete
* @param options cookie options; defaults to httponly, secure and samesite=strict
**/
static function delcookie(string $fieldname, array $options = self::COOKIE_OPTIONS) : void {
$options['expires'] = time() - 3600;
setcookie($fieldname, '', $options);
}
/**
* retrieves a value from a JWT cookie
* @param secret the secret key for HMAC
* @param name the name of the cookie and payload attribute to return
* @return the payload attribute or null if invalid or expired
**/
static function getcookie(string $secret, string $name) : mixed {
$value = null;
if (isset($_COOKIE[$name])) {
$payload = self::decode($secret, $_COOKIE[$name]);
if (null !== $payload && isset($payload->exp) && $payload->exp > time() && isset($payload->{$name})) {
$value = $payload->{$name};
}
}
return $value;
}
}
?>
L’intérêt de l’encodage base64 (standard JWT) est qu’il permet de stocker des données même binaires.
Le secret
Créer un fichier .htaccess à la racine de l’application (à côté de index.php),
avec le contenu suivant :
SetEnv JWT_SECRET "Votre se<ret lOng et κoμπλεχε."
Avant toute utilisation du secret, il faudra charger cette variable d’environnement :
if (!defined('JWT_SECRET')) define('JWT_SECRET', getenv('JWT_SECRET'));
Repérer les modification à apporter
Le mécanisme d’authentification ne change pas. Ce qui change, c’est l’endroit ou est mémorisé la trace de l’authentification réussie : ce n’est plus dans un fichier de session du serveur, mais dans un jeton JWT (stocké dans les cookies du navigateur).
Reprendre le code de l’activité précédente sur l’authentification et commenter
les session_start() et session_destroy() ainsi que les instructions $_SESSION.
Le challenge
Le challenge peut également être enregistré dans un JWT (en plus d’être mis en champ caché du formulaire) ; c’est important, car il ne faut pas permettre au client de choisir le challenge à signer (possibilité de rejeu) :
JWT::setcookie(JWT_SECRET, 'challenge', $challenge, 300);
Le cookie devrait être supprimé après authentification :
JWT::delcookie('challenge');
Le jeton d’identification
Lorsque l’authentification est validée, un jeton contenant l’adresse de courriel (identifiant l’utilisateur) est émis par le serveur :
JWT::setcookie(JWT_SECRET, 'email', $email, INACTIVITY_TIMEOUT);
Utiliser la même instruction pour raffraîchir le jeton (dans check-auth.php)
La déconnexion s’effectue simplement en détruisant le cookie :
JWT::delcookie('email');
- Qu’est-ce qu’un rejeu. Comment un attaquant pourrait-il rejouer la signature d’un challenge ?
- Décoder manuellement ce jeton JWT (la signature ne peut être vérifiée) :
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ3ZWIuc2lvLmxvY2FsIiwiZXhwIjoxNzc3MzUyMjUzLCJlbWFpbCI6Imx1Y2t5QGx1a2UudXMifQ.-KWzTA0zS5DOfnMjm20-wyALkUofQ2odwhcroHrLVeI