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:
@@ -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}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user