ATTENTION : certaines portions de code présentées ici sont valables pour Express 2.x et ne fonctionnent plus avec Express 3.x. Il s’agit principalement de la partie traitant du parsing de cookie, l’articles reste à 95% valable, mais vous trouverez les détails pour Express 3 dans cet article → Authentification WebSocket avec les sessions Express 3.x et Socket.IO
La première fois qu’on utilise les websockets, on se dit que c’est compliqué (pas tant que ça en fait). Puis on utilise socket.io, et on se dit que c’est vraiment super simple
Et puis un jour on aimerait bien sécuriser la connexion au socket, parce que la partie « temps réel » nécessite d’être authentifié, ou simplement parce qu’il faut un pré-requis (comme avoir donné son nom avant de pouvoir chatter) avant que l’écoute ne démarre réellement.
La première idée qu’on a est de se trimballer des flags et d’activer/désactiver l’écoute de certains messages en fonction de ces flags (par exemple: tant que l’utilisateur n’a pas donné son nom, les évènements « message envoyé » sont ignorés). Le problème est que la connexion est pourtant déjà ouverte, des données y transitent déjà, et c’est donc vraiment très loin d’être idéal. De manière plus générale, il semble très complexe de communiquer avec la session utilisateur au sein du websocket. En effet on a un code de ce genre, où l’on voit bien qu’il semble impossible de manipuler la session utilisateur dans le cadre d’un websocket vu qu’il n’y a aucune notion de « request »:
sockets.on('connection', function (socket) { // But... socket.on('msg', function (data) { // ... where the fuck is my session? }); }); |
On va voir que le protocole est bien fait et que la connexion se fait au sein d’une enveloppe HTTP standard, et qu’on a donc accès à tous les headers d’une requête HTTP, notamment les cookies, permettant de récupérer par exemple un ID de session, et donc de lire/modifier la session utilisateur depuis le websocket!
Sommaire:
- Gestion des sessions avec Express
- Sécuriser une page
- Utilisation de Socket.IO
- Manipulation de la session utilisateur depuis un WebSocket, et sécurisation de la connexion
- Gestion des connexions mutiples
- Aller plus loin…
Note: si vous connaissez déjà bien Express et Socket.IO, vous êtes encouragé à sauter directement au chapitre 4 (éventuellement, le chapitre 2 peut rester intéressant). Le reste ne vous apprendra rien.
Retrouvez le code complet des exemples sur GitHub.
Pré-requis
Je pars du principe dans cet article que vous connaissez:
- Express, le micro-framework web pour Node.JS;
- Le principe des middlewares dans Express;
- Les Web Sockets, et (au moins de nom) la librairie « socket.io »;
- Ah et puis le protocole HTTP, la notion de requête, les cookies, tout ça tout ça…
Si ce n’est pas le cas: GO L2P N00B!
On commence par installer les modules pré-requis: npm install express ejs socket.io.
Puis on crée l’arborescence minimal d’un projet Express (on ajoutera des fichiers au fur et à mesure):
/ +- app.js +- views/ +- layout.ejs |
1. Gestion des sessions avec Express
Voici notre application de départ app.js:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | const path = require('path') , express = require('express') , app = module.exports = express.createServer() , port = process.env.PORT || 1337 ; /** Configuration */ app.configure(function() { this.set('views', path.join(__dirname, 'views')); this.set('view engine', 'ejs'); this.use(express.static(path.join(__dirname, '/public'))); }); app.configure('development', function(){ this.use(express.errorHandler({ dumpExceptions: true, showStack: true })); }); app.configure('production', function(){ this.use(express.errorHandler()); }); /** Routes */ app.get('/', function (req, res, next) { res.render('index'); }); /** Start server */ if (!module.parent) { app.listen(port) } |
Les sessions avec Express fonctionnent de manière habituelle: un ID de session est affecté à un visiteur et stocké dans un cookie. Côté serveur, on stocke des données dans un « store » variable (mémoire, fichiers, base de données…) affectées à cet ID, avec un petit garbage collector qui va bien. Rien de détonnant, néanmoins comme par défaut il n’y a aucun parsing des headers de la requête HTTP, il faut tout de même prévoir quelques opérations ne serait-ce que pour savoir lire le cookie
On va donc utiliser deux « middlewares »: un pour parser les cookies, un autre pour gérer les sessions. C’est dans la partie « configuration » que ça se passe:
6 7 8 9 10 11 12 13 14 15 16 17 18 19 | /** Configuration */ app.configure(function() { ... // Allow parsing cookies from request headers this.use(express.cookieParser()); // Session management this.use(express.session({ // Private crypting key "secret": "some private string", // Internal session data storage engine, this is the default engine embedded with connect. // Much more can be found as external modules (Redis, Mongo, Mysql, file...). look at "npm search connect session store" "store": new express.session.MemoryStore({ reapInterval: 60000 * 10 }) })); }); |
Une fois qu’on a fait ça, chaque requête est enrichie d’un objet « session » qu’on peut manipuler. Supposons qu’on veuille par exemple attribuer un index numérique à un utilisateur, et l’incrémenter à chaque fois qu’il arrive sur la page (grand classique des sessions n’est-ce pas):
Template: views/session-index.ejs
1 2 3 4 | <ul> <li>ID de session: <%= sessId %> <li>Index: <%= index %> </ul> |
Route: /session-index
31 32 33 34 35 36 37 38 39 | app.get('/session-index', function (req, res, next) { // Increment "index" in session req.session.index = (req.session.index || 0) + 1; // View "session-index.ejs" res.render('session-index', { "index": req.session.index, "sessId": req.sessionID }); }); |
On lance l’application (node app.js) et on se rend sur http://127.0.0.1/session-index. On doit avoir un résultat de ce genre:
À chaque rafraichissement, l’index augmente. Ouvrez la page dans un autre navigateur et vous aurez un ID de session différent et un retour à la valeur 1.
Voilà pour la mise en place du support des sessions. Maintenant on va les utiliser de manière un peu plus pertinente, par exemple pour gérer une connexion et des pages interdites aux utilisateurs non connectés.
2. Sécuriser une page
On va se doter de deux pages:
- « / » est un chat, qui requiert d’être identifié;
- « /login » présente un simple formulaire avec un champ texte pour le login (pas de mot de passe ici);
Template views/index.ejs:
1 | <p>Hello, <%= username %></p> |
Route /:
41 42 43 44 45 46 47 48 49 | app.get('/', function (req, res, next) { if (req.session.username) { // User is authenticated, let him in res.render('index'); } else { // Otherwise we redirect him to login form res.redirect("/login"); } }); |
Comme il est fort probable qu’on aura dans notre application finale plusieurs pages à « sécuriser », on va extraire cette partie dans un middleware qu’on pourra ainsi réutiliser partout où c’est nécessaire:
41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | /** Middleware for limited access */ function requireLogin (req, res, next) { if (req.session.username) { // User is authenticated, let him in next(); } else { // Otherwise, we redirect him to login form res.redirect("/login"); } } /** Home page (requires authentication) */ app.get('/', [requireLogin], function (req, res, next) { res.render('index'); }); |
Le code semble plus lisible ainsi: le fait qu’une route soit sécurisé n’est plus traité au sein de son callback, mais comme un filtre préliminaire. C’est même conceptuellement bien plus proche de la réalité d’une sécurisation de page.
Maintenant passons au formulaire d’identification.
Template views/login.ejs:
1 2 3 4 5 6 | <p>You need to be logged in, bastard!</p>
<% if (error) { %><p>Erreur: <%= error %></p><% } %>
<form method="post">
<input type="text" name="username" placeholder="Type your login" autofocus required<% if (username) { %> value="<%= username %>"<% } %>>
<input type="submit">
</form> |
Afin d’être capable de lire le contenu d’un formulaire, il faut parser le corps de la requête, et un middleware est là pour ça:
22 23 | // Allow parsing form data this.use(express.bodyParser()); |
Route /login:
59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 | /** Login form */ app.get("/login", function (req, res) { // Show form, default value = current username res.render("login", { "username": req.session.username, "error": null }); }); app.post("/login", function (req, res) { var options = { "username": req.body.username, "error": null }; if (!req.body.username) { options.error = "User name is required"; res.render("login", options); } else if (req.body.username == req.session.username) { // User has not changed username, accept it as-is res.redirect("/"); } else if (!req.body.username.match(/^[a-zA-Z0-9\-_]{3,}$/)) { options.error = "User name must have at least 3 alphanumeric characters"; res.render("login", options); } else { // Validate if username is free if (usernameIsAlreadyUsed) { options.error = "User name is already used by someone else"; res.render("login", options); } else { req.session.username = req.body.username; res.redirect("/"); } } }); |
Le formulaire vérifie d’abord que le nom d’utilisateur entré a le bon format, puis s’il n’est pas déjà utilisé, et si tout va bien affecte la donnée en session et redirige vers la page d’accueil. Dans le cas contraire on affiche une erreur. Reste à implémenter la partie « le nom est déjà utilisé »: Dans un cas « réel » on aurait une base de données et une identification par login/password. Ici on va se contenter de parcourir le SessionStore pour voir si personne avec une session active ne serait déjà en train d’utiliser ce nom:
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 | // Validate if username is free req.sessionStore.all(function (err, sessions) { if (!err) { var found = false; for (var i=0; i<sessions.length; i++) { var session = JSON.parse(sessions[i]); // Si les sessions sont stockées en JSON if (session.username == req.body.username) { err = "User name already used by someone else"; found = true; break; } } } if (err) { options.error = ""+err; res.render("login", options); } else { req.session.username = req.body.username; res.redirect("/"); } }); |
Note: la méthode all() est spécifique au MemoryStore de connect, et ne se retrouvera pas forcément sur les autres stores.
On y est
Redémarrez l’application, et rendez-vous sur http://127.0.0.1:1337, vous serez redirigé vers /login et là vous pouvez choisir un nom. Si vous ouvrez un autre navigateur sur la même adresse, et tentez d’utiliser le même nom vous devriez vous faire envoyer dans les roses.
Maintenant qu’on a sécurisé notre page côté serveur, on va y mettre quelque chose d’intéressant, et de tellement original: un chat \o/
3. Utilisation de Socket.IO
Bon, si vous êtes habitués des tutos, et que vous vous êtes déjà tapé le chat en WebSocket avec Node.JS au moins mille fois, vous pouvez sauter cette étape, elle ne vous apprendra rien.
Côté serveur, dans app.js:
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 | /** WebSocket */ var sockets = require('socket.io').listen(app).of('/chat'); sockets.on('connection', function (socket) { // New client var socket_username = null; // User sends his username socket.on('user', function (username) { socket_username = username; sockets.emit('join', username, Date.now()); }); // When user leaves socket.on('disconnect', function () { if (socket_username) { sockets.emit('bye', socket_username, Date.now()); } }); // New message from client = "write" event socket.on('write', function (message) { if (socket_username) { sockets.emit('message', socket_username, message, Date.now()); } else { socket.emit('error', 'Username is not set yet'); } }); }); |
Côté client, dans views/index.ejs:
3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | <pre id="messages" style="max-height:300px; overflow:auto;"></pre>
<form method="post" id="message-form">
<input type="text" placeholder="Type your message" id="message-input" name="message" autofocus required>
<input type="submit" value="OK">
</form>
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
<script type="text/javascript">
/** Socket */
var sio = io.connect(), socket = sio.socket.of('/chat');
socket
.on('connect', function () {
// at connection, first send my username
socket.emit('user', <%- JSON.stringify(username) %>);
})
.on('join', function (username, time) {
// someone joined room
addMessage(username, 'joined room', time);
})
.on('bye', function (username, time) {
// someone left room
addMessage(username, 'left room', time);
})
.on('error', function (error) {
// an error occured
alert('Error: ' + error);
})
.on('message', function (username, message, time) {
// someone wrote a message
addMessage(username, message, time);
});
/** Form */
var messageInput = document.getElementById('message-input');
document.getElementById('message-form').onsubmit = function () {
socket.emit('write', messageInput.value);
messageInput.value = '';
return false;
}
/** Display */
var messagesArea = document.getElementById('messages');
function addMessage(username, message, time) {
if (typeof time != 'string') {
var date = new Date(time);
time = date.getHours() + ':' + date.getMinutes() + ':' + date.getSeconds();
}
var line = '[' + time + '] <strong>' + username + '</strong>: ' + message + '<br />';
messagesArea.innerHTML = line + messagesArea.innerHTML;
}
</script> |
Voici comment ça se passe pour l’instant:
- L’utilisateur arrive sur « / », il est renvoyé sur « /login » pour spécifier son nom d’utilisateur;
- Il est alors redirigé sur « / », et là un websocket est ouvert;
- Il reçoit l’évènement « connect », et émet en retour l’évènement « user » en spécifiant son nom d’utilisateur;
- Le serveur reçoit l’évènement « user », stocke le nom d’utilisateur pour plus tard, et broadcaste l’évènement « join » pour prévenir tout le monde que cet utilisateur est arrivé;
- Quand l’utilisateur essaie d’envoyer un message, ça émet l’évènement « write » avec son message;
- Le serveur reçoit l’évènement « write », et s’il connaît le nom d’utilisateur du client alors c’est bon: on broadcaste l’évènement « message »;
- En revanche en cas de faille spatio-temporelle où l’utilisateur aurait réussi à envoyer un message avant que l’évènement « user » ne soit correctement traité, il recevra une erreur;
- Quand l’utilisateur coupe la connexion, le serveur broadcaste un « bye » pour prévenir tout le monde de son départ;
On détecte immédiatement plusieurs problèmes:
- À l’ouverture du socket, l’utilisateur envoie son username, ce qui n’est pas optimal: on a déjà une session, on aimerait simplement l’utiliser; de plus, le fait de devoir commencer par envoyer son username alors que le socket est déjà ouvert impose de faire des vérifications partout;
- Si l’utilisateur ouvre un nouvel onglet sur la même page, il a toujours la même session, mais ouvre un nouveau socket, soit un nouveau message « join » pour tout le monde, et un « bye » quand il fermera l’onglet alors même qu’il est toujours connecté sur un autre socket;
Le premier problème relève de l’utilisation de la session depuis un websocket, soit le but de cet article.
Le second problème en revanche est classique et il n’y a hélas pas de méthode pour réutiliser une connexion déjà ouverte. Il faut donc stocker quelque part le fait qu’un même utilisateur a X connexions WebSocket ouvertes, et savoir les identifier. L’idéal serait de les stocker dans la session, mais pour ça il faut déjà savoir comment accéder à la session depuis le WebSocket
Here we go!
4. Manipulation de la session utilisateur dans une connexion WebSocket
Lors de l’ouverture du WebSocket, la première étape est une franche poignée de main entre le client et le serveur. Le client devant alors décliner son identité, il présente une enveloppe HTTP standard avec des headers contenant notamment les cookies. C’est en lisant ces cookies qu’on va pouvoir récupérer l’ID de session, et en interrogeant le moteur de stockage qu’on va pouvoir récupérer la session liée à un ID (via la méthode get(), cette fois-ci commune à tous les moteurs de stockage de session compatibles connect).
Côté serveur, il faut donc dans un premier temps rendre accessible le moteur de stockage de sessions à tous les acteurs de notre application. La partie où l’on déclare le middleware de gestion de session va donc être modifié ainsi:
14 15 16 17 18 19 20 21 22 | // Session management // Internal session data storage engine, this is the default engine embedded with connect. // Much more can be found as external modules (Redis, Mongo, Mysql, file...). look at "npm search connect session store" this.sessionStore = new express.session.MemoryStore({ reapInterval: 60000 * 10 }); this.use(express.session({ // Private crypting key "secret": "some private string", "store": this.sessionStore })); |
On peut donc accéder à app.sessionStore pour lire et manipuler les sessions partout dans notre application, et notamment dans le WebSocket. On va donc maintenant implémenter la couche « poignée de main »
On va avoir besoin d’un parseur de cookies, et on va donc utiliser celui de connect. Mais comme Express ne présente pas toute l’API de connect de manière publique, on va d’abord devoir installer le module: npm install connect.
Notre création de socket va s’enrichir de ces quelques lignes auto-documentées:
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 | const parseCookie = require('connect').utils.parseCookie; sockets.authorization(function (handshakeData, callback) { // Read cookies from handshake headers var cookies = parseCookie(handshakeData.headers.cookie); // We're now able to retrieve session ID var sessionID = cookies['connect.sid']; // No session? Refuse connection if (!sessionID) { callback('No session', false); } else { // Store session ID in handshake data, we'll use it later to associate // session with open sockets handshakeData.sessionID = sessionID; // On récupère la session utilisateur, et on en extrait son username app.sessionStore.get(sessionID, function (err, session) { if (!err && session && session.username) { // On stocke ce username dans les données de l'authentification, pour réutilisation directe plus tard handshakeData.username = session.username; // OK, on accepte la connexion callback(null, true); } else { // Session incomplète, ou non trouvée callback(err || 'User not authenticated', false); } }); } }); |
ATTENTION : dans Express 3.x, le cookie de session est signé. Voir ici pour les détails → Authentification WebSocket avec les sessions Express 3.x et Socket.IO
Le fonctionnement détaillé:
- Tous les détails sont sur cette page du wiki de Socket.IO;
- On utilise la méthode
authorize()comme indiqué dans le wiki, car on est dans le cas d’un socket utilisant un namespace; - Cette méthode prend en paramètre une fonction de traitement de l’authentification, qui prend elle-même deux paramètres:
- Les données de la « poignée de main », concrètement l’enveloppe de la requête HTTP;
- Une fonction de callback à appeler une fois le traitement terminé avec deux paramètres: une éventuelle erreur et un booléen indiquant si on accepte ou non la connexion;
- Lorsqu’on utilise ce processus d’autorisation, la connexion elle-même est « suspendue » tant qu’on n’a pas finalisé cette étape, et accepté ou non l’ouverture du socket;
Ainsi on a une vraie étape de validation de la connexion qui prend en compte la session utilisateur, et qui interdit réellement la connexion en cas d’échec. On a d’un côté énormément gagné en sécurité (le socket n’est pas ouvert si la session n’est pas complète) mais aussi en simplicité par la suite (plus besoin de cet échange et de ces vérifications autour du username: on l’a déjà!). Le reste du code de notre socket s’en voit en effet réduit.
Côté serveur:
129 130 131 132 133 134 135 136 137 138 139 | sockets.on('connection', function (socket) { // New client sockets.emit('join', socket.handshake.username, Date.now()); // When user leaves socket.on('disconnect', function () { sockets.emit('bye', socket.handshake.username, Date.now()); }); // New message from client = "write" event socket.on('write', function (message) { sockets.emit('message', socket.handshake.username, message, Date.now()); }); }); |
Côté client, on peut supprimer la ligne socket.emit('user', ...) qui est devenue inutile.
Bilan: sécurisation de la connexion, et suppression des échanges redondants!
5. Gestion des connexions multiples
Comme on l’a vu, il va falloir trouver un moyen d’associer la session utilisateur et les connexions ouvertes par l’utilisateur. On aurait pu faire cette association en se basant sur le username, mais on préfèrera une information réellement robuste comme l’ID de session. Ainsi on ne se prendra pas les pieds dans le tapis si on veut plus tard autoriser les utilisateurs à modifier leur nom.
Quand l’utilisateur ouvre un socket, on l’ajout à sa liste de connexions déjà ouvertes, et cette fois on ne broadcaste l’évènement « join » que si c’est sa première connexion:
129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 | // Active sockets by session var connections = {}; sockets.on('connection', function (socket) { // New client var sessionID = socket.handshake.sessionID; // Store session ID from handshake // this is required if we want to access this data when user leaves, as handshake is // not available in "disconnect" event. var username = socket.handshake.username; // Same here, to allow event "bye" with username if ('undefined' == typeof connections[sessionID]) { connections[sessionID] = { "length": 0 }; // First connection sockets.emit('join', username, Date.now()); } // Add connection to pool connections[sessionID][socket.id] = socket; connections[sessionID].length ++; ... |
Quand il ferme ce socket, on oublie la connexion, et cette fois-ci on ne broadcaste l’évènement « bye » que s’il vient de fermer sa dernière connexion:
143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 | // When user leaves socket.on('disconnect', function () { // Is this socket associated to user session ? var userConnections = connections[sessionID]; if (userConnections.length && userConnections[socket.id]) { // Forget this socket userConnections.length --; delete userConnections[socket.id]; } if (userConnections.length == 0) { // No more active sockets for this user: say bye sockets.emit('bye', username, Date.now()); } }); ... |
Maintenant un même utilisateur (désigné par sa session) va pouvoir ouvrir le chat dans autant d’onglets qu’il le souhaite, il sera considéré comme déconnecté seulement lorsque le dernier onglet sera fermé.
6. Aller plus loin…
Dans les étapes suivantes, on pourrait:
- Sécuriser complètement la connexion en passant par une couche SSL; c’est très simple à mettre en place car NodeJS et Express supportent l’https en natif, et socket.io (depuis sa version 0.6.10) dispose d’exemples de websockets over SSL;
- Créer son propre moteur de stockage de session pour le maîtriser complètement et ne pas forcément tout avoir en mémoire à chaque instant;
- Et même encore mieux, profiter de la toute nouvelle API de Socket.IO
socket.get()etsocket.set()qu’on peut a priori brancher sur n’importe quel moteur… dont notre moteur de stockage de session personnaligé évidemment
ce qui ferait une API de communication websocket <-> session d’une élégance imbattable;
Conclusion
Retrouvez le code complet des exemples sur GitHub.
On a vu qu’il était très simple de gérer une authentification en amont de la connexion, et donc de sécuriser un WebSocket de manière efficace. Avec la couche d’abstraction offerte par Socket.IO, et le récent ajout des namespaces (pour avoir plusieurs flux de communication distincts sur un seul socket) on a une solution vraiment élégante pour le temps réel qui ne finit pas de s’améliorer. Vous n’avez plus aucune excuse pour ne pas utiliser cette technologie
Handshake your booty!


Comments:27
Laisser un commentaire