memberOf. Pour cette démonstration publique, le module LDAP a été remplacé par une authentification locale via la base MySQL avec mots de passe hachés en bcrypt, et les groupes d'accès sont stockés sous forme de colonnes booléennes dans la table utilisateurs. Les données affichées sont fictives et un compte de démonstration demo / demo permet de tester l'application.Conception et développement d'une application web full-stack permettant la gestion centralisée du parc informatique et de l'annuaire utilisateurs de l'entreprise SYMETRIE. Cette application offre une interface moderne et sécurisée pour visualiser, filtrer et administrer les ressources IT (utilisateurs, ordinateurs, téléphones) avec un système d'authentification et de contrôle d'accès basé sur les groupes.
Contexte et problématique métier
Le service informatique de SYMETRIE gérait son parc informatique et son annuaire utilisateurs de manière fragmentée :
- Active Directory — Gestion des comptes et groupes utilisateurs (remplacé ici par une auth locale MySQL)
- Base MySQL — Inventaire des ordinateurs et téléphones
- Fichiers Excel — Suivi manuel des attributions
Problèmes identifiés :
- Pas de vue consolidée des utilisateurs et de leur équipement
- Difficultés pour identifier les ordinateurs non assignés
- Gestion des numéros de téléphone (postes et mobiles) dispersée
- Absence de contrôle d'accès pour les modifications
- Aucun export centralisé pour les audits
Objectifs du projet :
- Centraliser la visualisation des utilisateurs avec leurs ordinateurs et téléphones associés
- Permettre l'assignation/désassignation des ordinateurs aux utilisateurs
- Implémenter une authentification sécurisée avec gestion de session par cookies signés (HMAC-SHA256)
- Restreindre les modifications aux membres du groupe Service Informatique (RBAC)
- Fournir un export PDF de l'annuaire filtré
- Offrir des filtres avancés par activité, type, groupes AD
Architecture technique globale
L'application suit une architecture Next.js App Router full-stack avec séparation claire entre le frontend React et les API Routes backend :
Stack technologique
| Couche | Technologies | Rôle |
|---|---|---|
| Frontend | React 19, TypeScript, CSS Modules | Interface utilisateur réactive et typée |
| Framework | Next.js 16 (App Router) | SSR, API Routes, routing file-based |
| Backend API | Next.js Route Handlers | Endpoints REST sécurisés |
| Base de données | MySQL via mysql2 | Stockage des utilisateurs, ordinateurs, téléphones |
| Authentification | bcryptjs + cookies signés HMAC-SHA256 | Vérification du mot de passe haché et session signée (en prod : ldapts / Active Directory) |
| Validation | Zod | Validation de schémas côté serveur |
| Export | jsPDF, jspdf-autotable | Génération de PDF côté client |
| Conteneurisation | Docker, docker-compose | Déploiement multi-stage optimisé |
Architecture applicative
┌─────────────────────────────────────────────────────────────────────────┐
│ NAVIGATEUR CLIENT │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Annuaire │ │ Ordinateurs │ │ Login │ │
│ │ Page │ │ Page │ │ Modal │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
└──────────┼─────────────────────┼─────────────────────┼───────────────────┘
│ fetch() │ fetch() │ POST
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ NEXT.JS API ROUTES │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ /api/ │ │ /api/ │ │ /api/auth │ │
│ │ annuaire │ │ ordinateurs │ │ (bcrypt+JWT) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ ┌──────┴─────────────────────┴─────────────────────┴───────┐ │
│ │ MIDDLEWARE (auth-middleware.ts) │ │
│ │ • requireAuth() - Vérifie les cookies de session │ │
│ │ • requireGroup() - Vérifie l'appartenance au groupe │ │
│ │ • Rate Limiter - Protection brute force │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ MySQL (Données) │
│ ┌────────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ utilisateurs │ │ ordinateurs│ │ telephones │ │
│ │ (auth+groupes │ │ (parc IT) │ │ (postes/ │ │
│ │ en colonnes) │ │ │ │ mobiles) │ │
│ └────────────────┘ └────────────┘ └────────────┘ │
│ │
│ Auth : bcrypt sur mot_de_passe + colonnes Glo_* pour les groupes │
│ (En production : LDAP / Active Directory remplace cette partie) │
└─────────────────────────────────────────────────────────────────────────┘Structure du projet
src/ ├── app/ # Next.js App Router │ ├── layout.tsx # Layout global (Header) │ ├── page.tsx # Page d'accueil │ ├── annuaire/page.tsx # Page annuaire │ ├── ordinateurs/page.tsx # Page ordinateurs │ ├── unauthorized/page.tsx # Page accès refusé │ └── api/ # Route Handlers (API REST) │ ├── auth/route.ts # POST - Authentification (bcrypt) │ ├── session/route.ts # GET - Vérification session │ ├── health/route.ts # GET - Health check │ ├── annuaire/route.ts # GET - Liste utilisateurs │ ├── ordinateurs/ # GET/PUT - Gestion ordinateurs │ │ ├── route.ts │ │ └── annuaire/route.ts # GET - Liste filtrée + facettes │ ├── telephones/route.ts # GET/PUT - Gestion téléphones │ └── utilisateurs/[id]/mobile/route.ts # PUT - Mise à jour mobile │ ├── components/ # Composants React │ ├── auth/LoginModal.tsx # Modal de connexion │ ├── common/ # Header, HubGrid, SortableHeader… │ └── si/ │ ├── annuaire/ # Tableau, filtres, modals, export │ └── ordinateurs/ # Tableau et filtres ordinateurs │ ├── libs/ # Librairies internes │ ├── db.ts # Pool de connexions MySQL │ ├── api-wrapper.ts # withErrorHandler + ApiError │ └── cookie-signer.ts # Signature HMAC-SHA256 des cookies │ ├── middleware/ # Middlewares serveur │ ├── auth-middleware.ts # requireAuth / requireGroup │ └── rate-limiter.ts # Protection brute force │ ├── services/ │ └── audit-logger.ts # Journalisation des actions │ ├── hooks/ # Hooks React │ ├── useAnnuaire.ts │ ├── useOrdinateursData.ts │ └── useLocalStorage.ts │ ├── constants/index.ts # Constantes (groupes, routes, cookies) ├── validations/index.ts # Schémas Zod ├── utils/ # formatters, pdf-helpers, request-helpers ├── types/index.ts # Types TypeScript partagés └── styles/ # CSS Modules
Fonctionnalités développées
1. Authentification sécurisée (version démo)
Dans cette version de démonstration, l'authentification s'appuie sur la base MySQL : le mot de passe est haché avec bcrypt (10 rounds) et la session est matérialisée par des cookies signés HMAC-SHA256. En production, ce flux est remplacé par un binding LDAP sur l'Active Directory.
// src/app/api/auth/route.ts
import bcrypt from "bcryptjs";
import { signCookie } from "@/libs/cookie-signer";
export async function POST(req: NextRequest) {
return withErrorHandler(async () => {
// 1. Rate limiting par IP (5 tentatives / 15 min)
const clientIp = getClientIp(req.headers);
if (!authRateLimiter.check(clientIp)) {
auditLogger.logRateLimited(clientIp, API_ROUTES.AUTH);
throw new ApiError(ERROR_MESSAGES.RATE_LIMITED(...), 429);
}
// 2. Validation Zod du body
const parsed = AuthSchema.safeParse(await req.json());
if (!parsed.success) throw new ApiError("Identifiants requis", 400);
const { username, password } = parsed.data;
// 3. Lecture utilisateur + comparaison bcrypt
const [rows] = await getPool().query<RowDataPacket[]>(
"SELECT *, mot_de_passe FROM utilisateurs WHERE samaccountname = ?",
[username]
);
const dbUser = rows[0];
if (!dbUser || !(await bcrypt.compare(password, dbUser.mot_de_passe))) {
auditLogger.logAuthFailed(username, clientIp);
throw new ApiError("Identifiants invalides", 401);
}
// 4. Reconstruction des groupes depuis les colonnes Glo_* de la DB
const groups: string[] = [];
for (const { column, group } of DB_GROUP_COLUMNS) {
if (flagOn(dbUser[column])) groups.push(group);
}
// 5. Cookies signés HMAC-SHA256 (httpOnly, secure, sameSite=strict)
const response = NextResponse.json({ ok: true, username, groupes: groups });
response.cookies.set("auth_token", signCookie(username), COOKIE_OPTS);
response.cookies.set("auth_groups", signCookie(JSON.stringify(groups)), COOKIE_OPTS);
return response;
})(req);
}2. Middleware d'authentification et contrôle d'accès
// src/middleware/auth-middleware.ts
import { unsignCookie } from "@/libs/cookie-signer";
export interface AuthContext {
username: string;
groups: string[];
ip: string;
}
// Lit et vérifie les cookies signés (HMAC-SHA256)
export function parseSessionFromCookies(req: NextRequest) {
const cookies = parseCookies(req);
const username = cookies["auth_token"]
? unsignCookie(cookies["auth_token"]) // null si signature invalide
: null;
const groupsJson = cookies["auth_groups"]
? unsignCookie(cookies["auth_groups"])
: null;
const groups = groupsJson ? JSON.parse(groupsJson) : [];
return { username, groups };
}
// Vérifie que l'utilisateur est authentifié
export function requireAuth(req: NextRequest): AuthContext {
const { username, groups } = parseSessionFromCookies(req);
if (!username) throw new ApiError("Non autorisé", 401);
return { username, groups, ip: getClientIp(req.headers) };
}
// Vérifie l'appartenance à au moins un des groupes requis (RBAC)
export function requireGroup(req: NextRequest, allowed: string[]): AuthContext {
const auth = requireAuth(req);
if (!auth.groups.some(g => allowed.includes(g))) {
throw new ApiError("Accès interdit", 403);
}
return auth;
}3. API Annuaire avec jointures multiples
// src/app/api/annuaire/route.ts
export async function GET(req: NextRequest) {
return withErrorHandler(async () => {
requireAuth(req); // Vérification authentification
const pool = getPool();
const [rows] = await pool.execute<RowDataPacket[]>(
`SELECT ${USER_COLUMNS_NO_PASSWORD} FROM utilisateurs ORDER BY nom, prenom`
);
// Normalisation des flags binaires (BIT(1) MySQL)
const normalized = rows.map((r) => ({
...r,
isStagiaire: flagOn(r["Glo_Stagiaire"]),
}));
return NextResponse.json(normalized);
})(req);
}4. Gestion des ordinateurs avec filtres dynamiques
// src/app/api/ordinateurs/annuaire/route.ts
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const type = searchParams.get("type"); // Station, Serveur, etc.
const status = searchParams.get("status"); // assigned, non-assigned
const os = searchParams.get("os"); // Windows 10, Windows 11...
const sortBy = searchParams.get("sortBy");
const sortDir = searchParams.get("sortDir");
// Construction dynamique de la requête SQL
let query = `
SELECT o.*, u.prenom, u.nom as nom_utilisateur, u.activite
FROM ordinateurs o
LEFT JOIN utilisateurs u ON o.utilisateur_id = u.id
WHERE 1=1
`;
const params: any[] = [];
if (type) {
query += " AND o.type = ?";
params.push(type);
}
if (status === "assigned") {
query += " AND o.utilisateur_id IS NOT NULL";
} else if (status === "non-assigned") {
query += " AND o.utilisateur_id IS NULL";
}
// ... autres filtres
const [rows] = await pool.execute(query, params);
// Calcul des facettes pour les filtres
const facets = {
types: [...new Set(rows.map(r => r.type))],
osList: [...new Set(rows.map(r => r.systeme_exploitation))],
stats: {
total: rows.length,
assigned: rows.filter(r => r.utilisateur_id).length,
nonAssigned: rows.filter(r => !r.utilisateur_id).length
}
};
return NextResponse.json({ computers: rows, facets });
}5. Export PDF avec jsPDF-AutoTable
// src/components/si/annuaire/ExportPdfButton.tsx
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
function exportToPdf(data: Utilisateur[], phonesByUserId: Record<number, Telephone[]>) {
const doc = new jsPDF({ orientation: "landscape" });
// En-tête
doc.setFontSize(18);
doc.text("Annuaire SYMETRIE", 14, 20);
doc.setFontSize(10);
doc.text(`Généré le ${new Date().toLocaleDateString("fr-FR")}`, 14, 28);
// Préparation des données
const tableData = data.map(user => {
const phones = phonesByUserId[user.id] || [];
const postes = phones.map(p => p.poste).filter(Boolean).join(", ");
const lignes = phones.map(p => p.lignes_internes).filter(Boolean).join(", ");
return [
user.trigramme,
user.prenom,
user.nom,
user.samaccountname,
user.mobiles || "",
postes,
lignes,
user.activite
];
});
// Génération du tableau
autoTable(doc, {
head: [["Trigramme", "Prénom", "Nom", "Login", "Mobile", "Poste", "Ligne", "Statut"]],
body: tableData,
startY: 35,
styles: { fontSize: 8 },
headStyles: { fillColor: [41, 128, 185] }
});
doc.save("annuaire-symetrie.pdf");
}Sécurité implémentée
Rate Limiting
// src/middleware/rate-limiter.ts
class RateLimiter {
private attempts: Map<string, { count: number; resetTime: number }> = new Map();
check(ip: string): boolean {
const now = Date.now();
const record = this.attempts.get(ip);
if (!record || now > record.resetTime) {
this.attempts.set(ip, { count: 1, resetTime: now + 60000 }); // 1 minute
return true;
}
if (record.count >= 5) { // Max 5 tentatives par minute
return false;
}
record.count++;
return true;
}
}Cookies sécurisés
| Option | Valeur | Protection |
|---|---|---|
httpOnly | true | Protection XSS (non accessible via JavaScript) |
secure | true | Transmission HTTPS uniquement |
sameSite | strict | Protection CSRF |
maxAge | 28800 (8h) | Expiration automatique de session |
Conteneurisation Docker
Dockerfile (Multi-stage build)
# Stage 1: Installation des dépendances FROM node:20-alpine AS deps RUN apk add --no-cache libc6-compat WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci # Stage 2: Build de l'application FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build # Stage 3: Image de production optimisée FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static USER nextjs EXPOSE 3000 ENV PORT=3000 CMD ["node", "server.js"]
docker-compose.yml
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: si-dashboard
ports:
- "8080:3000"
environment:
- NODE_ENV=production
- DB_HOST=mysql-server
- DB_PORT=3306
- DB_NAME=si_database
- LDAP_URL=ldap://domaine.local
restart: unless-stoppedInterface utilisateur
Page Annuaire — Fonctionnalités
- Tableau interactif avec tri par colonne (nom, prénom, trigramme...)
- Filtres latéraux :
- Par activité (Actif / Inactif)
- Par type (Utilisateur / Stagiaire)
- Par groupes AD (sélection multiple)
- Barre de recherche full-text sur tous les champs
- Affichage inline des ordinateurs et téléphones
- Actions conditionnelles (SI uniquement) :
- Assigner/Désassigner un ordinateur
- Modifier les téléphones (postes, lignes internes)
- Modifier le numéro de mobile
- Export PDF avec filtres appliqués
- Persistance des filtres via localStorage
Composants React principaux
| Composant | Responsabilité |
|---|---|
AnnuaireTable | Rendu du tableau avec colonnes dynamiques (groupes AD) |
FiltersSidebar | Panneau latéral avec checkboxes de filtrage |
Toolbar | Barre de recherche et compteur de résultats |
AssignPcModal | Modal de sélection d'ordinateur à assigner |
PhonesEditorModal | Édition des téléphones fixes (postes + lignes) |
MobileEditorModal | Édition du numéro de mobile |
ExportPdfButton | Génération et téléchargement du PDF |
LoginModal | Formulaire d'authentification |
Compétences techniques mobilisées
| Domaine | Technologies / Compétences |
|---|---|
| Développement Frontend | React 19, TypeScript, Hooks (useState, useEffect, useMemo), CSS Modules |
| Développement Backend | Next.js API Routes, REST, Middleware, Gestion des erreurs |
| Base de données | MySQL, Pool de connexions, Requêtes paramétrées, Jointures |
| Authentification | bcrypt (hachage), Cookies signés HMAC-SHA256, Sessions stateless (LDAP/AD en production) |
| Sécurité | Rate limiting, Validation Zod, RBAC (contrôle par groupes), XSS/CSRF |
| DevOps | Docker multi-stage, docker-compose, Variables d'environnement |
| UX/UI | Interface responsive, Modals, Filtres persistants, Export PDF |
| Qualité de code | TypeScript strict, ESLint, Architecture modulaire, Types partagés |