Node.js

Socket Auth

Sécurisez vos connexions Socket.IO avec l\

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 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.