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
useretcomputerde 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_ServiceInfoetAdministrateurs(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
| Couche | Technologies | Rôle |
|---|---|---|
| Frontend | React 19, TypeScript, CSS Modules | UI réactive et typée |
| Framework | Next.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 applicative | JsonFileStore (Node fs) | Assignations + téléphones + mobiles (links.json) — écriture atomique + cache mémoire |
| Session | HMAC-SHA256 (Node crypto) | Token signé, vérification timingSafeEqual, secret rotatif |
| Validation | Zod | Schémas serveur + types inférés (z.infer) |
| Export | jsPDF, jspdf-autotable | Génération de PDF côté client |
| Conteneurisation | Docker, docker-compose, .env.production | Build 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 :
withErrorHandler— try/catch global, vérification CSRF (OriginvsHost), mapping des erreurs (ZodError → 400,ApiError → status, reste →500) et injection d'unX-Correlation-Id.requireAnyGroup— lecture et vérification HMAC du cookie de session, RBAC surGlo_ServiceInfoouAdministrateurs(401/403 en cas d'échec).- Validation Zod du corps de la requête.
- Service métier : transaction compare-and-swap sur
linksStore+ invalidation du cache TTL des ordinateurs. - 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/telephones—GET(tout authentifié),POST,PUT,DELETE(groupes d'écriture). Les opérations détectent les conflits (duplicate_poste,duplicate_lignes_internes) et retournent409./api/si/mobiles—GETetPUTpour associer un numéro de mobile à unsamAccountName.
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
| Risque | Mesure |
|---|---|
| Brute-force credentials | Rate limiter en mémoire (5 essais / 15 min / IP), fenêtre glissante, plafond anti-flood, cleanup périodique |
| Vol / forge de session | Cookie HMAC-SHA256 timing-safe, secret rotatif (cache 5 min), httpOnly + secure + sameSite=lax |
| CSRF | sameSite=lax + vérification Origin vs Host sur toute méthode mutante |
| Injection LDAP | escapeLdapFilter() conforme RFC 4515 sur toutes les valeurs interpolées |
| Données malformées | Validation Zod systématique + types inférés (z.infer) |
| Fuite de secrets | Variables sensibles dans .env.production (exclu du dépôt via .gitignore) ; jamais en dur dans le code |
| Élévation de privilèges | RBAC 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 minCookie de session signé (HMAC-SHA256)
| Option | Valeur | Protection |
|---|---|---|
httpOnly | true | Non accessible via JS (XSS) |
secure | true (prod) | Transmission HTTPS uniquement |
sameSite | lax | CSRF tout en permettant la navigation |
signature | HMAC-SHA256 + timingSafeEqual | Ni forge ni modification possible côté client |
maxAge | 7200 (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-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)
- 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/Administrateursuniquement) :- 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éestotalPages
Composants React principaux
| Composant | Responsabilité |
|---|---|
AnnuaireTable | Rendu du tableau avec colonnes dynamiques (groupes AD) |
FiltersSidebar | Panneau latéral avec checkboxes de filtrage |
UnifiedToolbar | Barre de recherche et compteur de résultats (réutilisable) |
AssignPcModal | Modal de sélection d'un ordinateur libre à 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 (jsPDF + autoTable) |
LoginModal | Formulaire d'authentification AD |
BaseModal / BaseSidebar | Primitives UI réutilisables (focus trap, esc-close, ARIA) |
Hooks React custom
| Hook | Rôle |
|---|---|
useSession | État de session + login / logout + revalidation |
useAnnuaireData | Chargement annuaire + tableaux dérivés (téléphones, ordinateurs par user) |
useAnnuaireFilters | Filtres + recherche + persistance localStorage |
useOrdinateursData | Fetch paginé + facettes + tri serveur |
useApiMutation | Wrapper de mutations (loading / error / optimistic update) |
useLocalStorage | Persistance localStorage typée + SSR-safe |
Compétences techniques mobilisées
| Domaine | Technologies / Apports |
|---|---|
| Frontend | React 19, TypeScript strict, hooks custom, CSS Modules, accessibilité (focus trap, ARIA), composants génériques réutilisables |
| Backend | Next.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 |
| Persistance | Store 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 |
| DevOps | Docker multi-stage, docker-compose, volumes persistants, utilisateur non-root, build standalone Next.js |
| UX / UI | Interface responsive, modals réutilisables, filtres persistants, export PDF, recherche normalisée |
| Qualité de code | TypeScript strict, ESLint, architecture modulaire en couches, types inférés depuis Zod, séparation des responsabilités, barrel exports |