Skip to main content

11. Socket Auth

Sécurisez vos connexions Socket.IO avec l'authentification et les middleware

Introduction

L'authentification est cruciale pour sécuriser vos applications temps réel. Sans elle, n'importe qui pourrait se connecter à votre serveur et accéder à des données sensibles.

Dans ce chapitre, nous allons étendre notre classe ChatServer pour ajouter l'authentification JWT. Nous verrons comment :

  • Utiliser les middlewares Socket.IO pour valider les connexions
  • Implémenter l'authentification avec JWT
  • Stocker et utiliser les données utilisateur authentifiées
  • Gérer les permissions (rôles admin/user)

Middlewares Socket.IO

Les middlewares s'exécutent avant l'établissement d'une connexion. Ils permettent de valider ou rejeter une connexion.

io.use((socket, next) => {
if (isValid) {
next() // Autoriser
} else {
next(new Error('Authentification échouée')) // Rejeter
}
})

Adaptation de ChatServer

Dans le chapitre 7. Authentification, nous avons déjà mis en place JWT pour Express avec le middleware authenticateToken. Nous allons réutiliser cette logique pour Socket.IO.

import {Server as HTTPServer} from 'http'
import {Server, Socket} from 'socket.io'
import jwt from 'jsonwebtoken'

// Types simplifiés pour les événements
interface ClientToServerEvents {
user: (username: string) => void
message: (username: string, message: string) => void
}

interface ServerToClientEvents {
welcome: (message: string) => void
'user-joined': (message: string) => void
message: (data: { username: string, message: string }) => void
}

// Données stockées après authentification (correspond au JWT du cours 11)
interface UserData {
userId: number
email: string
}

type TypedSocket = Socket<ClientToServerEvents, ServerToClientEvents>
type TypedServer = Server<ClientToServerEvents, ServerToClientEvents>

export class ChatServer {
private io: TypedServer

constructor(httpServer: HTTPServer) {
this.io = new Server<ClientToServerEvents, ServerToClientEvents>(httpServer, {
cors: {origin: '*'},
})
this.setupAuthMiddleware() // Nouveau : ajout de l'authentification
this.initializeSocket()
}

// Nouveau : middleware d'authentification
private setupAuthMiddleware() {
this.io.use((socket, next) => {
const token = socket.handshake.auth.token

if (!token) {
return next(new Error('Token manquant'))
}

try {
const decoded = jwt.verify(token, process.env.JWT_SECRET as string) as UserData
socket.data = decoded
next()
} catch (error) {
next(new Error('Token invalide ou expiré'))
}
})
}

private initializeSocket() {
this.io.on('connection', (socket) => {
// Récupérer les données de l'utilisateur authentifié
const userData = socket.data as UserData
console.log('Nouvelle connexion:', socket.id, `(${userData.email})`)

socket.emit('welcome', `Bienvenue ${userData.email}!`)

socket.on('user', (username) => this.handleUser(socket, userData))
socket.on('message', (username, message) => this.handleMessage(socket, userData, message))
})
}

private handleUser(socket: TypedSocket, userData: UserData) {
console.log('Utilisateur connecté:', userData.email)
socket.broadcast.emit('user-joined', `${userData.email} s'est connecté`)
}

private handleMessage(socket: TypedSocket, userData: UserData, message: string) {
console.log(`${userData.email}: ${message}`)
this.io.emit('message', {username: userData.email, message})
}
}

Explication des modifications

Voici ce qui a été ajouté :

  1. Import de jsonwebtoken : Pour vérifier les tokens JWT
  2. Interface UserData : Définit la structure des données stockées dans socket.data après authentification
  3. Méthode setupAuthMiddleware() :
    • Récupère le token depuis socket.handshake.auth.token
    • Vérifie le token avec jwt.verify() (même approche que le middleware Express)
    • Stocke les données décodées dans socket.data
    • Rejette la connexion si le token est invalide
  4. Modification de initializeSocket() : Récupère les données utilisateur depuis socket.data
  5. Modification des handlers : Utilisent userData au lieu du username passé en paramètre

Client HTML

Créez public/chat-auth.html :

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Chat avec Authentification</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
font-family: Arial, sans-serif;
padding: 20px;
max-width: 800px;
margin: 0 auto;
}

h1 {
margin-bottom: 20px;
}

