← Retour aux projets
Projet E5

SI Dashboard - Application Web de Gestion du Parc Informatique - Version production - E5

Application web full-stack de gestion du parc informatique de SYMETRIE : authentification LDAPS, RBAC par groupes Active Directory et persistance JSON atomique, livrée en conteneur Docker avec variables d'environnement externalisées.

Next.js 16React 19TypeScriptLDAP / Active DirectoryZodDocker

Conception et développement d'une application web full-stack destinée au service informatique de SYMETRIE pour centraliser la gestion du parc IT (utilisateurs, ordinateurs, téléphones fixes et mobiles). L'application s'appuie sur l'Active Directory comme source de vérité, expose une interface React moderne, et restreint les opérations sensibles via un RBAC par groupes AD. Cette version E5 correspond au déploiement en production interne : authentification LDAPS réelle, persistance applicative par store JSON atomique, et livraison conteneurisée via Docker avec variables d'environnement externalisées (.env.production).


Contexte et problématique métier

Avant le projet, le SI gérait son parc et son annuaire de manière fragmentée :

  • Active Directory — Comptes, groupes et inventaire des postes (objets computer)
  • Fichiers Excel — Suivi manuel des attributions d'ordinateurs et des numéros de téléphone

Problèmes identifiés :

  • Pas de vue consolidée utilisateur ↔ équipement
  • Difficultés pour identifier les ordinateurs non assignés
  • Gestion des téléphones (postes et mobiles) dispersée
  • Absence de contrôle d'accès et d'audit pour les modifications
  • Aucun export centralisé

Objectifs du projet :

  • Centraliser la visualisation des utilisateurs avec leurs ordinateurs et téléphones associés, en lisant directement les objets user et computer de l'AD
  • Permettre l'assignation/désassignation des ordinateurs sans modifier l'AD, via une persistance applicative dédiée
  • Implémenter une authentification LDAPS avec session signée HMAC-SHA256 dans un cookie httpOnly + secure + sameSite=lax
  • Restreindre les écritures aux groupes AD Glo_ServiceInfo et Administrateurs (RBAC)
  • Fournir des filtres avancés persistants et un export PDF de l'annuaire

Architecture technique globale

L'application suit une architecture Next.js App Router full-stack organisée en couches strictes — composants React, Route Handlers, services métier, repositories (AD ou JSON) — avec les préoccupations transverses (sécurité, audit, validation, gestion d'erreur) factorisées dans src/libs/ et composées autour de chaque handler.

Stack technologique
CoucheTechnologiesRôle
FrontendReact 19, TypeScript, CSS ModulesUI réactive et typée
FrameworkNext.js 16 (App Router)SSR, Route Handlers REST, routing file-based
Source de véritéldapts (LDAPS)Lecture des objets AD user, computer, group, groupes via memberOf
Persistance applicativeJsonFileStore (Node fs)Assignations + téléphones + mobiles (links.json) — écriture atomique + cache mémoire
SessionHMAC-SHA256 (Node crypto)Token signé, vérification timingSafeEqual, secret rotatif
ValidationZodSchémas serveur + types inférés (z.infer)
ExportjsPDF, jspdf-autotableGénération de PDF côté client
ConteneurisationDocker, docker-compose, .env.productionBuild multi-stage, variables externalisées via env_file, volume persistant /app/data
Architecture applicative
┌─────────────────────────────────────────────────────────────────────────┐
│                         NAVIGATEUR CLIENT                                │
├─────────────────────────────────────────────────────────────────────────┤
│   ┌──────────────┐      ┌──────────────┐      ┌──────────────┐          │
│   │   Annuaire   │      │  Ordinateurs │      │    Login     │          │
│   │    Page      │      │     Page     │      │    Modal     │          │
│   └──────┬───────┘      └──────┬───────┘      └──────┬───────┘          │
│          │ hooks/useAnnuaireData       hooks/useOrdinateursData          │
└──────────┼─────────────────────┼─────────────────────┼───────────────────┘
           │ fetch JSON          │ fetch JSON          │ POST credentials
           ▼                     ▼                     ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                      NEXT.JS ROUTE HANDLERS                              │
├─────────────────────────────────────────────────────────────────────────┤
│  withErrorHandler  →  CSRF check  →  requireAuth/Group  →  Zod schema   │
│                                  ↓                                       │
│  ┌──────────────────────────────────────────────────────────────────┐   │
│  │                        SERVICES (logique métier)                  │   │
│  │  annuaire · ordinateurs · telephones · mobiles · auth             │   │
│  └────────┬───────────────────────────────────────────────┬─────────┘   │
│           ▼                                               ▼              │
│  ┌────────────────────┐                       ┌────────────────────┐    │
│  │ REPOSITORIES (AD)  │                       │ REPOSITORIES (JSON)│    │
│  │ annuaire-repo      │                       │ links-store        │    │
│  │ ordinateurs-repo   │                       │ (assignations,     │    │
│  │ auth-repo          │                       │  phones, mobiles)  │    │
│  └────────┬───────────┘                       └─────────┬──────────┘    │
└───────────┼─────────────────────────────────────────────┼───────────────┘
            ▼                                             ▼
