Table of Contents
Securiser votre application Vue.js avec JWT : Guide complet
Introduction a la securite des applications web
La securite des applications web est devenue un enjeu majeur dans le developpement moderne. Avec la multiplication des cyberattaques et des fuites de donnees, les developpeurs doivent imperativement maitriser les techniques d’authentification et d’autorisation pour proteger leurs utilisateurs.
Dans le contexte des Single Page Applications (SPA) comme celles developpees avec Vue.js, la gestion de l’authentification presente des defis specifiques. Contrairement aux applications traditionnelles qui utilisent des sessions cote serveur, les SPA doivent gerer l’etat d’authentification cote client tout en maintenant une communication securisee avec le backend.
Les JSON Web Tokens (JWT) sont devenus la solution de reference pour l’authentification dans les architectures modernes. Ils offrent plusieurs avantages :
- Stateless : Le serveur n’a pas besoin de maintenir une session
- Scalable : Parfait pour les architectures distribuees et microservices
- Portable : Peut etre utilise entre differents domaines et services
- Auto-contenu : Le token contient toutes les informations necessaires
Dans ce tutoriel complet, nous allons explorer en profondeur comment implementer une authentification robuste dans une application Vue.js en utilisant JWT, Vue Router et Pinia (ou Vuex).
Comprendre les JSON Web Tokens (JWT)
Structure d’un JWT
Un JWT est compose de trois parties separees par des points :
xxxxx.yyyyy.zzzzz
Header.Payload.Signature
Voici un exemple concret de JWT decode :
// Header (Base64 decode)
{
"alg": "HS256",
"typ": "JWT"
}
// Payload (Base64 decode)
{
"sub": "1234567890",
"name": "Mahmoud DEVO",
"email": "contact@fullopenaiblog.com",
"role": "admin",
"iat": 1516239022,
"exp": 1516242622
}
// Signature
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
Les claims standards du JWT
Les claims sont les donnees contenues dans le payload. Il existe trois types :
Claims reserves (standard) :
iss(Issuer) : Identifiant de l’emetteur du tokensub(Subject) : Identifiant unique de l’utilisateuraud(Audience) : Destinataire prevu du tokenexp(Expiration) : Timestamp d’expirationnbf(Not Before) : Timestamp avant lequel le token n’est pas valideiat(Issued At) : Timestamp de creationjti(JWT ID) : Identifiant unique du token
Claims publics : Definis dans le registre IANA ou avec URI de collision.
Claims prives : Claims personnalises pour votre application.
// types/auth.ts
interface JWTPayload {
sub: string;
email: string;
name: string;
role: 'user' | 'admin' | 'moderator';
permissions: string[];
iat: number;
exp: number;
}
interface User {
id: string;
email: string;
name: string;
role: string;
permissions: string[];
}
Verification et expiration des tokens
La verification d’un JWT se fait en trois etapes :
- Verification de la structure : Le token doit avoir trois parties
- Verification de la signature : Avec la cle secrete ou publique
- Verification des claims : Expiration, audience, etc.
// utils/jwt.ts
import { jwtDecode } from 'jwt-decode';
import type { JWTPayload } from '@/types/auth';
export function isTokenExpired(token: string): boolean {
try {
const decoded = jwtDecode<JWTPayload>(token);
const currentTime = Date.now() / 1000;
// Ajouter une marge de 60 secondes pour eviter les problemes de synchronisation
return decoded.exp < currentTime - 60;
} catch {
return true;
}
}
export function getTokenExpirationDate(token: string): Date | null {
try {
const decoded = jwtDecode<JWTPayload>(token);
return new Date(decoded.exp * 1000);
} catch {
return null;
}
}
export function decodeToken(token: string): JWTPayload | null {
try {
return jwtDecode<JWTPayload>(token);
} catch {
return null;
}
}
export function getTimeUntilExpiration(token: string): number {
try {
const decoded = jwtDecode<JWTPayload>(token);
const currentTime = Date.now() / 1000;
return Math.max(0, decoded.exp - currentTime);
} catch {
return 0;
}
}
Flux d’authentification complet
Schema du flux d’authentification
Le flux d’authentification avec JWT suit generalement ce schema :
1. Utilisateur -> Login Form -> POST /api/auth/login
2. Serveur valide credentials -> Genere Access Token + Refresh Token
3. Client stocke les tokens -> Redirige vers page protegee
4. Client envoie Access Token dans Authorization header
5. Serveur valide le token -> Retourne les donnees
6. Si Access Token expire -> Utilise Refresh Token pour en obtenir un nouveau
7. Si Refresh Token expire -> Redirige vers login
Implementation du store d’authentification avec Pinia
// stores/auth.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { authApi } from '@/api/auth';
import {
isTokenExpired,
decodeToken,
getTimeUntilExpiration
} from '@/utils/jwt';
import type { User, LoginCredentials, RegisterData } from '@/types/auth';
export const useAuthStore = defineStore('auth', () => {
const router = useRouter();
// State
const user = ref<User | null>(null);
const accessToken = ref<string | null>(null);
const refreshToken = ref<string | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const refreshTokenTimeout = ref<number | null>(null);
// Getters
const isAuthenticated = computed(() => {
return !!accessToken.value && !isTokenExpired(accessToken.value);
});
const isAdmin = computed(() => {
return user.value?.role === 'admin';
});
const hasPermission = (permission: string) => {
return user.value?.permissions?.includes(permission) ?? false;
};
// Actions
async function login(credentials: LoginCredentials): Promise<boolean> {
loading.value = true;
error.value = null;
try {
const response = await authApi.login(credentials);
setTokens(response.accessToken, response.refreshToken);
user.value = response.user;
// Configurer le rafraichissement automatique
setupRefreshTokenTimer();
return true;
} catch (err: any) {
error.value = err.response?.data?.message || 'Erreur de connexion';
return false;
} finally {
loading.value = false;
}
}
async function register(data: RegisterData): Promise<boolean> {
loading.value = true;
error.value = null;
try {
const response = await authApi.register(data);
setTokens(response.accessToken, response.refreshToken);
user.value = response.user;
setupRefreshTokenTimer();
return true;
} catch (err: any) {
error.value = err.response?.data?.message || 'Erreur d\'inscription';
return false;
} finally {
loading.value = false;
}
}
async function refreshAccessToken(): Promise<boolean> {
if (!refreshToken.value) {
logout();
return false;
}
try {
const response = await authApi.refreshToken(refreshToken.value);
setTokens(response.accessToken, response.refreshToken);
setupRefreshTokenTimer();
return true;
} catch {
logout();
return false;
}
}
function setTokens(access: string, refresh: string): void {
accessToken.value = access;
refreshToken.value = refresh;
// Stocker de maniere securisee (voir section suivante)
localStorage.setItem('accessToken', access);
localStorage.setItem('refreshToken', refresh);
// Mettre a jour les informations utilisateur depuis le token
const payload = decodeToken(access);
if (payload) {
user.value = {
id: payload.sub,
email: payload.email,
name: payload.name,
role: payload.role,
permissions: payload.permissions
};
}
}
function setupRefreshTokenTimer(): void {
// Annuler le timer existant
if (refreshTokenTimeout.value) {
clearTimeout(refreshTokenTimeout.value);
}
if (!accessToken.value) return;
// Rafraichir 5 minutes avant l'expiration
const timeUntilExpiration = getTimeUntilExpiration(accessToken.value);
const refreshTime = Math.max(0, (timeUntilExpiration - 300) * 1000);
refreshTokenTimeout.value = window.setTimeout(() => {
refreshAccessToken();
}, refreshTime);
}
async function logout(): Promise<void> {
// Appeler l'API de deconnexion pour invalider le refresh token
if (refreshToken.value) {
try {
await authApi.logout(refreshToken.value);
} catch {
// Ignorer les erreurs de deconnexion
}
}
// Nettoyer l'etat local
clearAuthState();
// Rediriger vers la page de connexion
router.push({ name: 'login' });
}
function clearAuthState(): void {
user.value = null;
accessToken.value = null;
refreshToken.value = null;
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
if (refreshTokenTimeout.value) {
clearTimeout(refreshTokenTimeout.value);
refreshTokenTimeout.value = null;
}
}
// Initialisation au chargement de l'application
function initializeAuth(): void {
const storedAccessToken = localStorage.getItem('accessToken');
const storedRefreshToken = localStorage.getItem('refreshToken');
if (storedAccessToken && storedRefreshToken) {
if (!isTokenExpired(storedAccessToken)) {
setTokens(storedAccessToken, storedRefreshToken);
setupRefreshTokenTimer();
} else if (!isTokenExpired(storedRefreshToken)) {
// Access token expire mais refresh token valide
refreshToken.value = storedRefreshToken;
refreshAccessToken();
} else {
// Les deux tokens expires
clearAuthState();
}
}
}
return {
// State
user,
accessToken,
loading,
error,
// Getters
isAuthenticated,
isAdmin,
hasPermission,
// Actions
login,
register,
logout,
refreshAccessToken,
initializeAuth
};
});
Stockage securise des tokens : localStorage vs Cookies
Comparaison des methodes de stockage
Le choix du stockage des tokens est crucial pour la securite de votre application :
| Critere | localStorage | Cookies HttpOnly | Memory (RAM) |
|---|---|---|---|
| Vulnerable XSS | Oui | Non | Oui |
| Vulnerable CSRF | Non | Oui (sans SameSite) | Non |
| Persistance | Oui | Oui | Non |
| Acces JavaScript | Oui | Non | Oui |
| Taille max | ~5MB | ~4KB | Illimitee |
| Multi-onglets | Oui | Oui | Non |
Strategie recommandee : Approche hybride
La meilleure approche combine plusieurs techniques :
// services/tokenStorage.ts
import Cookies from 'js-cookie';
interface TokenStorage {
getAccessToken(): string | null;
setAccessToken(token: string): void;
getRefreshToken(): string | null;
setRefreshToken(token: string): void;
clearTokens(): void;
}
// Option 1: localStorage (simple mais vulnerable XSS)
export const localStorageTokenStorage: TokenStorage = {
getAccessToken: () => localStorage.getItem('accessToken'),
setAccessToken: (token) => localStorage.setItem('accessToken', token),
getRefreshToken: () => localStorage.getItem('refreshToken'),
setRefreshToken: (token) => localStorage.setItem('refreshToken', token),
clearTokens: () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
};
// Option 2: Cookies avec configuration securisee
export const cookieTokenStorage: TokenStorage = {
getAccessToken: () => Cookies.get('accessToken') || null,
setAccessToken: (token) => {
Cookies.set('accessToken', token, {
expires: 1/24, // 1 heure
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
});
},
getRefreshToken: () => Cookies.get('refreshToken') || null,
setRefreshToken: (token) => {
Cookies.set('refreshToken', token, {
expires: 7, // 7 jours
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
});
},
clearTokens: () => {
Cookies.remove('accessToken');
Cookies.remove('refreshToken');
}
};
// Option 3: Memory storage (plus securise mais non persistant)
class MemoryTokenStorage implements TokenStorage {
private accessToken: string | null = null;
private refreshToken: string | null = null;
getAccessToken = () => this.accessToken;
setAccessToken = (token: string) => { this.accessToken = token; };
getRefreshToken = () => this.refreshToken;
setRefreshToken = (token: string) => { this.refreshToken = token; };
clearTokens = () => {
this.accessToken = null;
this.refreshToken = null;
};
}
export const memoryTokenStorage = new MemoryTokenStorage();
// Service principal avec strategie configurable
class TokenService {
private storage: TokenStorage;
constructor(storage: TokenStorage) {
this.storage = storage;
}
getAccessToken(): string | null {
return this.storage.getAccessToken();
}
setAccessToken(token: string): void {
this.storage.setAccessToken(token);
}
getRefreshToken(): string | null {
return this.storage.getRefreshToken();
}
setRefreshToken(token: string): void {
this.storage.setRefreshToken(token);
}
clearTokens(): void {
this.storage.clearTokens();
}
}
// Utiliser cookies en production, localStorage en dev
const storage = process.env.NODE_ENV === 'production'
? cookieTokenStorage
: localStorageTokenStorage;
export const tokenService = new TokenService(storage);
Guards de navigation avec Vue Router
Configuration des routes protegees
Les guards de navigation permettent de proteger vos routes et de gerer les redirections :
// router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
// Definition des meta types
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean;
requiresGuest?: boolean;
roles?: string[];
permissions?: string[];
}
}
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue')
},
{
path: '/login',
name: 'login',
component: () => import('@/views/auth/LoginView.vue'),
meta: { requiresGuest: true }
},
{
path: '/register',
name: 'register',
component: () => import('@/views/auth/RegisterView.vue'),
meta: { requiresGuest: true }
},
{
path: '/dashboard',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
meta: { requiresAuth: true }
},
{
path: '/profile',
name: 'profile',
component: () => import('@/views/ProfileView.vue'),
meta: { requiresAuth: true }
},
{
path: '/admin',
name: 'admin',
component: () => import('@/views/admin/AdminView.vue'),
meta: {
requiresAuth: true,
roles: ['admin']
},
children: [
{
path: 'users',
name: 'admin-users',
component: () => import('@/views/admin/UsersView.vue'),
meta: {
requiresAuth: true,
permissions: ['users:read']
}
},
{
path: 'settings',
name: 'admin-settings',
component: () => import('@/views/admin/SettingsView.vue'),
meta: {
requiresAuth: true,
permissions: ['settings:write']
}
}
]
},
{
path: '/products',
name: 'products',
component: () => import('@/views/ProductsView.vue'),
meta: { requiresAuth: true }
},
{
path: '/products/:id',
name: 'product-detail',
component: () => import('@/views/ProductDetailView.vue'),
meta: { requiresAuth: true }
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/views/NotFoundView.vue')
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
// Guard global de navigation
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore();
// Initialiser l'auth si necessaire
if (!authStore.isAuthenticated && localStorage.getItem('accessToken')) {
authStore.initializeAuth();
}
const requiresAuth = to.matched.some(record => record.meta.requiresAuth);
const requiresGuest = to.matched.some(record => record.meta.requiresGuest);
const requiredRoles = to.meta.roles as string[] | undefined;
const requiredPermissions = to.meta.permissions as string[] | undefined;
// Route pour invites seulement (login, register)
if (requiresGuest && authStore.isAuthenticated) {
return next({ name: 'dashboard' });
}
// Route protegee
if (requiresAuth) {
// Verifier l'authentification
if (!authStore.isAuthenticated) {
// Essayer de rafraichir le token
const refreshed = await authStore.refreshAccessToken();
if (!refreshed) {
return next({
name: 'login',
query: { redirect: to.fullPath }
});
}
}
// Verifier les roles
if (requiredRoles && requiredRoles.length > 0) {
const userRole = authStore.user?.role;
if (!userRole || !requiredRoles.includes(userRole)) {
return next({ name: 'dashboard' });
}
}
// Verifier les permissions
if (requiredPermissions && requiredPermissions.length > 0) {
const hasAllPermissions = requiredPermissions.every(
permission => authStore.hasPermission(permission)
);
if (!hasAllPermissions) {
return next({ name: 'dashboard' });
}
}
}
next();
});
// Guard apres navigation pour le tracking
router.afterEach((to, from) => {
// Analytics, logging, etc.
console.log(`Navigation: ${from.path} -> ${to.path}`);
});
export default router;
Composable pour les verifications d’acces
// composables/useAuth.ts
import { computed } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { useRouter, useRoute } from 'vue-router';
export function useAuth() {
const authStore = useAuthStore();
const router = useRouter();
const route = useRoute();
const isAuthenticated = computed(() => authStore.isAuthenticated);
const user = computed(() => authStore.user);
const isAdmin = computed(() => authStore.isAdmin);
function requireAuth() {
if (!authStore.isAuthenticated) {
router.push({
name: 'login',
query: { redirect: route.fullPath }
});
return false;
}
return true;
}
function requireRole(role: string | string[]) {
if (!requireAuth()) return false;
const roles = Array.isArray(role) ? role : [role];
const userRole = authStore.user?.role;
if (!userRole || !roles.includes(userRole)) {
router.push({ name: 'dashboard' });
return false;
}
return true;
}
function requirePermission(permission: string | string[]) {
if (!requireAuth()) return false;
const permissions = Array.isArray(permission) ? permission : [permission];
const hasAll = permissions.every(p => authStore.hasPermission(p));
if (!hasAll) {
router.push({ name: 'dashboard' });
return false;
}
return true;
}
async function loginAndRedirect(credentials: { email: string; password: string }) {
const success = await authStore.login(credentials);
if (success) {
const redirect = route.query.redirect as string || '/dashboard';
router.push(redirect);
}
return success;
}
return {
isAuthenticated,
user,
isAdmin,
requireAuth,
requireRole,
requirePermission,
loginAndRedirect,
login: authStore.login,
logout: authStore.logout,
error: computed(() => authStore.error),
loading: computed(() => authStore.loading)
};
}
Interceptors Axios pour les tokens
Configuration complete d’Axios
// api/axios.ts
import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from 'axios';
import { useAuthStore } from '@/stores/auth';
import { tokenService } from '@/services/tokenStorage';
import { isTokenExpired } from '@/utils/jwt';
// Creer l'instance Axios
const apiClient: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
});
// File d'attente pour les requetes pendant le refresh
let isRefreshing = false;
let failedQueue: Array<{
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
}> = [];
const processQueue = (error: Error | null, token: string | null = null) => {
failedQueue.forEach(promise => {
if (error) {
promise.reject(error);
} else {
promise.resolve(token);
}
});
failedQueue = [];
};
// Interceptor de requete
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = tokenService.getAccessToken();
if (token && !config.headers['Authorization']) {
config.headers['Authorization'] = `Bearer ${token}`;
}
// Ajouter des headers de securite supplementaires
config.headers['X-Requested-With'] = 'XMLHttpRequest';
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Interceptor de reponse
apiClient.interceptors.response.use(
(response: AxiosResponse) => {
return response;
},
async (error) => {
const originalRequest = error.config;
// Si erreur 401 et pas deja en retry
if (error.response?.status === 401 && !originalRequest._retry) {
// Si deja en train de refresh, mettre en queue
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(token => {
originalRequest.headers['Authorization'] = `Bearer ${token}`;
return apiClient(originalRequest);
}).catch(err => {
return Promise.reject(err);
});
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = tokenService.getRefreshToken();
if (!refreshToken || isTokenExpired(refreshToken)) {
// Refresh token invalide, deconnecter
const authStore = useAuthStore();
authStore.logout();
processQueue(new Error('Session expiree'), null);
isRefreshing = false;
return Promise.reject(error);
}
try {
// Appel direct a l'API de refresh (sans interceptors)
const response = await axios.post(
`${apiClient.defaults.baseURL}/auth/refresh`,
{ refreshToken },
{ headers: { 'Content-Type': 'application/json' } }
);
const { accessToken, refreshToken: newRefreshToken } = response.data;
tokenService.setAccessToken(accessToken);
tokenService.setRefreshToken(newRefreshToken);
// Mettre a jour le header de la requete originale
originalRequest.headers['Authorization'] = `Bearer ${accessToken}`;
// Traiter la queue
processQueue(null, accessToken);
return apiClient(originalRequest);
} catch (refreshError) {
// Echec du refresh, deconnecter
const authStore = useAuthStore();
authStore.logout();
processQueue(refreshError as Error, null);
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
// Gestion des autres erreurs
if (error.response?.status === 403) {
console.error('Acces refuse');
} else if (error.response?.status === 500) {
console.error('Erreur serveur');
}
return Promise.reject(error);
}
);
export default apiClient;
API d’authentification
// api/auth.ts
import apiClient from './axios';
import type {
LoginCredentials,
RegisterData,
AuthResponse
} from '@/types/auth';
export const authApi = {
async login(credentials: LoginCredentials): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>('/auth/login', credentials);
return response.data;
},
async register(data: RegisterData): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>('/auth/register', data);
return response.data;
},
async refreshToken(refreshToken: string): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>('/auth/refresh', {
refreshToken
});
return response.data;
},
async logout(refreshToken: string): Promise<void> {
await apiClient.post('/auth/logout', { refreshToken });
},
async getProfile(): Promise<{ user: User }> {
const response = await apiClient.get('/auth/me');
return response.data;
},
async updateProfile(data: Partial<User>): Promise<{ user: User }> {
const response = await apiClient.put('/auth/profile', data);
return response.data;
},
async changePassword(data: {
currentPassword: string;
newPassword: string
}): Promise<void> {
await apiClient.post('/auth/change-password', data);
},
async requestPasswordReset(email: string): Promise<void> {
await apiClient.post('/auth/forgot-password', { email });
},
async resetPassword(token: string, password: string): Promise<void> {
await apiClient.post('/auth/reset-password', { token, password });
}
};
Gestion du Refresh Token
Strategie de rafraichissement automatique
Le refresh token permet de maintenir la session utilisateur sans demander une nouvelle authentification :
// services/tokenRefreshService.ts
import { useAuthStore } from '@/stores/auth';
import { tokenService } from './tokenStorage';
import { getTimeUntilExpiration, isTokenExpired } from '@/utils/jwt';
class TokenRefreshService {
private refreshTimeout: number | null = null;
private readonly REFRESH_MARGIN = 5 * 60; // 5 minutes avant expiration
start(): void {
this.scheduleRefresh();
// Ecouter les changements de visibilite de la page
document.addEventListener('visibilitychange', this.handleVisibilityChange);
// Ecouter les events de focus
window.addEventListener('focus', this.handleWindowFocus);
}
stop(): void {
if (this.refreshTimeout) {
clearTimeout(this.refreshTimeout);
this.refreshTimeout = null;
}
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
window.removeEventListener('focus', this.handleWindowFocus);
}
private scheduleRefresh(): void {
const accessToken = tokenService.getAccessToken();
if (!accessToken) return;
const timeUntilExpiration = getTimeUntilExpiration(accessToken);
const refreshIn = Math.max(0, timeUntilExpiration - this.REFRESH_MARGIN);
if (this.refreshTimeout) {
clearTimeout(this.refreshTimeout);
}
this.refreshTimeout = window.setTimeout(async () => {
await this.refreshToken();
}, refreshIn * 1000);
console.log(`Token refresh scheduled in ${Math.round(refreshIn / 60)} minutes`);
}
private async refreshToken(): Promise<void> {
const authStore = useAuthStore();
const success = await authStore.refreshAccessToken();
if (success) {
this.scheduleRefresh();
}
}
private handleVisibilityChange = async (): Promise<void> => {
if (document.visibilityState === 'visible') {
const accessToken = tokenService.getAccessToken();
if (accessToken && isTokenExpired(accessToken)) {
await this.refreshToken();
} else {
this.scheduleRefresh();
}
}
};
private handleWindowFocus = async (): Promise<void> => {
const accessToken = tokenService.getAccessToken();
if (accessToken && isTokenExpired(accessToken)) {
await this.refreshToken();
}
};
}
export const tokenRefreshService = new TokenRefreshService();
Integration dans l’application
// main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import { useAuthStore } from './stores/auth';
import { tokenRefreshService } from './services/tokenRefreshService';
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
// Initialiser l'authentification avant le montage
const authStore = useAuthStore();
authStore.initializeAuth();
// Demarrer le service de refresh si authentifie
if (authStore.isAuthenticated) {
tokenRefreshService.start();
}
// Observer les changements d'authentification
watch(
() => authStore.isAuthenticated,
(isAuthenticated) => {
if (isAuthenticated) {
tokenRefreshService.start();
} else {
tokenRefreshService.stop();
}
}
);
app.mount('#app');
Deconnexion et revocation des tokens
Implementation complete de la deconnexion
// stores/auth.ts (methode logout complete)
async function logout(options: {
revokeAllSessions?: boolean;
reason?: string;
} = {}): Promise<void> {
const { revokeAllSessions = false, reason } = options;
try {
if (refreshToken.value) {
if (revokeAllSessions) {
// Revoquer tous les refresh tokens de l'utilisateur
await authApi.revokeAllSessions();
} else {
// Revoquer uniquement ce refresh token
await authApi.logout(refreshToken.value);
}
}
} catch (error) {
console.error('Erreur lors de la revocation du token:', error);
} finally {
// Toujours nettoyer l'etat local
clearAuthState();
// Arreter le service de refresh
tokenRefreshService.stop();
// Emettre un event pour les autres composants
window.dispatchEvent(new CustomEvent('auth:logout', {
detail: { reason }
}));
// Rediriger vers login
router.push({
name: 'login',
query: reason ? { message: reason } : undefined
});
}
}
// Deconnexion forcee (session expiree, etc.)
function forceLogout(reason: string): void {
clearAuthState();
tokenRefreshService.stop();
router.push({
name: 'login',
query: { message: reason }
});
}
Composant de deconnexion
<!-- components/LogoutButton.vue -->
<template>
<button
@click="handleLogout"
:disabled="loading"
class="logout-button"
>
<span v-if="loading">Deconnexion...</span>
<span v-else>Se deconnecter</span>
</button>
<!-- Modal de confirmation -->
<Teleport to="body">
<div v-if="showConfirmation" class="modal-overlay">
<div class="modal">
<h3>Confirmer la deconnexion</h3>
<p>Voulez-vous vous deconnecter de tous les appareils ?</p>
<div class="modal-actions">
<button @click="confirmLogout(false)">
Cet appareil uniquement
</button>
<button @click="confirmLogout(true)">
Tous les appareils
</button>
<button @click="showConfirmation = false">
Annuler
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useAuth } from '@/composables/useAuth';
const props = defineProps<{
requireConfirmation?: boolean;
}>();
const { logout, loading } = useAuth();
const showConfirmation = ref(false);
function handleLogout() {
if (props.requireConfirmation) {
showConfirmation.value = true;
} else {
confirmLogout(false);
}
}
async function confirmLogout(revokeAll: boolean) {
showConfirmation.value = false;
await logout({ revokeAllSessions: revokeAll });
}
</script>
Comparatif JWT vs Sessions
| Aspect | JWT | Sessions |
|---|---|---|
| Stockage serveur | Aucun (stateless) | Base de donnees ou memoire |
| Scalabilite | Excellente | Necessite partage de session |
| Performance | Rapide (pas de lookup DB) | Lookup requis a chaque requete |
| Revocation | Complexe (blacklist) | Simple (supprimer session) |
| Taille payload | Plus grand (token dans header) | Petit (session ID) |
| Cross-domain | Facile | Complexe (CORS, cookies) |
| Mobile/API | Ideal | Moins adapte |
| Securite XSS | Vulnerable si localStorage | Cookies HttpOnly proteges |
| Securite CSRF | Immunise | Vulnerable sans protection |
| Expiration | Integree au token | Geree serveur |
Quand utiliser JWT ?
- Applications Single Page (SPA)
- APIs RESTful
- Architecture microservices
- Authentification mobile
- Systemes distribues
Quand utiliser les sessions ?
- Applications web traditionnelles
- Besoin de revocation immediate
- Donnees sensibles ne devant pas etre exposees
- Infrastructure simple (monolithe)
Bonnes pratiques de securite
1. Utiliser HTTPS partout
// Verifier HTTPS en production
if (process.env.NODE_ENV === 'production' && location.protocol !== 'https:') {
location.replace(`https:${location.href.substring(location.protocol.length)}`);
}
2. Duree d’expiration appropriee
// Configuration recommandee
const TOKEN_EXPIRATION = {
ACCESS_TOKEN: '15m', // 15 minutes
REFRESH_TOKEN: '7d', // 7 jours
REMEMBER_ME: '30d' // 30 jours si "Se souvenir de moi"
};
3. Valider les tokens cote serveur
// Exemple Node.js avec jsonwebtoken
const jwt = require('jsonwebtoken');
function verifyToken(token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'fullopenaiblog.com',
audience: 'web-app'
});
return decoded;
} catch (error) {
throw new Error('Token invalide');
}
}
4. Implementer le rate limiting
// Limiter les tentatives de connexion
const loginAttempts = new Map<string, { count: number; lastAttempt: Date }>();
function checkRateLimit(ip: string): boolean {
const attempt = loginAttempts.get(ip);
const now = new Date();
if (!attempt) {
loginAttempts.set(ip, { count: 1, lastAttempt: now });
return true;
}
// Reset apres 15 minutes
if (now.getTime() - attempt.lastAttempt.getTime() > 15 * 60 * 1000) {
loginAttempts.set(ip, { count: 1, lastAttempt: now });
return true;
}
// Bloquer apres 5 tentatives
if (attempt.count >= 5) {
return false;
}
attempt.count++;
attempt.lastAttempt = now;
return true;
}
5. Sanitizer les inputs utilisateur
// composables/useFormValidation.ts
import DOMPurify from 'dompurify';
export function sanitizeInput(input: string): string {
return DOMPurify.sanitize(input.trim());
}
export function validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
export function validatePassword(password: string): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (password.length < 8) {
errors.push('Au moins 8 caracteres');
}
if (!/[A-Z]/.test(password)) {
errors.push('Au moins une majuscule');
}
if (!/[a-z]/.test(password)) {
errors.push('Au moins une minuscule');
}
if (!/[0-9]/.test(password)) {
errors.push('Au moins un chiffre');
}
if (!/[!@#$%^&*]/.test(password)) {
errors.push('Au moins un caractere special');
}
return { valid: errors.length === 0, errors };
}
6. Logger les evenements de securite
// services/securityLogger.ts
interface SecurityEvent {
type: 'login' | 'logout' | 'failed_login' | 'token_refresh' | 'permission_denied';
userId?: string;
ip?: string;
userAgent?: string;
details?: Record<string, unknown>;
timestamp: Date;
}
class SecurityLogger {
private events: SecurityEvent[] = [];
log(event: Omit<SecurityEvent, 'timestamp'>): void {
const fullEvent: SecurityEvent = {
...event,
timestamp: new Date()
};
this.events.push(fullEvent);
// Envoyer au backend pour analyse
this.sendToBackend(fullEvent);
// Log console en dev
if (process.env.NODE_ENV === 'development') {
console.log('[Security]', fullEvent);
}
}
private async sendToBackend(event: SecurityEvent): Promise<void> {
try {
await fetch('/api/security/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event)
});
} catch (error) {
console.error('Failed to log security event:', error);
}
}
}
export const securityLogger = new SecurityLogger();
Pieges courants a eviter
1. Stocker des donnees sensibles dans le JWT
// MAUVAIS - Ne jamais faire cela !
const badPayload = {
sub: '123',
password: 'secret123', // JAMAIS !
creditCard: '4111...', // JAMAIS !
ssn: '123-45-6789' // JAMAIS !
};
// BON - Donnees minimales necessaires
const goodPayload = {
sub: '123',
email: 'user@example.com',
role: 'user',
exp: Math.floor(Date.now() / 1000) + 3600
};
2. Ne pas verifier l’expiration cote client
// MAUVAIS - Utiliser le token sans verification
async function fetchData() {
const token = localStorage.getItem('token');
// Pas de verification d'expiration !
return await api.get('/data', {
headers: { Authorization: `Bearer ${token}` }
});
}
// BON - Toujours verifier avant utilisation
async function fetchDataSafe() {
const token = localStorage.getItem('token');
if (!token || isTokenExpired(token)) {
const refreshed = await refreshToken();
if (!refreshed) {
throw new Error('Session expiree');
}
}
return await api.get('/data');
}
3. Utiliser un secret JWT faible
// MAUVAIS - Secret previsible
const SECRET = 'secret123';
const SECRET = 'password';
const SECRET = process.env.APP_NAME;
// BON - Secret fort et aleatoire
const SECRET = 'xK9#mP2$vL5@nQ8&wR3*jT6^hY1!bF4%cD7'; // 32+ caracteres
// Ou generer avec crypto
const crypto = require('crypto');
const SECRET = crypto.randomBytes(64).toString('hex');
4. Ignorer les erreurs de verification
// MAUVAIS - Catch silencieux
function getUser(token: string) {
try {
return jwt.verify(token, SECRET);
} catch {
return null; // L'erreur est ignoree
}
}
// BON - Gerer correctement les erreurs
function getUserSafe(token: string): User | null {
try {
return jwt.verify(token, SECRET) as User;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
securityLogger.log({ type: 'token_expired' });
} else if (error instanceof jwt.JsonWebTokenError) {
securityLogger.log({ type: 'invalid_token', details: { error: error.message } });
}
return null;
}
}
5. Ne pas proteger contre le CSRF avec les cookies
// Si vous utilisez des cookies pour les tokens
// MAUVAIS - Cookie sans protection CSRF
Cookies.set('token', token);
// BON - Cookie avec SameSite et CSRF token
Cookies.set('token', token, {
secure: true,
sameSite: 'strict',
httpOnly: true // Cote serveur uniquement
});
// Ajouter un token CSRF dans les headers
axios.defaults.headers.common['X-CSRF-Token'] = getCsrfToken();
Exemple complet : Formulaire de connexion
<!-- views/auth/LoginView.vue -->
<template>
<div class="login-container">
<form @submit.prevent="handleSubmit" class="login-form">
<h1>Connexion</h1>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
v-model="form.email"
type="email"
required
autocomplete="email"
:disabled="loading"
/>
<span v-if="errors.email" class="field-error">
{{ errors.email }}
</span>
</div>
<div class="form-group">
<label for="password">Mot de passe</label>
<input
id="password"
v-model="form.password"
type="password"
required
autocomplete="current-password"
:disabled="loading"
/>
<span v-if="errors.password" class="field-error">
{{ errors.password }}
</span>
</div>
<div class="form-group checkbox">
<label>
<input type="checkbox" v-model="form.rememberMe" />
Se souvenir de moi
</label>
</div>
<button type="submit" :disabled="loading || !isValid">
<span v-if="loading">Connexion en cours...</span>
<span v-else>Se connecter</span>
</button>
<div class="form-links">
<router-link to="/forgot-password">
Mot de passe oublie ?
</router-link>
<router-link to="/register">
Creer un compte
</router-link>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue';
import { useAuth } from '@/composables/useAuth';
import { validateEmail, sanitizeInput } from '@/composables/useFormValidation';
import { securityLogger } from '@/services/securityLogger';
const { loginAndRedirect, error, loading } = useAuth();
const form = reactive({
email: '',
password: '',
rememberMe: false
});
const errors = reactive({
email: '',
password: ''
});
const isValid = computed(() => {
return form.email && form.password && !errors.email && !errors.password;
});
function validateForm(): boolean {
errors.email = '';
errors.password = '';
if (!validateEmail(form.email)) {
errors.email = 'Email invalide';
return false;
}
if (form.password.length < 6) {
errors.password = 'Le mot de passe doit contenir au moins 6 caracteres';
return false;
}
return true;
}
async function handleSubmit() {
if (!validateForm()) return;
const sanitizedEmail = sanitizeInput(form.email);
const success = await loginAndRedirect({
email: sanitizedEmail,
password: form.password,
rememberMe: form.rememberMe
});
if (success) {
securityLogger.log({
type: 'login',
details: { email: sanitizedEmail }
});
} else {
securityLogger.log({
type: 'failed_login',
details: { email: sanitizedEmail }
});
}
}
</script>
<style scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-form {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 400px;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input[type="email"],
.form-group input[type="password"] {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.error-message {
background: #fee;
color: #c00;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.field-error {
color: #c00;
font-size: 0.875rem;
margin-top: 0.25rem;
}
button[type="submit"] {
width: 100%;
padding: 0.75rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: opacity 0.2s;
}
button[type="submit"]:hover:not(:disabled) {
opacity: 0.9;
}
button[type="submit"]:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.form-links {
margin-top: 1rem;
display: flex;
justify-content: space-between;
}
.form-links a {
color: #667eea;
text-decoration: none;
}
.form-links a:hover {
text-decoration: underline;
}
</style>
Conclusion
La securisation d’une application Vue.js avec JWT est un processus multi-facettes qui requiert une attention particuliere a chaque etape du flux d’authentification. Dans ce guide complet, nous avons couvert :
-
La structure et le fonctionnement des JWT : Comprendre les trois parties du token et les claims standards est essentiel pour implementer correctement l’authentification.
-
Le flux d’authentification complet : De la connexion initiale jusqu’a la deconnexion, en passant par le rafraichissement automatique des tokens.
-
Le stockage securise : Les avantages et inconvenients de localStorage, cookies et memory storage, avec une strategie hybride recommandee.
-
Les guards Vue Router : Protection des routes avec verification des roles et permissions.
-
Les interceptors Axios : Injection automatique des tokens et gestion transparente du rafraichissement.
-
La gestion du refresh token : Maintien de sessions longues tout en gardant des access tokens de courte duree.
-
Les bonnes pratiques : HTTPS, expiration appropriee, validation serveur, rate limiting, sanitization et logging.
-
Les pieges courants : Erreurs de securite frequentes et comment les eviter.
L’authentification est un domaine en constante evolution. Restez informe des nouvelles vulnerabilites et mettez regulierement a jour vos implementations. N’hesitez pas a faire auditer votre code par des experts en securite pour les applications critiques.
Points cles a retenir :
- Ne jamais stocker de donnees sensibles dans le JWT
- Toujours utiliser HTTPS en production
- Implementer le rafraichissement automatique des tokens
- Valider les tokens cote serveur ET cote client
- Logger tous les evenements de securite
- Tester regulierement votre implementation
En suivant ces recommandations, vous construirez des applications Vue.js robustes et securisees, offrant a vos utilisateurs une experience fluide tout en protegeant leurs donnees.
In-Article Ad
Dev Mode
Tags
Mahmoud DEVO
Senior Full-Stack Developer
I'm a passionate full-stack developer with 10+ years of experience building scalable web applications. I write about Vue.js, Node.js, PostgreSQL, and modern DevOps practices.
Enjoyed this article?
Subscribe to get more tech content delivered to your inbox.
Related Articles
Synchronisation de l'État Vuex avec localStorage en Vue.js
Apprenez à synchroniser votre store Vuex avec localStorage. Utilisez les watchers pour persister l'état et gérer les sessions utilisateur.
Gestion de Formulaires avec Vuex : Mutations, Getters et Actions
Maîtrisez la gestion des formulaires avec Vuex. Créez des mutations pour chaque champ, des getters pour l'état et des actions pour les API.
Migrer vers la Composition API Vue 3 : Guide Etape par Etape
Migrez vos composants Vue vers la Composition API. useStore, ref, onMounted et lifecycle hooks expliques avec des exemples pratiques.