.hidden {
display: none;
}

#loginForm input {
display: block;
width: 100%;
padding: 10px;
margin: 10px 0;
border: 1px solid #ddd;
}

#messages {
border: 1px solid #ddd;
height: 400px;
overflow-y: auto;
padding: 10px;
margin: 10px 0;
background: #f9f9f9;
}

#messages div {
margin: 5px 0;
padding: 5px;
}

.system {
color: #666;
font-style: italic;
}

.error {
color: red;
}

#form {
display: flex;
gap: 10px;
}

input {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
}

button {
padding: 10px 20px;
background: #007bff;
color: white;
border: none;
cursor: pointer;
}

button:hover {
background: #0056b3;
}
</style>
</head>
<body>
<h1>Chat Socket.IO avec Auth</h1>

<div id="loginForm">
<input id="email" placeholder="Email" type="email"/>
<input id="password" placeholder="Password" type="password"/>
<button id="loginButton">Se connecter</button>
<p class="error" id="loginError"></p>
<p><small>Comptes : alice@example.com/password123 ou bob@example.com/password123</small></p>
</div>

<div class="hidden" id="chatContainer">
<p>Connecté : <strong id="currentUser"></strong></p>
<div id="messages"></div>
<form id="form">
<input autocomplete="off" id="input" placeholder="Votre message..."/>
<button>Envoyer</button>
</form>
</div>

<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
<script>
let socket, userEmail

document.getElementById('loginButton').addEventListener('click', async () => {
const email = document.getElementById('email').value.trim()
const password = document.getElementById('password').value.trim()
const loginError = document.getElementById('loginError')
loginError.textContent = ''

try {
const response = await fetch('/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({email, password})
})

if (!response.ok) {
const error = await response.json()
loginError.textContent = error.error
return
}

const {token} = await response.json()
userEmail = email

document.getElementById('loginForm').classList.add('hidden')
document.getElementById('chatContainer').classList.remove('hidden')
document.getElementById('currentUser').textContent = userEmail

connectToChat(token)
} catch (error) {
loginError.textContent = 'Erreur réseau'
}
})

function connectToChat(token) {
socket = io('http://localhost:3000', {auth: {token}})
const messages = document.getElementById('messages')
const form = document.getElementById('form')
const input = document.getElementById('input')

socket.emit('user', userEmail)

function addMessage(text, isSystem = false) {
const div = document.createElement('div')
div.className = isSystem ? 'system' : ''
div.textContent = text
messages.appendChild(div)
messages.scrollTop = messages.scrollHeight
}

socket.on('welcome', (msg) => addMessage(msg, true))
socket.on('user-joined', (msg) => addMessage(msg, true))
socket.on('message', (data) => addMessage(`${data.username}: ${data.message}`))

form.addEventListener('submit', (e) => {
e.preventDefault()
if (input.value) {
socket.emit('message', userEmail, input.value)
input.value = ''
}
})
}
</script>
</body>
</html>

Servir le fichier HTML

Lorsque vous allez envoyer la requête POST /auth/login, le navigateur va envoyer une erreur. En effet le fichier chat-auth.html n'est pas servi par le serveur Node mais par le système de fichiers local. Il faut donc modifier le serveur pour qu'il serve ce fichier.

import 'dotenv/config'
import express from 'express'
import {userRouter} from "@/routes/user.route";
import {authRouter} from "@/routes/auth.route";
import swaggerUi from 'swagger-ui-express'
import {swaggerDocument} from './docs'
import * as http from "node:http";
import {ChatServer} from "@/socket/Chat";

const app = express()
const server = http.createServer(app);

// Initialiser le serveur de chat avec la classe
new ChatServer(server)

const port = 3000

// Middleware pour parser le JSON
app.use(express.json())

// Sert les fichiers statiques
app.use(express.static('public'))

// Documentation Swagger UI
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument, {
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: "API Documentation"
}))

// Route Express classique
app.get('/', (_req, res) => {
res.send('Serveur Socket.IO actif')
})

// Autres middlewares et routes Express
app.use('/users', userRouter)
app.use('/auth', authRouter)

server.listen(port, () => {
console.log(`Serveur démarré sur http://localhost:${port}`)
})

On a ajouté express.static('public') pour servir les fichiers statiques dans le dossier public.