feat: rate limiting sur les routes d'authentification critiques

- Ajout de lib/rate-limit.js : fabrique de limiter en mémoire (closure +
  Map avec nettoyage lazy), sans dépendance externe, réutilisable
- Ajout de middleware.js : intercepte /api/auth/register (5 req/15min)
  et /api/auth/callback/credentials (10 req/5min), répond 429 + Retry-After
- Ajout de tasks/todo.md et tasks/lessons.md (suivi CLAUDE.md)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 21:30:38 +04:00
parent 22130529f6
commit d8a63bc4d8
4 changed files with 165 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
/**
* Fabrique de rate-limiter en mémoire.
*
* Adapté à un déploiement single-process (PM2 sans cluster).
* Pour un déploiement multi-instances, remplacer le Map par un store
* Redis partagé (ex. @upstash/ratelimit).
*
* @param {object} options
* @param {number} options.windowMs - Durée de la fenêtre en millisecondes
* @param {number} options.max - Nombre maximum de requêtes par fenêtre
*/
export function createRateLimiter({windowMs, max}) {
const store = new Map()
let lastCleanup = Date.now()
/**
* Supprime les entrées expirées du store.
* Appelé automatiquement une fois par fenêtre temporelle.
*/
function cleanup(now) {
for (const [storeKey, entry] of store) {
if (now - entry.start >= windowMs) {
store.delete(storeKey)
}
}
lastCleanup = now
}
/**
* Vérifie si la clé (IP:route) dépasse la limite autorisée.
*
* @param {string} key - Identifiant unique (ex. "1.2.3.4:/api/auth/register")
* @returns {{ success: boolean, retryAfter?: number }}
*/
return key => {
const now = Date.now()
if (now - lastCleanup >= windowMs) {
cleanup(now)
}
const entry = store.get(key)
// Première requête ou fenêtre expirée : on repart à zéro
if (!entry || now - entry.start >= windowMs) {
store.set(key, {count: 1, start: now})
return {success: true}
}
// Limite atteinte
if (entry.count >= max) {
const retryAfter = Math.ceil((entry.start + windowMs - now) / 1000)
return {success: false, retryAfter}
}
// Incrément normal
store.set(key, {...entry, count: entry.count + 1})
return {success: true}
}
}
+59
View File
@@ -0,0 +1,59 @@
import {NextResponse} from 'next/server'
import {createRateLimiter} from '@/lib/rate-limit.js'
// 5 inscriptions max par IP toutes les 15 minutes
const checkRegister = createRateLimiter({windowMs: 15 * 60 * 1000, max: 5})
// 10 tentatives de connexion max par IP toutes les 5 minutes
const checkSignin = createRateLimiter({windowMs: 5 * 60 * 1000, max: 10})
const limiters = {
'/api/auth/register': checkRegister,
'/api/auth/callback/credentials': checkSignin,
}
/**
* Extrait l'IP cliente depuis les headers HTTP.
* Priorité à X-Real-IP (Nginx), puis X-Forwarded-For.
*/
function getClientIp(request) {
const realIp = request.headers.get('x-real-ip')
if (realIp) {
return realIp.trim()
}
const forwarded = request.headers.get('x-forwarded-for')
if (forwarded) {
return forwarded.split(',')[0].trim()
}
return 'unknown'
}
export function middleware(request) {
const {pathname} = request.nextUrl
const check = limiters[pathname]
if (!check) {
return NextResponse.next()
}
const ip = getClientIp(request)
const result = check(`${ip}:${pathname}`)
if (result.success) {
return NextResponse.next()
}
return NextResponse.json(
{message: 'Trop de tentatives. Veuillez réessayer dans quelques minutes.'},
{
status: 429,
headers: {'Retry-After': String(result.retryAfter)},
}
)
}
export const config = {
matcher: ['/api/auth/register', '/api/auth/callback/credentials'],
}
+18
View File
@@ -0,0 +1,18 @@
# Leçons — Konstitisyon Frontend
## 2026-04-13 — Rate limiting / Premier chantier
### Erreur commise
Implémentation directe sans passer par le mode planification ni créer `tasks/todo.md` avant de coder.
La tâche a été présentée comme terminée après validation XO, sans preuve de fonctionnement réel.
### Règle à retenir
1. **Toujours** créer/mettre à jour `tasks/todo.md` avant de toucher au code pour toute tâche 3+ étapes.
2. **Toujours** prouver le fonctionnement après implémentation : test unitaire, `curl`, ou vérification Node directe — pas seulement le linter.
3. Le linter qui passe ≠ la feature qui fonctionne.
### Ce qui a bien fonctionné
- Lecture complète des fichiers impactés avant d'écrire (middleware, register, options, package.json)
- Zéro dépendance externe ajoutée — solution en-mémoire adaptée à PM2 single-process
- Correction autonome des erreurs XO (curly, arrow-parens, func-names) sans intervention
- Abstraction propre : `createRateLimiter` est réutilisable pour d'autres routes futures
+27
View File
@@ -0,0 +1,27 @@
# Tâches — Konstitisyon Frontend
## Améliorations critiques (P1)
- [x] **Rate limiting**`lib/rate-limit.js` + `middleware.js`
- Routes protégées : `/api/auth/register` (5/15min) et `/api/auth/callback/credentials` (10/5min)
- Logique vérifiée : comptage, blocage 429 + Retry-After, expiration fenêtre ✓
- [ ] **CORS whitelist** — restreindre `CORS_ORIGIN=true` dans l'env Directus
- [ ] **Sanitisation Markdown** — ajouter `isomorphic-dompurify` dans `markdown-renderer`
## Améliorations hautes (P2)
- [ ] **Headers CSP** — ajouter dans `next.config.js`
- [ ] **Tests unitaires** — Vitest sur `lib/format.js`, `lib/version-utils.js`, `lib/rate-limit.js`
- [ ] **Tests extensions Directus** — mocks VersionsService
- [ ] **Refresh token explicite** — callback `jwt` dans NextAuth options
- [ ] **Pipeline CI** — GitHub Actions (lint + test + build)
- [ ] **Sentry** — tracking erreurs frontend + API routes
## Améliorations moyennes (P3)
- [ ] ISR page d'accueil (`revalidate`)
- [ ] Dockerisation frontend (`output: standalone`)
- [ ] Audit accessibilité WCAG 2.1
- [ ] Responsive mobile dashboard
- [ ] Lazy loading jsPDF + md-editor
- [ ] Migration NextAuth v5 stable