Destinataires : Tout le personnel du CATI SICPA Date de révision : Juillet 2020

Gé né rér ét utilisér lés tokéns JWT én PHP

Avant de commencer Cette documentation fait suite à la présentation faite par Alexandre autour de la génération de tokens via JWT pour sécuriser nos webservices et authentifier de manière plus fiable nos utilisateurs. Alexandre a mis en place une API REST sur Sicpa-Interop, API qui permet de générer et de vérifier des tokens. Cette API se base sur l’authentification WebSSO et, après 3 jours de tests, je n’ai jamais réussi à générer un token à cause de ce webSSO qui aspire toutes les requêtes que je passe tel un trou noir. Après discussion avec Alexandre, j’ai choisi de générer le token en PHP et de le vérifier via son API. ’est ce que je vais vous présenter dans cette courte documentation.

Table des matières Avant de commencer ...... 1 1. La classe jwt.class. ...... 2 2. Les méthodes que j’ai écrites ...... 2 3. Génération de token JWT ...... 2 4. Verification du token JWT ...... 3 5. Validation du token JWT via l’API « SicpaAuthentificationServeur » ...... 3 Annexe 1 : jwt.class.php ...... 4 Annexe 2 : getToken($ldap, $key, $exp=null) ...... 9 Annexe 3 : checkToken($token, $key) ...... 9 Annexe 4 : isTokenOK($, $ldap, $token) ...... 10

Auteur : Thierry HEIRMAN Page 1 1. La classe jwt.class.php

Pour générer et vérifier un token JWT, je vais me baser sur la classe jwt.class.php que j’ai trouvé en parcourant le site jwt.io. Vous trouverez le code de cette classe en annexe de ce document.

2. Les méthodes que j’ai écrites

A partir de la classe jwt.class.php, j’ai écrit 3 méthodes. Pour ceux qui ont suivi la présentation d’Alexandre en avril 2020, vous remarquerez que j’ai été très original sur l’appellation de mes méthodes :

- getToken($ldap, $key, $exp=null) : cette méthode permet de générer et de retourner un token JWT - checkToken($token, $key) : cette méthode permet de vérifier localement la validité d’un token JWT - isTokenOK($url, $ldap, $token) : cette méthode permet de vérifier la validité d’un token JWT à travers l’API REST mise en place par Alexandre

Vous trouverez également le code de ces méthodes en annexe de ce document.

3. Génération de token JWT