┌───────────────────────────────┐    ┌─────────────────────────────────┐
│  Active Directory (LDAPS)     │    │   JsonFileStore (fs + cache)     │
│  • Bind sAMAccountName        │    │  • Écriture atomique (tmp+rename)│
│  • memberOf → groupes (CN)    │    │  • Invalidation par mtime        │
│  • Objets user + computer     │    │  • Volume Docker /app/data       │
└───────────────────────────────┘    └─────────────────────────────────┘
Pipeline d'une requête sensible

Exemple complet d'une opération d'écriture : POST /api/si/ordinateurs (assignation d'un poste). La requête traverse successivement chaque couche transverse avant d'atteindre le store JSON par une opération compare-and-swap atomique :

  1. withErrorHandler — try/catch global, vérification CSRF (Origin vs Host), mapping des erreurs (ZodError → 400, ApiError → status, reste → 500) et injection d'un X-Correlation-Id.
  2. requireAnyGroup — lecture et vérification HMAC du cookie de session, RBAC sur Glo_ServiceInfo ou Administrateurs (401/403 en cas d'échec).
  3. Validation Zod du corps de la requête.
  4. Service métier : transaction compare-and-swap sur linksStore + invalidation du cache TTL des ordinateurs.
  5. Audit log structuré (action, user, ip, correlationId) puis réponse normalisée apiSuccess.

Structure du projet

src/
├── app/                                    # Next.js App Router
│   ├── layout.tsx                          # Layout global (Header)
│   ├── page.tsx                            # Hub d'accueil
│   ├── si/
│   │   ├── annuaire/page.tsx               # Page annuaire utilisateurs
│   │   ├── ordinateurs/page.tsx            # Page parc informatique
│   │   └── unauthorized/page.tsx           # Page d'accès refusé (403)
│   └── api/                                # Route Handlers (REST)
│       ├── auth/route.ts                   # POST   - Authentification LDAPS
│       ├── logout/route.ts                 # POST   - Déconnexion
│       ├── session/route.ts                # GET    - État de session
│       ├── health/route.ts                 # GET    - Health check
│       └── si/
│           ├── annuaire/route.ts           # GET    - Annuaire consolidé
│           ├── ordinateurs/route.ts        # GET / POST / DELETE
│           ├── telephones/route.ts         # GET / POST / PUT / DELETE
│           └── mobiles/route.ts            # GET / PUT
│
├── components/
│   ├── auth/LoginModal.tsx                 # Modal de connexion AD
│   ├── common/                             # Header, BaseModal, Toolbar…
│   └── si/
│       ├── annuaire/                       # Table, filtres, modals, export PDF
│       └── ordinateurs/                    # Table, sidebar de filtres
│
├── hooks/                                  # Hooks React custom
│   ├── useSession.ts                       # État de session + login/logout
│   ├── useAnnuaireData.ts                  # Chargement annuaire + dérivations
│   ├── useAnnuaireFilters.ts               # Filtres + persistance localStorage
│   ├── useOrdinateursData.ts               # Fetch + facettes + tri serveur
│   ├── useApiMutation.ts                   # Helper de mutation typée
│   └── useLocalStorage.ts                  # Persistance localStorage SSR-safe
│
├── services/                               # Logique métier + caches TTL
│   ├── auth-service.ts
│   ├── annuaire-service.ts
│   ├── ordinateurs-service.ts              # Cache 30s par signature de query
│   ├── telephones-service.ts
│   └── mobiles-service.ts
│
├── repositories/                           # Accès aux sources de données
│   ├── auth-repository.ts                  # Bind LDAP + lecture memberOf
│   ├── annuaire-repository.ts              # AD users + cache TTL
│   ├── ordinateurs-repository.ts           # AD computers + cache TTL
│   ├── telephones-repository.ts            # links.json (phones[])
│   └── mobiles-repository.ts               # links.json (mobiles{})
│
├── schemas/                                # Schémas Zod (types inférés)
│   ├── annuaire.schema.ts
│   ├── ordinateurs.schema.ts
│   ├── telephones.schema.ts
│   ├── mobiles.schema.ts
│   └── api-response.schema.ts
│
├── libs/                                   # Cross-cutting
│   ├── api-wrapper.ts                      # withErrorHandler, apiSuccess/Failure, CSRF
│   ├── auth-middleware.ts                  # requireAuth / requireGroup / requireAnyGroup
│   ├── session.ts                          # createSessionToken / verifySessionToken (HMAC)
│   ├── rate-limiter.ts                     # Anti brute-force (5/15 min)
│   ├── audit-logger.ts                     # Journalisation JSON ligne
│   ├── ad-client.ts                        # Client LDAPS + résolution secrets
│   ├── validations.ts                      # Schémas Zod transverses
│   └── stores/
│       ├── json-file-store.ts              # Store générique atomique
│       └── links-store.ts                  # links.json typé
│
├── constants/                              # access (RBAC), api, session, ui
├── types/                                  # Types TS partagés
├── utils/                                  # ad-utils, string-utils, pdf-utils…
└── data/links.json                         # Persistance (volume Docker /app/data)

Fonctionnalités développées

1. Authentification LDAPS / Active Directory

L'authentification réalise un bind LDAPS contre l'AD de SYMETRIE via ldapts, lit les groupes depuis l'attribut memberOf de l'entrée AD et émet un token signé HMAC-SHA256 stocké dans un cookie sécurisé. Le flux est protégé par un rate limiter applicatif et tracé par l'audit logger.

// src/app/api/auth/route.ts (extraits clés)
export const POST = withErrorHandler(async (req: NextRequest) => {
  const clientIp = getClientIp(req.headers);
  const correlationId = getCorrelationId(req.headers);

  // 1. Rate limiting par IP (5 tentatives / 15 min)
  if (!authRateLimiter.check(clientIp)) {
    const retryAfter = authRateLimiter.getTimeUntilReset(clientIp);
    auditLogger.audit("AUTH_RATE_LIMITED", { ip: clientIp, correlationId, success: false });
    return apiFailure(`Trop de tentatives. Réessayez dans ${retryAfter} secondes.`,
      { status: HTTP_STATUS.TOO_MANY_REQUESTS, headers: { "Retry-After": retryAfter.toString() } });
  }

  // 2. Validation Zod du body
  const { username, password } = await validateRequest(req, AuthSchema);

  // 3. Bind LDAPS + lecture memberOf via le service métier
  const authResult = await authenticateCredentials(username, password);
  const groups = authResult.groups;

  // 4. Reset rate limiter + audit + token signé HMAC-SHA256
  authRateLimiter.reset(clientIp);
  auditLogger.audit("AUTH_SUCCESS", { username, ip: clientIp, correlationId, details: { groups } });
  const sessionToken = createSessionToken(username, groups, COOKIE_CONFIG.MAX_AGE);

  // 5. Cookie sécurisé httpOnly + secure + sameSite=lax + maxAge 2h
  const response = apiSuccess({ ok: true, username, groups });
  response.cookies.set(COOKIE_CONFIG.SESSION, sessionToken, {
    httpOnly: COOKIE_CONFIG.HTTP_ONLY, secure: COOKIE_CONFIG.SECURE,
    sameSite: COOKIE_CONFIG.SAME_SITE, path: COOKIE_CONFIG.PATH,
    maxAge: COOKIE_CONFIG.MAX_AGE,
  });
  return response;
});
2. Session signée HMAC-SHA256 (vérification timing-safe)

Plutôt qu'un simple cookie contenant un identifiant, l'application signe son propre token au format base64url(payload).signature avec payload = { username, groups, exp }. La vérification utilise crypto.timingSafeEqual (anti-attaque par chronométrage). Le secret est résolu via Docker secret → fichier SESSION_SECRET_FILE → variable d'environnement et mis en cache 5 minutes pour autoriser la rotation sans redémarrer le service.

// src/libs/session.ts (extraits)
export function createSessionToken(username: string, groups: string[], maxAgeSeconds: number): string {
  const payload = { username, groups, exp: Date.now() + maxAgeSeconds * 1000 };
  const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
  const signature  = createHmac("sha256", getSecret()).update(payloadB64).digest("hex");
  return `${payloadB64}.${signature}`;
}

export function verifySessionToken(token: string): SessionPayload | null {
  const [payloadB64, signature] = token.split(".");
  if (!payloadB64 || !signature) return null;

  const expected = createHmac("sha256", getSecret()).update(payloadB64).digest("hex");
  if (!timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"))) return null;

  const payload = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8"));
  if (payload.exp < Date.now()) return null;          // expiration
  return payload;
}
3. Middleware d'authentification et RBAC

Chaque route sensible traverse requireAuth(), requireGroup() ou requireAnyGroup(). Le cookie de session est lu et vérifié, puis l'appartenance à un groupe AD précis est exigée. Les exceptions ApiError sont propagées à withErrorHandler qui les convertit en réponses HTTP standardisées.

// src/libs/auth-middleware.ts
export interface AuthContext {
  username: string;
  groups:   string[];
  ip:       string;
}

export function requireAuth(req: NextRequest | Request): AuthContext {
  const sessionCookie = getSessionCookie(req);
  if (!sessionCookie) throw new ApiError(ERROR_MESSAGES.UNAUTHORIZED, HTTP_STATUS.UNAUTHORIZED);
  const payload = verifySessionToken(sessionCookie);          // HMAC + exp + timing-safe
  if (!payload) throw new ApiError(ERROR_MESSAGES.UNAUTHORIZED, HTTP_STATUS.UNAUTHORIZED);
  return { username: payload.username, groups: payload.groups, ip: getClientIp(req.headers) };
}

// RBAC : impose l'appartenance à au moins un groupe parmi une liste
export function requireAnyGroup(req: NextRequest | Request, allowedGroups: string[]): AuthContext {
  const auth = requireAuth(req);
  if (!auth.groups.some(g => allowedGroups.includes(g)))
    throw new ApiError(ERROR_MESSAGES.FORBIDDEN, HTTP_STATUS.FORBIDDEN);
  return auth;
}
4. Groupes AD autorisés

L'accès en lecture est réservé aux membres des groupes ci-dessous. Les opérations d'écriture (CRUD) sont restreintes à Glo_ServiceInfo ou Administrateurs.

// src/constants/access.ts
// Groupes AD autorisés à accéder à l'application
export const GROUPS_AUTORISES = [
  "Administrateurs",
  "Glo_Symetrie",
] as const;

// Groupes requis pour le CRUD sur les ressources SI
export const GROUP_SERVICE_INFO = "Glo_ServiceInfo";
export const GROUP_DIRECTION    = "Administrateurs";
5. Client LDAPS avec anti-injection (RFC 4515)

Tous les accès à l'Active Directory passent par un client centralisé qui force la connexion LDAPS, vérifie le certificat TLS, et expose un helper anti-injection. Toute valeur utilisateur interpolée dans un filtre LDAP est échappée en séquences hexadécimales (\hh), neutralisant les caractères de contrôle * ( ) \ NUL conformément à la RFC 4515.

// src/libs/ad-client.ts (extraits)
function readSecret(envVar: string, secretName?: string): string {
  // Priorité : Docker secret → fichier *_FILE → variable d'env
  if (secretName && existsSync(`/run/secrets/${secretName}`))
    return readFileSync(`/run/secrets/${secretName}`, "utf-8").trim();
  const fileEnv = process.env[`${envVar}_FILE`];
  if (fileEnv && existsSync(fileEnv)) return readFileSync(fileEnv, "utf-8").trim();
  return process.env[envVar] || "";
}

// Anti-injection LDAP (RFC 4515)
export function escapeLdapFilter(input: string): string {
  let out = "";
  for (const ch of input) {
    if ("\\*()/\0".includes(ch))
      out += "\\" + ch.charCodeAt(0).toString(16).padStart(2, "0");
    else out += ch;
  }
  return out;
}

// Bind et requêtes typées (lecture user / computer / group)
export async function getAllADUsers():     Promise<ADUser[]>     { /* ... */ }
export async function getAllADComputers(): Promise<ADComputer[]> { /* ... */ }
export async function getAllADGroups():    Promise<ADGroup[]>    { /* ... */ }
6. Persistance : JsonFileStore générique (atomique + cache mtime)

Aucune base relationnelle : les utilisateurs, ordinateurs et groupes sont lus dans l'AD ; seules les données métier qui n'y existent pas — assignations d'ordinateurs, postes fixes, numéros mobiles — sont persistées dans links.json. Le store générique combine cache mémoire invalidé par mtime, écriture atomique POSIX (fichier temporaire + rename) et transactions compare-and-swap idéales pour les assignations concurrentes.

// src/libs/stores/json-file-store.ts (extraits)
export class JsonFileStore<T> {
  private _cache: T | null = null;
  private _lastModified = 0;

  read(): T {
    const mtime = getFileMtime(this._filePath);
    if (this._cache && mtime <= this._lastModified) return this._cache;
    try {
      this._cache = JSON.parse(readFileSync(this._filePath, "utf-8"));
      this._lastModified = mtime;
      return this._cache!;
    } catch {
      // Fallback : valeurs par défaut si JSON corrompu
      return (this._cache = structuredClone(this._default));
    }
  }

  write(data: T): void {
    const tmp = `${this._filePath}.tmp`;
    writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
    renameSync(tmp, this._filePath);  // remplacement atomique POSIX
    this._cache = data;
  }

  // Lecture + mutation atomique avec verrouillage optimiste (mtime compare-and-swap)
  transaction<R>(mutator: (current: T) => { next: T; result: R }): R {
    const mtimeBefore = getFileMtime(this._filePath);
    const current = this.read();
    const { next, result } = mutator(structuredClone(current));
    const mtimeNow = getFileMtime(this._filePath);
    if (mtimeNow !== mtimeBefore && mtimeNow !== this._lastModified) {
      throw new Error("[JsonFileStore] Conflit d'écriture concurrent. Réessayez.");
    }
    this.write(next);
    return result;
  }
}
7. Réponse API standardisée (apiSuccess / apiFailure)

Toutes les routes répondent dans un format unique { data, meta? } en succès et { error, details? } en erreur. Le wrapper withErrorHandler mappe automatiquement ZodError → 400, ApiError → status, et tout autre rejet → 500, avec corrélation tracée et CSRF vérifié en amont.

// src/libs/api-wrapper.ts (extraits)
export class ApiError extends Error {
  constructor(message: string, public statusCode = 500,
              public details?: Record<string, unknown> | Array<{ field: string; message: string }>) {
    super(message);
  }
}

export function apiSuccess<T>(data: T, opts?: { status?: number; meta?: unknown; headers?: HeadersInit }) {
  const body = opts?.meta ? { data, meta: opts.meta } : { data };
  return NextResponse.json(body, { status: opts?.status ?? 200, headers: opts?.headers });
}

export function withErrorHandler(handler: ApiHandler) {
  return async (req: NextRequest, context: unknown) => {
    const correlationId = getCorrelationId(req.headers);
    try {
      checkCsrfOrigin(req);                                          // Origin vs Host
      const response = await handler(req, context);
      response.headers.set("x-correlation-id", correlationId);     // propagation sur succès
      return response;
    } catch (error) {
      if (error instanceof z.ZodError)
        return apiFailure("Données invalides", { status: 400, details: error.issues,
                          headers: { "x-correlation-id": correlationId } });
      if (error instanceof ApiError)
        return apiFailure(error.message, { status: error.statusCode, details: error.details,
                          headers: { "x-correlation-id": correlationId } });
      console.error(error);
      return apiFailure(ERROR_MESSAGES.INTERNAL_ERROR, { status: 500,
                        headers: { "x-correlation-id": correlationId } });
    }
  };
}
8. API Annuaire — consolidation AD + liens applicatifs

Le service annuaire compose en parallèle les utilisateurs et groupes AD (avec cache TTL côté repository) et les données applicatives lues depuis links.json (téléphones, mobiles, assignations d'ordinateurs) pour produire une vue consolidée par utilisateur.

// src/app/api/si/annuaire/route.ts
export const GET = withErrorHandler(async (req: NextRequest) => {
  requireAuth(req);
  const url = new URL(req.url);
  const { page, limit } = AnnuaireQuerySchema.parse({
    page:  url.searchParams.get("page")  ?? undefined,
    limit: url.searchParams.get("limit") ?? undefined,
  });
  const { users, total } = await getAnnuaireUsers(page, limit);
  const totalPages = Math.max(1, Math.ceil(total / limit));
  return apiSuccess(users, { meta: { page, limit, total, totalPages } });
});

// src/services/annuaire-service.ts (extrait)
export async function getAnnuaireUsers(page = 1, limit = 5000): Promise<{ users: Utilisateur[]; total: number }> {
  // getCachedAnnuaireUsers() lit l'AD + overrides mobiles du JSON store,
  // trie par nom/prénom et met en cache TTL 60 s
  const users = await getCachedAnnuaireUsers();
  const start = (Math.max(1, page) - 1) * limit;
  return { users: users.slice(start, start + limit), total: users.length };
}

async function getCachedAnnuaireUsers(): Promise<Utilisateur[]> {
  const cached = getAnnuaireUsersCache();
  if (cached) return cached;

  const { adUsers, mobilesMap } = await getAnnuaireSourceData();
  const normalized = adUsers
    .map(u => ({ ...u, mobiles: mobilesMap[u.samaccountname] || u.mobile || "" }))
    .sort((a, b) => (a.nom || "").localeCompare(b.nom || "") || (a.prenom || "").localeCompare(b.prenom || ""));

  const users = AnnuaireUsersSchema.parse(normalized);
  setAnnuaireUsersCache(users, Date.now() + CACHE_TTL_MS);
  return users;
}
9. API Ordinateurs — filtres, tri, pagination, facettes + cache TTL

La route /api/si/ordinateurs supporte filtres dynamiques (type, OS, statut assigned/free), tri serveur par Intl.Collator("fr"), pagination contrôlée et facettes calculées côté serveur (compteurs par filtre). Les réponses sont mises en cache 30 s par signature de requête (clé = JSON.stringify(query)) et invalidées immédiatement sur toute écriture, réduisant fortement la charge LDAP.

// src/services/ordinateurs-service.ts (extraits)
const ordinateursResponseCache = new Map<string, { value: OrdinateursResponse; expiresAt: number }>();
const ORDINATEURS_CACHE_TTL_MS = 30 * 1000;

export async function getOrdinateursResponse(query: OrdinateursQuery) {
  // Cache par signature de query (LRU souple, TTL 30s)
  const key    = JSON.stringify(query);
  const cached = ordinateursResponseCache.get(key);
  if (cached && cached.expiresAt > Date.now()) return cached.value;

  const [adComputers, adUsers] = await Promise.all([listAdComputers(), listAdUsers()]);
  const assignments = getAssignmentsMap();                       // links.json
  const usersMap    = new Map(adUsers.map(u => [u.samaccountname, u]));

  // Enrichissement : croisement AD ↔ assignations
  let computers = adComputers.map(c => {
    const sam  = assignments[c.nom] ?? null;
    const user = sam ? usersMap.get(sam) : null;
    return {
      ...c,
      assigned_to:     sam,
      prenom:          user?.prenom    ?? null,
      nom_utilisateur: user?.nom       ?? null,
      activite:        user?.activite  ?? null,
    };
  });

  // Filtres + tri (collator fr) + pagination + facettes (compteurs)
  computers = applyFilters(computers, query);
  const facets = computeFacets(computers);
  computers = sortAndPaginate(computers, query);

  const result = { computers, facets, totalFiltered: facets.total };
  ordinateursResponseCache.set(key, { value: result, expiresAt: Date.now() + ORDINATEURS_CACHE_TTL_MS });
  return result;
}

export function invalidateOrdinateursCache() {
  ordinateursResponseCache.clear();
}
10. Assignation d'ordinateur — transaction atomique

L'assignation d'un poste à un utilisateur s'effectue dans une transaction compare-and-swap qui rejette les assignations concurrentes (poste déjà attribué) et invalide le cache TTL des ordinateurs avant de tracer l'action dans l'audit log.

// src/app/api/si/ordinateurs/route.ts (extrait POST)
export const POST = withErrorHandler(async (req: NextRequest) => {
  const auth = requireAnyGroup(req, [GROUP_SERVICE_INFO, GROUP_DIRECTION]);
  const correlationId = getCorrelationId(req.headers);
  const { computer_name, samaccountname } = await validateRequest(req, AssignComputerSchema);

  // Le service vérifie que le poste est libre (throw ApiError 409 sinon)
  // et délègue à assignComputerToUserIfFree() dans le repository (atomique)
  const assignedComputer = await assignOrdinateur(computer_name, samaccountname);

  auditLogger.audit("COMPUTER_ASSIGN", {
    username: auth.username, ip: auth.ip, correlationId,
    details: { computerName: computer_name, samaccountname },
  });
  return apiSuccess(assignedComputer, { status: HTTP_STATUS.CREATED });
});
11. CRUD téléphones et mobiles

Les téléphones fixes (postes + lignes internes) et les numéros mobiles sont stockés dans links.json et exposés par deux routes multi-méthodes :

  • /api/si/telephonesGET (tout authentifié), POST, PUT, DELETE (groupes d'écriture). Les opérations détectent les conflits (duplicate_poste, duplicate_lignes_internes) et retournent 409.
  • /api/si/mobilesGET et PUT pour associer un numéro de mobile à un samAccountName.
12. Validation des entrées avec Zod

Chaque endpoint d'écriture déclare son schéma Zod ; la validation est exécutée avant tout accès aux repositories. Les types TypeScript du domaine sont inférés directement (z.infer), garantissant la cohérence entre validation runtime et typage compile-time.

// src/libs/validations.ts (extraits)
export const AuthSchema = z.object({
  username: z.string().trim().min(1).max(100),
  password: z.string().min(1).max(500),
});

export const AssignComputerSchema = z.object({
  computer_name:  z.string().trim().min(1).max(100),
  samaccountname: z.string().trim().min(1).max(100),
});

export const CreateTelephoneSchema = z.object({
  poste:           z.string().trim().min(1).max(2).regex(/^[0-9]+$/),
  lignes_internes: z.string().trim().length(14).regex(/^\d{2}( \d{2}){4}$/),
  samaccountname:  z.string().trim().min(1).max(100),
});

// Mapping rétro-compatible FR → EN pour le paramètre status
const statusMapping: Record<string, string> = {
  "Tous": "all",
  "Affecté": "assigned", "Affecte": "assigned", "assigned": "assigned",
  "Non affecté": "free", "Non affecte": "free", "": "all",
};

export const OrdinateursQuerySchema = z.object({
  type:    z.string().optional(),
  status:  z.string().transform(v => statusMapping[v] ?? v)
            .pipe(z.enum(["all", "assigned", "free"])).default("all"),
  os:      z.string().optional(),
  sortBy:  z.enum(["nom", "type", "os", "utilisateur"]).default("nom"),
  sortDir: z.enum(["asc", "desc"]).default("asc"),
  page:    z.coerce.number().int().min(1).default(1),
  limit:   z.coerce.number().int().min(1).max(10000).default(5000),
});
13. Export PDF avec jsPDF-AutoTable
// src/components/si/annuaire/ExportPdfButton.tsx
export default function ExportPdfButton({ data, phonesBySam = {}, fileName = "annuaire_utilisateurs" }: Props) {
  const onExport = async () => {
    const { jsPDF } = await import("jspdf");
    const autoTable = (await import("jspdf-autotable")).default;

    const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
    const startY = addPdfHeader(doc, { title: "Annuaire", itemCount: data.length,
                                       itemLabel: "Nombre d'utilisateurs exportés" });

    autoTable(doc, {
      head: [["Nom", "Prénom", "Trig.", "Poste", "Ligne interne", "Mobile"]],
      body: data.map(u => {
        const tels = resolveTelephonesList(u, phonesBySam);
        return [
          u.nom ?? "", u.prenom ?? "", u.trigramme ?? "",
          stringifyPostes(tels), stringifyFixes(tels),
          u.mobiles ?? "",
        ];
      }),
      startY,
      ...getAutoTableStyles(),
    });

    doc.save(`${fileName}.pdf`);
  };

  return <button type="button" onClick={onExport}>Exporter en PDF</button>;
}

Sécurité implémentée

Défense en profondeur — vue d'ensemble
RisqueMesure
Brute-force credentialsRate limiter en mémoire (5 essais / 15 min / IP), fenêtre glissante, plafond anti-flood, cleanup périodique
Vol / forge de sessionCookie HMAC-SHA256 timing-safe, secret rotatif (cache 5 min), httpOnly + secure + sameSite=lax
CSRFsameSite=lax + vérification Origin vs Host sur toute méthode mutante
Injection LDAPescapeLdapFilter() conforme RFC 4515 sur toutes les valeurs interpolées
Données malforméesValidation Zod systématique + types inférés (z.infer)
Fuite de secretsVariables sensibles dans .env.production (exclu du dépôt via .gitignore) ; jamais en dur dans le code
Élévation de privilègesRBAC par groupes AD (requireAnyGroup), conteneur Docker en utilisateur non-root
TraçabilitéAudit log JSON ligne (auth, CRUD, accès refusés) + X-Correlation-Id propagé
Corruption de fichierÉcriture atomique (tmp + rename) + fallback aux valeurs par défaut
Rate Limiting (anti brute-force)
// src/libs/rate-limiter.ts (extraits)
class RateLimiter {
  private attempts = new Map<string, RateLimitEntry>(); // ip → { count, resetTime }
  private readonly maxEntries = 10_000;                  // protection anti-flood mémoire

  constructor(private maxAttempts: number, private windowMs: number) {
    // Nettoyage périodique non bloquant pour le shutdown
    const timer = setInterval(() => this.cleanup(), 5 * 60 * 1000);
    (timer as NodeJS.Timeout & { unref?: () => void }).unref?.();
  }

  check(ip: string): boolean {
    const now   = Date.now();
    const entry = this.attempts.get(ip);

    if (!entry || now > entry.resetTime) {
      this.attempts.set(ip, { count: 1, resetTime: now + this.windowMs });
      return true;
    }
    if (entry.count >= this.maxAttempts) return false;
    entry.count++;
    return true;
  }

  getTimeUntilReset(ip: string): number {
    const entry = this.attempts.get(ip);
    if (!entry) return 0;
    return Math.ceil(Math.max(0, entry.resetTime - Date.now()) / 1000);
  }

  reset(ip: string): void { this.attempts.delete(ip); }
}

export const authRateLimiter = new RateLimiter(5, 15 * 60 * 1000);   // 5 / 15 min
Cookie de session signé (HMAC-SHA256)
OptionValeurProtection
httpOnlytrueNon accessible via JS (XSS)
securetrue (prod)Transmission HTTPS uniquement
sameSitelaxCSRF tout en permettant la navigation
signatureHMAC-SHA256 + timingSafeEqualNi forge ni modification possible côté client
maxAge7200 (2h)Expiration automatique de session
Anti-CSRF — vérification Origin / Host

En complément de sameSite=lax, toutes les méthodes mutantes (POST, PUT, DELETE) passent par checkCsrfOrigin() qui refuse la requête (403) si l'en-tête Origin est présent et que son hôte ne correspond pas au Host.

Audit logging structuré + corrélation

Toutes les actions sensibles (auth, dépassements rate-limiter, CRUD ressources SI, accès refusés) sont tracées au format JSON ligne, prêt pour ingestion ELK / Datadog. Un identifiant X-Correlation-Id (UUID v4) est propagé d'un bout à l'autre, permettant de relier en exploitation toutes les traces d'une même action utilisateur.

// src/types/common.ts — type exporté dans @/types
export type AuditAction =
  | "AUTH_SUCCESS" | "AUTH_FAILED" | "AUTH_RATE_LIMITED" | "LOGOUT"
  | "TELEPHONE_CREATE" | "TELEPHONE_UPDATE" | "TELEPHONE_DELETE"
  | "COMPUTER_ASSIGN" | "COMPUTER_UNASSIGN"
  | "USER_MOBILE_UPDATE";

// src/libs/audit-logger.ts
// Clés sensibles jamais journalisées (OWASP : pas de credentials en clair dans les logs)
const SENSITIVE_KEYS = new Set(["password", "secret", "token", "authorization", "cookie"]);

class AuditLogger {
  audit(
    action: AuditAction,
    opts: { username?: string; ip: string; correlationId?: string;
            details?: Record<string, unknown>; success?: boolean }
  ): void {
    const safeDetails = opts.details
      ? Object.fromEntries(
          Object.entries(opts.details).filter(([key]) => !SENSITIVE_KEYS.has(key.toLowerCase()))
        )
      : undefined;

    const line =
      `[${new Date().toISOString()}] [${action}] ` +
      (opts.correlationId ? `cid=${opts.correlationId} ` : "") +
      `user=${opts.username ?? "anonymous"} ip=${opts.ip} ` +
      `success=${opts.success ?? true}` +
      (safeDetails && Object.keys(safeDetails).length > 0
        ? ` details=${JSON.stringify(safeDetails)}` : "") + "\n";

    this.maybeRotate(); // rotation automatique si > 10 Mo
    void fs.promises.appendFile(LOG_FILE, line, "utf-8").catch(err => console.error("[AUDIT]", err));
  }
}

export const auditLogger = new AuditLogger();

Conteneurisation Docker

Dockerfile (multi-stage, user non-root, volume persistant)
# Stage 1 : Dépendances
FROM node:lts-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci

# Stage 2 : Build Next.js (mode standalone)
FROM node:lts-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

# Stage 3 : Runtime minimal, utilisateur non-root
FROM node:lts-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nodejs
RUN adduser  --system --uid 1001 nextjs

# Créer les dossiers de données/logs avec les bonnes permissions
RUN mkdir -p /app/data /app/logs && chown nextjs:nodejs /app/data /app/logs

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
ENV HOSTNAME="0.0.0.0"

CMD ["node", "server.js"]
docker-compose.yml (env_file + volumes)
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: si-dashboard
    ports:
      - "8081:3000"
    env_file:
      - .env.production
    environment:
      - NODE_ENV=production
    volumes:
      - ./data:/app/data  # persistance de links.json
      - ./logs:/app/logs  # persistance des logs d'audit
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: "1.0"
    restart: unless-stopped

Interface 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)
  • Recherche full-text normalisée (insensible aux accents) sur tous les champs
  • Affichage inline des ordinateurs et téléphones associés à chaque utilisateur
  • Actions conditionnelles (Glo_ServiceInfo / Administrateurs 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
Page Ordinateurs — Fonctionnalités
  • Vue tableau du parc AD complet avec statut d'assignation issu de links.json
  • Filtres dynamiques : type de machine, système d'exploitation, statut (assigné / libre)
  • Facettes calculées côté serveur (compteurs par filtre) avec cache TTL 30 s
  • Tri multi-colonnes (nom, type, OS, utilisateur) via Intl.Collator("fr")
  • Pagination serveur (page, limit) avec méta-données totalPages
Composants React principaux
ComposantResponsabilité
AnnuaireTableRendu du tableau avec colonnes dynamiques (groupes AD)
FiltersSidebarPanneau latéral avec checkboxes de filtrage
UnifiedToolbarBarre de recherche et compteur de résultats (réutilisable)
AssignPcModalModal de sélection d'un ordinateur libre à assigner
PhonesEditorModalÉdition des téléphones fixes (postes + lignes)
MobileEditorModalÉdition du numéro de mobile
ExportPdfButtonGénération et téléchargement du PDF (jsPDF + autoTable)
LoginModalFormulaire d'authentification AD
BaseModal / BaseSidebarPrimitives UI réutilisables (focus trap, esc-close, ARIA)
Hooks React custom
HookRôle
useSessionÉtat de session + login / logout + revalidation
useAnnuaireDataChargement annuaire + tableaux dérivés (téléphones, ordinateurs par user)
useAnnuaireFiltersFiltres + recherche + persistance localStorage
useOrdinateursDataFetch paginé + facettes + tri serveur
useApiMutationWrapper de mutations (loading / error / optimistic update)
useLocalStoragePersistance localStorage typée + SSR-safe

Compétences techniques mobilisées

DomaineTechnologies / Apports
FrontendReact 19, TypeScript strict, hooks custom, CSS Modules, accessibilité (focus trap, ARIA), composants génériques réutilisables
BackendNext.js 16 Route Handlers, architecture services / repositories, validation Zod, caches TTL applicatifs
Annuaire / IdentitéLDAP / LDAPS, bind utilisateur, lecture memberOf, parsing DN/CN, RBAC par groupes AD, anti-injection RFC 4515
PersistanceStore JSON générique typé, cache invalidé par mtime, écriture atomique (temp file + rename), transactions compare-and-swap
SécuritéHMAC-SHA256 timing-safe, anti-CSRF Origin/Host, rate limiting en mémoire, variables externalisées (.env.production), audit structuré, correlation id, LDAPS avec vérification TLS
DevOpsDocker multi-stage, docker-compose, volumes persistants, utilisateur non-root, build standalone Next.js
UX / UIInterface responsive, modals réutilisables, filtres persistants, export PDF, recherche normalisée
Qualité de codeTypeScript strict, ESLint, architecture modulaire en couches, types inférés depuis Zod, séparation des responsabilités, barrel exports