Un jeton dans une URL ? C’est quoi ? Pourquoi ?
Dans un site d’e-commerce, dans un site de contenu, dans des forums, dans des backoffice, etc. vous avez souvent dans les URL, même si elle sont réécrites, un identifiant, ID, qui correspond tout bêtement à la clé primaire utilisée en base de donnée.
Seulement cela pose plusieurs problèmes :
- ça expose une partie de la logique de votre base de donnée : on connaît l’ID et on peut donc deviner en incrémentant d’autres entrées. Gênant dans un backoffice. Surtout s’il y a des failles… Y compris dans une API REST.
- ce n’est pas joli et pas très encourageant pour quelqu’un qui parcourt un site
nouvellement en ligne avec peu de pages, par exemple
foo.com/article/1
,foo.com/article/2
,foo.com/article/3
.
Comment faire alors ?
UUID ? Non. Les chaînes obtenues sont trop longues.
De préférence, il faut créer une chaîne de longueur fixe, de taille acceptable, et en évitant des caractères trop compliqués. Cette chaîne doit être générée aléatoirement et être unique.
On a des exemples concrets ?
Oui. Pleins. Stackoverflow, YouTube, Amazon… et le blog que vous êtes en train de lire.
Alors, comment cela se présente à chaque fois ? Voyons ça de plus près avec des exemples d’URL.
Stackoverflow
Une URL de question sur Stackoverflow ressemble à ça : https://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb. Mais savez-vous que l’URL https://stackoverflow.com/questions/5623838/ est tout aussi valable et redirigera vers l’URL précédente ? Très utile. Maintenant vous le savez. De plus, leur système de jeton permet (à priori) de ne pas connaître le nombre d’éléments en base de données.
YouTube
Le jeton utilisé par Youtube, du moins pour les vidéos, et plus compliqué. Voici un exemple d’URL : https://www.youtube.com/watch?v=WLnz4wJ4DiU. Et encore un autre : https://www.youtube.com/watch?v=c0UsOlqG_Xk
Intéressant non ? Il s’agit en fait d’une version modifiée du codage en base 64. Je vous recommande cette réponse très bien documentée sur StackExchange de Glenn Slayden, qui a fait un remarquable travail à ce sujet.
Amazon
Amazon semble utiliser un numéro de 10 chiffres. Une URL Amazon d’exemple : https://www.amazon.fr/PostgreSQL-Running-Practical-Advanced-Database/dp/1491963417/. Si vous utilisez l’URL https://www.amazon.fr/dp/1491963417/, vous serez redirigé sur la précédente, un peu comme pour Stackoverflow.
Comment réaliser ça en DB ?
Nous allons partir sur une génération aléatoire d’octets, que nous allons ensuite convertir dans une base qui est convenable. On utilisera 3 bases au choix : base64, base 16 et base 10.
L’idéal, c’est que ce jeton se génère sans action de notre part, il faut donc utiliser des déclencheurs (triggers).
Nous verrons comment faire pour PostgreSQL, MySQL et SQLite.
PostgreSQL
Pour Postgresql, j’ai cet article du blog de &yet qui explique bien le procédé, avec script clé en main.
J’ai repris le script pour avoir la possibilité de choisir le type de jeton à générer en passant par deux fonctions au lieux d’une : une fonction générant purement et simpelment le jeton, et une générant un jeton unique pour une requête, destiné à être utilisée dans un déclencheur.
Notez cependant que j’ai dû ajouter une fonction supplémentaire pour palier l’impossibilité de convertir directement une chaîne en hexadécimal vers un entier. J’ai choisi la solution proposée sur le site https://postgres.cz.
Enfin, le code que je vous fournis ici a été testé sur PostgreSQL version 10.6.
Donc, la fonction pour convertir vers des entiers décimaux :
CREATE OR REPLACE FUNCTION hex2dec (TEXT)
RETURNS TEXT as
$$
DECLARE
x numeric;
cnt int;
digit int;
BEGIN
x := 0;
FOR cnt IN 1..length($1) LOOP
EXECUTE E'SELECT x\''||substring($1 FROM cnt FOR 1)|| E'\'::integer' INTO digit;
x := x * 16 + digit ;
END LOOP;
return x::varchar;
END;
$$ language 'plpgsql' immutable;
Maintenant, définissons la fonction créant un jeton :
CREATE OR REPLACE FUNCTION short_token(base TEXT, size INT)
RETURNS TEXT AS $$
DECLARE
token TEXT;
BEGIN
IF base = 'base64' THEN
token := rtrim(encode(gen_random_bytes(size), 'base64'), '=');
token := replace(token, '/', '_');
token := replace(token, '+', '-');
ELSIF base = 'hex' THEN
token := encode(gen_random_bytes(size), 'hex');
ELSIF base = 'dec' THEN
token := hex2dec(encode(gen_random_bytes(size), 'hex'));
ELSE
RAISE EXCEPTION 'Bad value for "base" argument. It must be "base64", "hex" or "dec".';
END IF;
RETURN token;
END;
$$ language 'plpgsql';
Testons là :
# SELECT short_token('base64', 4);
short_token
-------------
GIWfjg
(1 row)
# SELECT short_token('hex', 4);
short_token
-------------
4b9f380f
(1 row)
# SELECT short_token('dec', 4);
short_token
-------------
1023497367
(1 row)
# SELECT short_token('foo', 4);
ERROR: Bad value for "base" argument. It must be "base64", "hex" or "dec".
CONTEXT: PL/pgSQL function short_token(text,integer) line 15 at RAISE
# SELECT short_token('dec', 0);
ERROR: Length not in range
CONTEXT: PL/pgSQL function short_token(text,integer) line 13 at assignment
Parfait, elle donne des résultats cohérents et lève bien des erreurs quand il le faut.
Ensuite, il nous faut une fonction à utiliser dans un contexte de déclencheur, cette fonction vérifie, pour la table à laquelle elle est associée via un trigger, si le jeton généré est déjà utilisé ou non en bouclant jusqu’à ce que le jeton généré n’existe pas déjà en base de données.
CREATE OR REPLACE FUNCTION unique_short_token()
RETURNS TRIGGER AS $$
DECLARE
token TEXT;
query TEXT;
found TEXT;
BEGIN
query := 'SELECT token FROM ' || quote_ident(TG_TABLE_NAME) || ' WHERE token=';
LOOP
token := short_token(CAST(TG_ARGV[0] AS TEXT), CAST(TG_ARGV[1] AS INT));
EXECUTE query || quote_literal(token)
INTO found;
IF found IS NULL THEN
EXIT;
END IF;
END LOOP;
NEW.token = token;
RETURN NEW;
END;
$$ language 'plpgsql';
Il ne vous reste plus qu’à associer cette fonction à des déclencheurs, par exemple :
DROP TABLE IF EXISTS examples;
CREATE TABLE examples (
id SERIAL,
name TEXT,
token TEXT
);
-- on associe la génération de jetons en hexa
DROP TRIGGER IF EXISTS trigger_unique_short_token ON examples;
CREATE TRIGGER trigger_unique_short_token
BEFORE INSERT ON examples
FOR EACH ROW
EXECUTE PROCEDURE unique_short_token('hex', 4);
DROP TABLE IF EXISTS examples2;
CREATE TABLE examples2 (
id SERIAL,
name TEXT,
token TEXT
);
-- on associe la génération de jetons en entiers décimaux
DROP TRIGGER IF EXISTS trigger_unique_short_token ON examples2;
CREATE TRIGGER trigger_unique_short_token
BEFORE INSERT ON examples2
FOR EACH ROW
EXECUTE PROCEDURE unique_short_token('dec', 4);
DROP TABLE IF EXISTS examples3;
CREATE TABLE examples3 (
id SERIAL,
name TEXT,
token TEXT
);
-- on asscoie la génération de jetons en base64
DROP TRIGGER IF EXISTS trigger_unique_short_token ON examples3;
CREATE TRIGGER trigger_unique_short_token
BEFORE INSERT ON examples3
FOR EACH ROW
EXECUTE PROCEDURE unique_short_token('base64', 4);
INSERT INTO examples(name) VALUES ('foo'), ('bar'), ('thing');
INSERT INTO examples2(name) VALUES ('foo'), ('bar'), ('thing');
INSERT INTO examples3(name) VALUES ('foo'), ('bar'), ('thing');
-- on affiche les résultats
SELECT * FROM examples;
SELECT * FROM examples2;
SELECT * FROM examples3;
Et les résultats sont :
id | name | token
----+-------+----------
1 | foo | 8c103451
2 | bar | f412fe71
3 | thing | 6261ea56
(3 rows)
id | name | token
----+-------+------------
1 | foo | 2952238719
2 | bar | 2098431685
3 | thing | 1039318104
(3 rows)
id | name | token
----+-------+--------
1 | foo | 0HgMVw
2 | bar | I0ArEg
3 | thing | kXuttQ
(3 rows)
Vous pouvez télécharger directement le script complet pour PostgreSQL. et le modifier selon vos besoins.
MySQL
On peut choisir la même approche pour MySQL, qui possède un générateur d’octets aléatoires ainsi que des fonctions de conversion en base 64, en binaire vers base 16 et base 10.
On va créer dans un premier temps une fonction, et ensuite créer le déclencheur. L’intéret de passer par une fonction ici est le même que dans le cas de PostgreSQL : pouvoir réutiliser le code dans d’autres cas ou d’autres déclencheurs.
Notez que le code a été testé sur MySQL version 5.7.25.
DROP FUNCTION IF EXISTS short_token;
DELIMITER $$
CREATE FUNCTION short_token(base VARCHAR(6), size INT)
RETURNS VARCHAR(255)
BEGIN
DECLARE token VARCHAR(255);
IF base = "base64" THEN
SELECT
REPLACE(
REPLACE(
TRIM(TRAILING '=' FROM TO_BASE64(RANDOM_BYTES(size))),
'+',
'-'
),
'/',
'_'
)
INTO token;
ELSEIF base = "hex" THEN
SELECT LOWER(HEX(RANDOM_BYTES(size)))
INTO token;
ELSEIF base = "dec" THEN
SELECT CONV(HEX(RANDOM_BYTES(size)), 16, 10)
INTO token;
ELSE
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Bad value for "base" argument. It must be "base64", "hex" or "dec".';
END IF;
RETURN token;
END$$
DELIMITER ;
Bien, comme pour son équivalent vu plus haut pour PostgreSQL, nous allons la tester :
mysql> SELECT short_token('dec', 4);
+-----------------------+
| short_token('dec', 4) |
+-----------------------+
| 4043672793 |
+-----------------------+
1 row in set (0.00 sec)
mysql> SELECT short_token('hex', 4);
+-----------------------+
| short_token('hex', 4) |
+-----------------------+
| c9bb80d2 |
+-----------------------+
1 row in set (0.00 sec)
mysql> SELECT short_token('base64', 4);
+--------------------------+
| short_token('base64', 4) |
+--------------------------+
| FT4GIg |
+--------------------------+
1 row in set (0.00 sec)
mysql> SELECT short_token('foo', 4);
ERROR 1644 (45000): Bad value for "base" argument. It must be "base64", "hex" or "dec".
mysql> SELECT short_token('base64', 0);
ERROR 1690 (22003): length value is out of range in 'random_bytes'
Parfait, c’est le comportement attendu, y compris au niveau des erreurs levées. Nous pouvons passer à la création d’une procédure stockée, qui sera appelée dans des déclencheurs pour générer un jeton unique par rapport à la table associée.
MySQL ne permettant pas de SQL dynamique dans toute portion de code destinée à
des déclencheurs, j’ai dû prévoir le cas des tables concernées avec un IF THEN
.
Bref, assez de blabla et voyons cette procédure stockée :
DROP PROCEDURE IF EXISTS unique_short_token;
DELIMITER $$
CREATE PROCEDURE unique_short_token(
IN base VARCHAR(6),
IN size INT,
IN table_name VARCHAR(255),
OUT token VARCHAR(255)
)
BEGIN
ok: LOOP
SET token = short_token(base, size);
-- MySQL ne permet pas le SQL dynamique dans le code utilisé dans
-- des triggers… C’est très pénible et ça oblige à faire ce genre
-- d’horreur
IF table_name = 'examples' THEN
SELECT `token`
INTO @found
FROM examples
WHERE `token` = QUOTE(@token);
ELSEIF table_name = 'examples2' THEN
SELECT `token`
INTO @found
FROM examples2
WHERE `token` = QUOTE(@token);
ELSEIF table_name = 'examples3' THEN
SELECT `token`
INTO @found
FROM examples3
WHERE `token` = QUOTE(@token);
END IF;
IF @found IS NULL THEN
LEAVE ok;
END IF;
END LOOP;
END$$
DELIMITER ;
L’idée est sensiblement la même que pour l’équivalent en PostgreSQL. Sauf qu’il faut prévoir avant le nom des tables (oui c’est nul, mais c’est MySQL, ce n’est ni Oracle, ni PostgreSQL).
Allons-y pour les déclencheurs (et les tables) :
DROP TABLE IF EXISTS examples;
CREATE TABLE examples (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
token VARCHAR(255)
);
DROP TABLE IF EXISTS examples2;
CREATE TABLE examples2 (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
token VARCHAR(255)
);
DROP TABLE IF EXISTS examples3;
CREATE TABLE examples3 (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
token VARCHAR(255)
);
DROP TRIGGER IF EXISTS trigger_unique_short_token;
DELIMITER $$
CREATE TRIGGER trigger_unique_short_token
BEFORE INSERT ON examples FOR EACH ROW
BEGIN
DECLARE token VARCHAR(255);
CALL unique_short_token('hex', 4, 'examples', token);
SET NEW.token = token;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trigger_unique_short_token2;
DELIMITER $$
CREATE TRIGGER trigger_unique_short_token2
BEFORE INSERT ON examples2 FOR EACH ROW
BEGIN
DECLARE token VARCHAR(255);
CALL unique_short_token('dec', 4, 'examples2', token);
SET NEW.token = token;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trigger_unique_short_token3;
DELIMITER $$
CREATE TRIGGER trigger_unique_short_token3
BEFORE INSERT ON examples3 FOR EACH ROW
BEGIN
DECLARE token VARCHAR(255);
CALL unique_short_token('base64', 4, 'examples3', token);
SET NEW.token = token;
END$$
DELIMITER ;
Et testons tout ça :
INSERT INTO examples(`name`) VALUES ('foo'), ('bar'), ('thing');
INSERT INTO examples2(`name`) VALUES ('foo'), ('bar'), ('thing');
INSERT INTO examples3(`name`) VALUES ('foo'), ('bar'), ('thing');
SELECT * FROM examples;
SELECT * FROM examples2;
SELECT * FROM examples3;
On obtient alors :
+----+-------+----------+
| id | name | token |
+----+-------+----------+
| 1 | foo | 7e52cf7a |
| 2 | bar | c7b165b5 |
| 3 | thing | 76841269 |
+----+-------+----------+
3 rows in set (0.00 sec)
+----+-------+------------+
| id | name | token |
+----+-------+------------+
| 1 | foo | 2758149348 |
| 2 | bar | 2493851898 |
| 3 | thing | 1853883029 |
+----+-------+------------+
3 rows in set (0.00 sec)
+----+-------+--------+
| id | name | token |
+----+-------+--------+
| 1 | foo | oQpNvA |
| 2 | bar | DVuQ3w |
| 3 | thing | FVfBSw |
+----+-------+--------+
3 rows in set (0.00 sec)
Ce qui est conforme à ce qu’on avait avec PostgreSQL.
Vous pouvez télécharger directement le script complet pour MySQL pour le tester et le reprendre chez vous.
SQLite
Le cas de SQLite est plus compliqué. On peut passer par la génération de nombre aléatoire grâce à la fonction RANDOMBLOB. Donc, pour faire avec une chaîne en base 16, c’est faisable, il suffit de faire comme ce qui suit :
-- donnera une chaîne en base 16 de 8 caractères
SELECT hex(randomblob(4));
Mais c’est tout, en SQLite natif, on est bloqué. Pour une version à la carte, en base64 ou juste en base 10, soit il faut s’inspirer de ce gist qui utilise les common table expression et adapter le code dans un ou plusieurs déclencheurs selon votre besoin, soit, le plus simple, est d’injecter des fonctions utilisateurs dans SQLite si vous utilisez la base via un langage de programmation comme C ou PHP. Voyez SQLite3::createFunction pour PHP par exemple.
Voyons tout de même la réalisation d’un déclencheur pour une table examples
avec un jeton généré en base 16 :
DROP TABLE IF EXISTS examples;
CREATE TABLE examples (
id INTEGER PRIMARY KEY
UNIQUE,
name TEXT NOT NULL,
token TEXT UNIQUE
);
CREATE TRIGGER on_insert_example
AFTER INSERT
ON examples
FOR EACH ROW
WHEN NEW.token IS NULL
BEGIN
-- changez la valeur 6 par le nombre d’octets que vous voulez
UPDATE examples
SET token = hex(randomblob(6));
WHERE id = NEW.id;
END;
Et maintenant, avec la base 10, grâce à une Common Table Expression :
DROP TABLE IF EXISTS examples2;
CREATE TABLE examples2 (
id INTEGER PRIMARY KEY
UNIQUE,
name TEXT NOT NULL,
token TEXT UNIQUE
);
-- majeure partie du code de ce trigger
-- par Chichou. Son blog : https://blog.chichou.me/
-- Vu sur <https://gist.github.com/ChiChou/97a53caa2c0b49c1991e>
CREATE TRIGGER on_insert_example2
AFTER INSERT
ON examples2
FOR EACH ROW
WHEN NEW.token IS NULL
BEGIN
UPDATE examples2
SET token = (
-- Common Table Expression pour convertir hex vers decimal
-- Voir <https://gist.github.com/ChiChou/97a53caa2c0b49c1991e>
-- Par Chichou. Son blog : https://blog.chichou.me/
-- Copyright ChiChou
WITH RECURSIVE
unhex(str, val, weight) AS (
-- changez la valeur 4 par le nombre
-- d’octets que vous voulez
SELECT hex(randomblob(4)), 0, 1
UNION ALL
SELECT
substr(str, 1, length(str) - 1),
val + (instr('0123456789ABCDEF', substr(str, length(str), 1)) - 1) * weight,
weight * 16
FROM unhex WHERE length(str) > 0
)
SELECT val FROM unhex ORDER BY weight DESC LIMIT 1
)
WHERE id = NEW.id;
END;
Testons ces codes :
INSERT INTO examples(name) VALUES ('foo'), ('bar'), ('thing');
INSERT INTO examples2(name) VALUES ('foo'), ('bar'), ('thing');
Ce qui donne bien ce qu’on veut :
# SELECT * FROM examples;
1 foo 7693521d
2 bar 4239c1f0
3 thing 0ee2b138
# SELECT * FROM examples2;
1 foo 3438703825
2 bar 2207269411
3 thing 894887396
Mais ces possibilités n’incluent pas un test de l’existant. Je vous recommande dans ce cas de passer par le développement du côté PHP et/ou par l’injection de fonctions utilisateurs, plus pratique et permet d’éviter de répéter le code.
Vous pouvez en tout cas télécharger le script complet pour SQLite pour vous amuser avec.
Comment faire ça en PHP ?
J’en ai eu besoin sur certains projets. Du coup j’ai conçu une petite lib en POO, Token Generator, qui permet de générer des jetons de différentes façons et avec différentes représentations. Tout est expliqué dans le fichier README. Vous pouvez y définir votre propre générateur et votre propre formateur.
Si vous voulez faire ça par vous-même, vous devrez utiliser la fonction random_bytes, et jouez avec les fonctions de conversion de base.
Vous pourrez éventuellement utiliser le code PHP comme fonctions utilisateurs à injecter dans la base de données SQLite.
Pour se caler avec ce qui a été fait dans les exemples de codes pour les bases de données, je vous propose cet exemple d’implémentation :
/**
* La fonction définissant juste le générateur de token
*/
function short_token(string $type = 'base64', int $size = 6): string
{
if (!in_array($type, ['base64', 'hex', 'dec'])) {
throw new \InvalidArgumentException(
'Type must be "base64", "hex" or "dec".'
);
}
// On génère les octets aléatoires
$alea = random_bytes($size);
$funcs = [
'base64' => function($alea){
return strtr(
strtr(
trim(
base64_encode($alea),
'='
),
'/',
'_'
),
'+',
'-'
);
},
'hex' => function ($alea) {
return bin2hex($alea);
},
'dec' => function($alea) use ($size) {
return base_convert(bin2hex($alea), 16, 10);
}
];
return (string) $funcs[$type]($alea);
}
La fonction prend deux paramètres optionnels :
$type
pour le type de jeton à générer, trois choix sont possibles parmibase64
,hex
etdec
, le défaut étantbase64
;$size
pour le nombre d’octets aléatoires à utiliser à la source.
Voyons si tout fonctionne bien :
var_dump(unique_short_token('base64', 4));
var_dump(unique_short_token('base64', 6));
var_dump(unique_short_token('base64', 8));
var_dump(unique_short_token('hex', 4));
var_dump(unique_short_token('hex', 6));
var_dump(unique_short_token('hex', 8));
var_dump(unique_short_token('dec', 4));
var_dump(unique_short_token('dec', 6));
var_dump(unique_short_token('dec', 8));
Et leurs sorties respectives :
string(6) "MgPWdA"
string(8) "gquhxYZK"
string(11) "UUw6sMSEc0k"
string(8) "bf3ed005"
string(12) "386a99623f35"
string(16) "17e70658734b76ad"
string(9) "167164252"
string(15) "115409802893224"
string(20) "11500403502333684202"
Vérifions la levée des erreurs :
short_token('foo', 4);
/* donne
PHP Fatal error: Uncaught InvalidArgumentException: Type must be "base64", "hex" or "dec". in /home/michel/token.php:8
Stack trace:
#0 /home/michel/token.php(58): short_token('foo', 4)
#1 {main}
thrown in /home/michel/token.php on line 8
*/
short_token('base64', 0);
/* donne
PHP Fatal error: Uncaught Error: Length must be greater than 0 in /home/michel/token.php:14
Stack trace:
#0 /home/michel/token.php(14): random_bytes(0)
#1 /home/michel/token.php(58): short_token('base64', 0)
#2 {main}
thrown in /home/michel/token.php on line 14
*/
Parfait, les erreurs sont bien levées. On peut maintenant passer à la fonction pour générer un jeton unique par rapport à un ensemble de jetons existants :
/**
* La fonction générant un token unique par rapport à un ensemble de tokens pré-existants.
*/
function unique_short_token(string $type = 'base64', int $size = 6, array $existing)
{
$token = short_token($type, $size);
if (!in_array($token, $existing)) {
return $token;
}
return unique_short_token($type, $size, $existing);
}
C’est une petite fonction récursive, elle prend les mêmes arguments que short_token()
avec en plus un troisième permettant de fournir un tableau de valeurs existantes.
Voilà, vous avez une implémentation toute simple en PHP. Pour quelque chose de plus poussée, je vous invite à regarder la lib que j’ai faite.
Conclusion
S’il y a une chose à retenir, c’est l’utilisation de la génération aléatoire d’octets. Pour rappel :
gen_random_bytes()
en PostgreSQLrandom_bytes()
en MySQLrandomblob()
en SQLiterandom_bytes()
en PHP
Ensuite, il faut convertir le résultat de ce générateur dans une base qui vous convient, avec d’éventuel post-traitement si c’est le base 64 qui est choisi.
Vous avez maintenant les outils clé en main pour réaliser vos jetons, voir même étendre ce que je vous ai fourni. Avouez que c’est mieux d’utiliser les jetons dans vos projets qu’au casino.