A partir de la classe et des méthodes que j’ai écrites, il est très simple de générer son propre token en PHP. Dans l’exemple suivant, en une ligne, je le génère et je le stocke en variable de session. if($_POST) { if(isset($_POST['txtAuthLogin']) && isset($_POST['txtAuthPassword'])) { $sUtilisateur = $_POST['txtAuthLogin']; $sMotPasse = $_POST['txtAuthPassword'];

//je teste si les identifiants sont non vides if( empty($sUtilisateur) || empty($sMotPasse) ) { $_SESSION['MessageErreur'][] = ERR_CHAMP_VIDE; return; }

//je effectue l’authentification de l’utilisateur via le LDAP $ldap = AuthentificationLDAP($sUtilisateur, $sMotPasse);

//si l’authentification LDAP réussi if( !is_null($ldap) && $ldap->retour->etat == "OK" ) { //je récupère les informations de l’utilisateur $personne = $ldap->retour->personne;

//on enregistre l'utilisateur $_SESSION['utilUID'] = $sUtilisateur; $_SESSION['utilCN'] = $personne->cn; $_SESSION['utilMail'] = strtolower( $personne->mail ); $_SESSION['timeout'] = time() + LIFETIME; //on vérifie que l'utilisateur existe dans la base de données locale

Auteur : Thierry HEIRMAN Page 2 $objUtilisateur = LireUtilisateur($sUtilisateur);

if(is_null($objUtilisateur)) $_SESSION['MessageErreur'][] = ERR_UTILISATEUR_INCONNU; else { //je récupère les informations de l’utilisateur depuis la BD $_SESSION['utilADM'] = $objUtilisateur->utilisateur_is_admin; $_SESSION['utilconnecte'] = $objUtilisateur->login; $_SESSION['utilID'] = $objUtilisateur->utilisateur_id; //je génère mon token et je le stocke en variable de session $_SESSION['token'] = getToken($sUtilisateur, SICPA_KEY);

//puis j’effectue la suite de mes traitements (qui nous interessent peu ici) … … }

4. Verification du token JWT

En utilisant la méthode « checkToken », on va pouvoir vérifier que notre token est conforme à la RFC7519

$key = "ceci_est_ma_clé_privée" $token = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzM4NCJ9. eyJsZGFwIjoidGhlaXJtYW4iLCJpYXQiOjE1OTM2MTI2NzUsImV4cCI6MTU5MzY0MDgwMH0. aX6IZIdp0dkkDMLV29bu8SA8pJHFSezWAbIfJBSqdu8k70N5oTg1QnNB5UBtoI8o

$verification = checkToken($token, $key); echo $verification;

5. Validation du token JWT via l’API « SicpaAuthentificationServeur »

En invoquant la méthode « isTokenOK », on va pouvoir vérifier que l’utilisateur authentifié est bien autorisé à utiliser nos webservices.

$ldap = "theirman" $url = "https://sicpa-interop.inra.fr:443/SicpaAuthentificationServeur/rest/tokenSicpa/isTokenOK ?ldap={ldap}&token={token}"; $token = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzM4NCJ9.eyJsZGFwIjoidGhlaXJtYW4iLCJpYXQiOjE1OTM2MTI2NzUsImV4cCI6M TU5MzY0MDgwMH0.aX6IZIdp0dkkDMLV29bu8SA8pJHFSezWAbIfJBSqdu8k70N5oTg1QnNB5UBtoI8o

$validation = isTokenOK($url, $ldap, $token); echo $validation;

Auteur : Thierry HEIRMAN Page 3 Annexe 1 : jwt.class.php

/** * JSON Web Token implementation, based on this spec: * https://tools.ietf.org/html/rfc7519 * * This class library is based on original Firebase/JWT source code written by * Neuman Vong and Anant Narayanan found here: https://github.com/firebase/php-jwt * * @license http://opensource.org/licenses/BSD-3-Clause 3-clause BSD * */ class JWT {

public static $leeway = 0; // allows for nbf, iat or exp clock skew public static $timestamp = null; // allow timestamp to be specified for testing. Defaults to php (time) if null. public static $supported_algs = array( 'HS256' => array('hash_hmac', 'SHA256'), 'HS512' => array('hash_hmac', 'SHA512'), 'HS384' => array('hash_hmac', 'SHA384'), 'RS256' => array('openssl', 'SHA256'), 'RS384' => array('openssl', 'SHA384'), 'RS512' => array('openssl', 'SHA512'), ); /** ------* Decodes a JWT string into a PHP object. * * @param string $token The JSON web token * @param string|array $key The secret key * @param array $allowed_algs If the algorithm used is asymmetric, this is the public key list * of supported verification algorithms. Supported algorithms are: * 'HS256', 'HS384', 'HS512' and 'RS256' * * @return object The JWT's payload as a PHP object * */ public static function decode($token, $key, array $allowed_algs = array()) { if ((!isset($timestamp)) || (is_null($timestamp))) { $timestamp = time(); }

if (empty($key)) { throw new Exception('Invalid or missing key.'); }

$tokenSegments = explode('.', $token);

if (count($tokenSegments) != 3) { throw new Exception('Wrong number of segments'); }

list($headb64, $bodyb64, $cryptob64) = $tokenSegments; if (null === ($header = static::jsonDecode(static::urlsafeB64Decode($headb64)))) { throw new Exception('Invalid header encoding'); }

if (null === $payload = static::jsonDecode(static::urlsafeB64Decode($bodyb64))) { throw new Exception('Invalid claims encoding'); }

if (false === ($sig = static::urlsafeB64Decode($cryptob64))) { throw new Exception('Invalid signature encoding'); }

Auteur : Thierry HEIRMAN Page 4

if (empty($header->alg)) { throw new Exception('Empty algorithm'); }

if (empty(static::$supported_algs[$header->alg])) { throw new Exception('Algorithm not supported'); }

if (!in_array($header->alg, $allowed_algs)) { throw new Exception('Algorithm not allowed'); }

if (is_array($key) || $key instanceof ArrayAccess) { if (isset($header->kid)) { if (!isset($key[$header->kid])) { throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key'); } $key = $key[$header->kid]; } else { throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); } }

// Check the signature if (!static::verify("$headb64.$bodyb64", $sig, $key, $header->alg)) { throw new Exception('Signature verification failed'); }

// Check if the nbf if it is defined. This is the time that the // token can actually be used. If it's not yet that time, abort. if (isset($payload->nbf) && $payload->nbf > ($timestamp + static::$leeway)) { throw new Exception( 'Cannot handle token prior to ' . date(DateTime::ISO8601, $payload->nbf) ); }

// Check that this token has been created before 'now'. This prevents // using tokens that have been created for later use (and haven't // correctly used the nbf claim). if (isset($payload->iat) && $payload->iat > ($timestamp + static::$leeway)) { throw new Exception( 'Cannot handle token prior to ' . date(DateTime::ISO8601, $payload->iat) ); }

// Check if this token has expired. if (isset($payload->exp) && ($timestamp - static::$leeway) >= $payload->exp) { throw new Exception('Expired token'); }

return $payload; } /** ------* Converts and signs a PHP object or array into a JWT string. * * @param object|array $payload PHP object or array * @param string $key The secret key. * If the algorithm used is asymmetric, this is the private key * @param string $alg The signing algorithm. * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256' * @param mixed $keyId * @param array $head An array with header elements to attach * * @return string A signed JWT * */ public static function encode($payload, $key, $alg = 'HS256', $keyId = null, $head = null)

Auteur : Thierry HEIRMAN Page 5 { $header = array('typ' => 'JWT', 'alg' => $alg);

if ($keyId !== null) { $header['kid'] = $keyId; }

if ( isset($head) && is_array($head) ) { $header = array_merge($head, $header); }

$segments = array(); $segments[] = static::urlsafeB64Encode(static::jsonEncode($header)); $segments[] = static::urlsafeB64Encode(static::jsonEncode($payload)); $signing_input = implode('.', $segments); $signature = static::sign($signing_input, $key, $alg); $segments[] = static::urlsafeB64Encode($signature);

return implode('.', $segments); } /** ------* Sign a string with a given key and algorithm. * * @param string $msg The message to sign * @param string|resource $key The secret key * @param string $alg The signing algorithm. * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256' * * @return string An encrypted message * */ public static function sign($msg, $key, $alg = 'HS256') { if (empty(static::$supported_algs[$alg])) { throw new Exception('Algorithm not supported'); } list($function, $algorithm) = static::$supported_algs[$alg]; switch($function) { case 'hash_hmac': return hash_hmac($algorithm, $msg, $key, true); case 'openssl': $signature = ''; $success = openssl_sign($msg, $signature, $key, $algorithm); if (!$success) { throw new Exception("OpenSSL unable to sign data"); } else { return $signature; } } } /** ------* Verify a signature with the message, key and method. Not all methods * are symmetric, so we must have a separate verify and sign method. * * @param string $msg The original message (header and body) * @param string $signature The original signature * @param string|resource $key For HS*, a string key works. for RS*, must be a resource of an openssl public key * @param string $alg The algorithm * * @return bool */ private static function verify($msg, $signature, $key, $alg) { if (empty(static::$supported_algs[$alg])) { throw new Exception('Algorithm not supported'); } list($function, $algorithm) = static::$supported_algs[$alg];

Auteur : Thierry HEIRMAN Page 6 switch($function) { case 'openssl': $success = openssl_verify($msg, $signature, $key, $algorithm); if ($success === 1) { return true; } elseif ($success === 0) { return false; } // returns 1 on success, 0 on failure, -1 on error. throw new Exception( 'OpenSSL error: ' . openssl_error_string() ); case 'hash_hmac': default: $hash = hash_hmac($algorithm, $msg, $key, true); if (function_exists('hash_equals')) { return hash_equals($signature, $hash); } $len = min(static::safeStrlen($signature), static::safeStrlen($hash)); $status = 0; for ($i = 0; $i < $len; $i++) { $status |= (ord($signature[$i]) ^ ord($hash[$i])); } $status |= (static::safeStrlen($signature) ^ static::safeStrlen($hash)); return ($status === 0); } } /** ------* Decode a JSON string into a PHP object. * * @param string $input JSON string * * @return object Object representation of JSON string * * @throws Exception Provided string was invalid JSON */ public static function jsonDecode($input) { if (version_compare(PHP_VERSION, '5.4.0', '>=') && !(defined('JSON_C_VERSION') && PHP_INT_SIZE > 4)) { /** In PHP >=5.4.0, json_decode() accepts an options parameter, that allows you * to specify that large ints (like Steam Transaction IDs) should be treated as * strings, rather than the PHP default behaviour of converting them to floats. */ $obj = json_decode($input, false, 512, JSON_BIGINT_AS_STRING); } else { /** Not all servers will support that, however, so for older versions we must * manually detect large ints in the JSON string and quote them (thus converting *them to strings) before decoding, hence the preg_replace() call. */ $max_int_length = strlen((string) PHP_INT_MAX) - 1; $json_without_bigints = preg_replace('/:\s*(-?\d{'.$max_int_length.',})/', ': "$1"', $input); $obj = json_decode($json_without_bigints); } if (function_exists('json_last_error') && $errno = json_last_error()) { static::handleJsonError($errno); } elseif ($obj === null && $input !== 'null') { throw new Exception('Null result with non-null input'); } return $obj; } /** ------* Encode a PHP object into a JSON string. * * @param object|array $input A PHP object or array * * @return string JSON representation of the PHP object or array * * @throws Exception Provided object could not be encoded to valid JSON

Auteur : Thierry HEIRMAN Page 7 */ public static function jsonEncode($input) { $ = json_encode($input); if (function_exists('json_last_error') && $errno = json_last_error()) { static::handleJsonError($errno); } elseif ($json === 'null' && $input !== null) { throw new Exception('Null result with non-null input'); } return $json; } /** ------* Decode a string with URL-safe . * * @param string $input A Base64 encoded string * * @return string A decoded string */ public static function urlsafeB64Decode($input) { $remainder = strlen($input) % 4; if ($remainder) { $padlen = 4 - $remainder; $input .= str_repeat('=', $padlen); } return base64_decode(strtr($input, '-_', '+/')); } /** ------* Encode a string with URL-safe Base64. * * @param string $input The string you want encoded * * @return string The base64 encode of what you passed in */ public static function urlsafeB64Encode($input) { return str_replace('=', '', strtr(base64_encode($input), '+/', '-_')); } /** ------* Helper method to create a JSON error. * * @param int $errno An error number from json_last_error() * * @return void */ private static function handleJsonError($errno) { $messages = array( JSON_ERROR_DEPTH => 'Maximum stack depth exceeded', JSON_ERROR_STATE_MISMATCH => 'Invalid or malformed JSON', JSON_ERROR_CTRL_CHAR => 'Unexpected control character found', JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON', JSON_ERROR_UTF8 => 'Malformed UTF-8 characters' //PHP >= 5.3.3 ); throw new Exception( isset($messages[$errno]) ? $messages[$errno] : 'Unknown JSON error: ' . $errno ); } /** ------* Get the number of bytes in cryptographic strings. * * @param string * * @return int */ private static function safeStrlen($str)

Auteur : Thierry HEIRMAN Page 8 { if (function_exists('mb_strlen')) { return mb_strlen($str, '8bit'); } return strlen($str); } }

Annexe 2 : getToken($ldap, $key, $exp=null) function getToken($ldap, $key, $exp=null) { //si le ldap et la clé ne sont pas fournis, j'abandonne la génération du token if(empty($ldap) || empty($key)) return false;

//je déclare le 'IssuedAt' $iat = strtotime(date("Y-m-d H:i:s"));

//si le 'Expire' n'est pas précisé, je l'initialise au début de la journée suivante if(is_null($exp) || empty($exp) || !is_int($exp)) { $exp = new DateTime(date("Y-m-d 00:00:00")); $exp = $exp->add(new DateInterval('P1D'))->getTimestamp(); }

//j'initialise le tableau qui contiendra le payload $payload = array();

//je peuple le tableau du payload $payload['ldap'] = $ldap; $payload['iat'] = $iat; $payload['exp'] = $exp;

//je retourne le token JWT return JWT::encode($payload, $key, "HS384"); }

Annexe 3 : checkToken($token, $key) function checkToken($token, $key) { //si le token/ou et la clé ne sont pas fournis, j'abandonne la vérification du token if(empty($token) || empty($key)) return false;

//je récupère l'instant présent $now = new DateTime(date(DateTime::ISO8601));

try { //je décode mon token $payload = JWT::decode($token, $key, array('HS384'));

//je vérifie que le payload contienne le ldap de l'utilisateur if(!isset($payload->ldap)) return false;

//je vérifie que la date d'expiration du token ne soit pas dépassée if(isset($payload->exp)) {

Auteur : Thierry HEIRMAN Page 9 $exp = new DateTime(date(DateTime::ISO8601, $payload->exp));

if($exp < $now) return false; }

//je vérifie que la date d'émission du token soit inférieure à 24 heures //si aucune date d'expiration n'est précisée if(!isset($payload->exp) && isset($payload->iat)) { $iat = new DateTime(date(DateTime::ISO8601, $payload->iat)); $exp = new DateTime(date(DateTime::ISO8601, $payload->iat)); $exp->add(new DateInterval('P1D'));

if($iat > $now || $exp < $now) return false; }

return true; } catch(Exception $e) { return false; } }

Annexe 4 : isTokenOK($url, $ldap, $token) function isTokenOK($url, $ldap, $token) { $url = preg_replace("#{ldap}#", $ldap, $url); $url = preg_replace("#{token}#", $token, $url); return file_get_contents($url); }

Auteur : Thierry HEIRMAN Page 10