mirror of
https://github.com/MaksTinyWorkshop/_Assistant_Lead_Tech
synced 2026-05-18 08:18:15 +02:00
capitalisation: intégration ~60 entrées RL799_V2 (triage 2026-05-02)
Triage du 95_a_capitaliser.md (~75 propositions) : - 60 entrées intégrées dans knowledge/ (backend, frontend, workflow) - 4 nouveaux fichiers : backend/patterns/tests.md, backend/risques/tests.md, frontend/patterns/general.md, workflow/patterns/general.md - 6 doublons rejetés - Mise à jour des READMEs index pour refléter les nouvelles entrées - 95_a_capitaliser.md restauré à sa structure initiale - 40_decisions_et_archi.md : décision mono-tenant déployable vs SaaS multi-tenant - 90_debug_et_postmortem.md : sub-agents Write indisponible, effet iceberg CI, prisma migrate diffs cosmétiques Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -342,3 +342,338 @@ Le helper `requireRoleAccess` doit retourner :
|
||||
- Contexte technique : auth / audit — RL799_V2
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-consume-single-use-pas-session-implicite"></a>
|
||||
## Pattern : Consume single-use = pas de session implicite, force re-login
|
||||
|
||||
- Objectif : éviter qu'un endpoint qui consume un magic link (invitation, reset password) émette une session implicite — défense en profondeur contre l'interception d'email.
|
||||
- Contexte : flows magic-link qui aboutissent à un `consume` (set du nouveau mot de passe ou activation du compte).
|
||||
- Quand l'utiliser : tous les flows single-use qui touchent au cycle d'authentification.
|
||||
- Quand l'éviter : magic-link "passwordless" pur (sans étape de saisie de mot de passe) où le link EST le facteur d'auth.
|
||||
- Avantage :
|
||||
- un attaquant qui intercepte le mail obtient un consume, pas une session
|
||||
- cohérence cross-flow : un seul pattern post-consume, surface d'audit réduite
|
||||
- factorisation page consume facilitée (les modes diffèrent par wording, pas par flow)
|
||||
- Limites / vigilance :
|
||||
- friction perçue d'un re-login → minime, l'utilisateur vient de saisir son mot de passe
|
||||
- Validé le : 28-04-2026
|
||||
- Contexte technique : auth / magic-link — RL799_V2
|
||||
|
||||
### Règle
|
||||
|
||||
Réponse minimale post-consume : `{ data: { ok: true, email } }`. Pas de cookie JWT, pas d'access token. Le frontend redirige vers `/login?email=…` avec bouton "Se connecter" bien placé sur la page de succès.
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
- "Auto-login pour invitation, re-login pour reset" sous prétexte UX — divergence asymétrique = anti-pattern de sécurité
|
||||
- Cookie JWT émis avant que l'utilisateur ait validé qu'il connaît son nouveau mot de passe
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-magic-link-consume-sans-fusion-table"></a>
|
||||
## Pattern : Magic-link consume — mécanique partagée sans fusion table
|
||||
|
||||
- Objectif : factoriser la mécanique sécu d'un consume single-use (hash SHA-256, transaction atomique, révocation refresh tokens) sans fusionner les modèles DB des deux domaines.
|
||||
- Contexte : projet avec deux flows partageant la même mécanique (invitation magic-link + reset password) mais des objets métier différents (invitation = audit riche, reset = purement technique).
|
||||
- Quand l'utiliser : factorisation au niveau **service**, pas au niveau table.
|
||||
- Quand l'éviter : un seul flow magic-link dans le projet — pas de factorisation prématurée.
|
||||
- Avantage :
|
||||
- audit historique riche pour les invitations (statut traçable)
|
||||
- transaction atomique partagée (rotation/consume)
|
||||
- rate limiter dédié par endpoint sans pollution cross-domaine
|
||||
- Limites / vigilance :
|
||||
- duplication contrôlée des modèles DB (acceptable — chaque domaine a son cycle de vie)
|
||||
- Validé le : 28-04-2026
|
||||
- Contexte technique : auth / magic-link / Prisma — RL799_V2
|
||||
|
||||
### Règles d'or
|
||||
|
||||
1. **Tokens stockés en hash SHA-256 uniquement** (`tokenHash @unique`). Le raw token n'est transmis que dans l'URL email, jamais persisté. Helper `hashXxxToken(raw: string): string` réutilisable côté service ET tests.
|
||||
|
||||
2. **PK technique `id String @id @default(uuid())`**, pas le token comme PK. Évite le couplage PK/sécurité, permet la rotation d'un token sans créer une nouvelle row.
|
||||
|
||||
3. **Transaction atomique consume** :
|
||||
- Lock implicite via `findUnique({ where: { tokenHash }, include: { user } })`
|
||||
- Check `status === 'active'`, `consumedAt === null`, `revokedAt === null`, `expiresAt > now`, `user.isActive === true`
|
||||
- UPDATE token `status='consumed', consumedAt=now()`
|
||||
- UPDATE user (password si applicable)
|
||||
- UPDATE refresh_tokens `revokedAt=now()` (force re-login propre)
|
||||
- UPDATE autres tokens actifs `status='revoked'` (anti-réutilisation)
|
||||
- INSERT audit dans la transaction (atomicité stricte)
|
||||
|
||||
4. **Retour discriminé** :
|
||||
|
||||
```typescript
|
||||
export type ConsumeResult =
|
||||
| { ok: true; userId: string; email: string }
|
||||
| { ok: false; reason: 'NOT_FOUND' | 'ALREADY_USED' | 'EXPIRED' | 'REVOKED' | 'USER_INACTIVE' };
|
||||
```
|
||||
|
||||
Le frontend ne connaît qu'un code générique côté UX (`INVALID_X_TOKEN`) pour ne pas exposer d'oracle.
|
||||
|
||||
5. **Rate limiter dédié par endpoint** :
|
||||
- `validate` : 30 req / 15 min / IP (oracle d'énumération)
|
||||
- `consume` : 10 req / 15 min / IP (modifie le mot de passe)
|
||||
- `resend` (admin) : 20 req / heure / admin
|
||||
|
||||
6. **Helpers transverses** : `revokeAndIssueXxx(input)` en transaction unique (couplé à un index unique partiel `WHERE status='active'`), `revokeXxxForUser(userId)` (appelé par `setUserActive(false)`), `getLatestXxxByUserIds(userIds[])` batch (anti N+1).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-sentinelle-non-hashable-user-invite"></a>
|
||||
## Pattern : Sentinelle non-hashable pour user en attente de mot de passe
|
||||
|
||||
- Objectif : éviter à la fois un `password: String?` nullable qui casse les chemins login/test/audit qui font tous des `select: { password }`, et un appel scrypt inutile (~100 ms par user invité).
|
||||
- Contexte : user créé via invitation qui n'a pas encore défini son mot de passe.
|
||||
- Quand l'utiliser : tout flow d'invitation où le user existe en base avant le set du password.
|
||||
- Quand l'éviter : projet où le user n'est créé qu'au consume du magic link (pas de placeholder nécessaire).
|
||||
- Avantage :
|
||||
- le champ `password` reste `NOT NULL` en DB → aucun chemin code ne casse
|
||||
- `verifyPassword` détecte le préfixe en early-return → 0 ms scrypt
|
||||
- garantie cryptographique (pas conventionnelle) : le préfixe `!` est absent d'une sortie hex
|
||||
- Limites / vigilance :
|
||||
- la sentinelle est lisible en clair par un admin DB — acceptable car c'est un placeholder identifié, pas un secret
|
||||
- Validé le : 28-04-2026
|
||||
- Contexte technique : auth / scrypt — RL799_V2
|
||||
|
||||
### Implémentation
|
||||
|
||||
```typescript
|
||||
// services/.../inviteService.ts
|
||||
const buildInvitedPlaceholderPassword = (): string =>
|
||||
`!INVITED_PENDING_${crypto.randomUUID()}`;
|
||||
|
||||
await prisma.user.create({
|
||||
data: { email, password: buildInvitedPlaceholderPassword(), ... },
|
||||
});
|
||||
|
||||
// lib/passwords.ts
|
||||
export const verifyPassword = (password: string, stored: StoredPassword): boolean => {
|
||||
if (typeof stored !== 'string' || typeof password !== 'string') return false;
|
||||
if (stored.startsWith('!')) return false; // placeholder réservé, jamais matchable
|
||||
// …logique scrypt habituelle
|
||||
};
|
||||
```
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] Préfixe `!` (caractère **garanti** absent de l'alphabet hex `0-9a-f`)
|
||||
- [ ] UUID embarqué pour l'unicité par user (utile pour debug audit, pas un secret)
|
||||
- [ ] Test : `verifyPassword('anything', '!INVITED_PENDING_xxx') === false`
|
||||
- [ ] AC dédié : `verifyPassword` rejette en 0 ms observable (pas d'appel scrypt)
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-ttl-court-bouton-resend"></a>
|
||||
## Pattern : TTL court + bouton resend admin > TTL longue
|
||||
|
||||
- Objectif : minimiser la fenêtre d'exposition d'un token sensible sans dégrader l'UX dans les cas marginaux (user en vacances).
|
||||
- Contexte : tokens d'authentification sensibles (invitation, reset password) où la tentation est d'allonger la TTL pour couvrir les cas de longue absence.
|
||||
- Quand l'utiliser : tout token sensible avec un canal admin disponible pour relancer.
|
||||
- Quand l'éviter : tokens où le resend est impossible (signed URLs publiques sans admin).
|
||||
- Avantage :
|
||||
- surface d'attaque réduite (token intercepté n'est utilisable que pendant la TTL courte)
|
||||
- granularité opérationnelle : l'admin trace dans l'audit qui demande un resend
|
||||
- invariant "1 active par user" reste applicable (le resend révoque l'ancien et émet un nouveau)
|
||||
- Limites / vigilance :
|
||||
- rate limiter sur le bouton resend (20/h/admin) pour éviter le spam involontaire
|
||||
- pas d'extension d'`expiresAt` au resend — émettre un nouveau token, pas patcher l'ancien
|
||||
- Validé le : 28-04-2026
|
||||
- Contexte technique : auth / magic-link — RL799_V2
|
||||
|
||||
### Règles d'or
|
||||
|
||||
- TTL en constante explicite côté service (`7 * 24 * 60 * 60 * 1000`), pas en magic number éparpillé
|
||||
- Fenêtres recommandées : invitation ≤ 7 jours, reset password ≤ 24 h
|
||||
- Audit `xxx.resend` sur le bouton admin + audit `xxx.revoke` (cohérence avec resend qui révoque l'ancien)
|
||||
- AC dédié : `expiresAt - createdAt ≈ N * 24h ± 1s` (tolérance fixture)
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-hook-setuseractive-revoke-side-tokens"></a>
|
||||
## Pattern : Hook `setUserActive(false)` → revoke side-tokens
|
||||
|
||||
- Objectif : éviter qu'un user désactivé puisse continuer à consommer un magic link reçu juste avant la désactivation et reprendre la main.
|
||||
- Contexte : opération admin de désactivation user dans un projet avec tokens secondaires actifs (refresh tokens, invitations, futurs reset password tokens).
|
||||
- Quand l'utiliser : tout endpoint qui transitionne `user.isActive` de `true` à `false`.
|
||||
- Quand l'éviter : si la désactivation est purement métier (suspension UI) sans portée auth.
|
||||
- Avantage :
|
||||
- défense en profondeur (le checker du consume vérifie aussi `user.isActive` — ceinture + bretelles)
|
||||
- les tokens orphelins ne survivent pas à la désactivation
|
||||
- Limites / vigilance :
|
||||
- best-effort tracé : les revokes ne doivent pas bloquer la désactivation (l'admin attend un retour immédiat)
|
||||
- Validé le : 28-04-2026
|
||||
- Contexte technique : auth / lifecycle user — RL799_V2
|
||||
|
||||
### Implémentation
|
||||
|
||||
```typescript
|
||||
if (!body.isActive) {
|
||||
try {
|
||||
await revokeAllRefreshTokensForUser(userId);
|
||||
} catch (err) {
|
||||
console.error('[admin.users] refresh token revoke failed', err);
|
||||
refreshTokenWarning = '...';
|
||||
}
|
||||
try {
|
||||
await revokeInvitationsForUser(userId);
|
||||
} catch (err) {
|
||||
console.error('[admin.users] invitation revoke failed', err);
|
||||
}
|
||||
// À ajouter quand pertinent : reset password tokens, OAuth states, etc.
|
||||
}
|
||||
```
|
||||
|
||||
### Règles d'or
|
||||
|
||||
- **Best-effort tracé**, pas silencieux : `try { ... } catch { console.error(...) }`
|
||||
- Warning UX en cas d'échec partiel : la réponse JSON peut contenir un champ optionnel `warning` que le frontend affiche
|
||||
- **Double protection consume** : le checker du consume vérifie également `user.isActive === true` — même si le revoke échoue, l'user désactivé ne peut pas consommer
|
||||
- AC dédié : "user actif avec invitation pending → admin désactive → consume échoue avec INVALID_TOKEN, pas de session créée"
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-magic-link-url-clean"></a>
|
||||
## Pattern : Magic link "URL clean" — token signé HMAC + `history.replaceState`
|
||||
|
||||
- Objectif : ouvrir une page web qui pré-charge un contexte serveur (soireeId, eventId, inviteId) sans que l'identifiant ne reste visible dans l'URL navigable.
|
||||
- Contexte : mail (convocation, invitation, RSVP) avec un bouton qui mène vers une landing page PWA. On veut éviter forward, capture, indexation involontaire.
|
||||
- Quand l'utiliser : magic links publics vers une page qui pré-charge un contexte sensible.
|
||||
- Quand l'éviter : magic links d'authentification membre — utiliser les patterns auth classiques (refresh + access httpOnly).
|
||||
- Avantage :
|
||||
- URL visible côté utilisateur ne contient pas l'identifiant
|
||||
- HMAC garantit l'intégrité (custom claim `purpose` pour rejeter un token signé pour un autre usage)
|
||||
- `sessionStorage` plutôt que `localStorage` → contexte meurt avec l'onglet
|
||||
- Limites / vigilance :
|
||||
- `algorithms: ['HS256']` imposé côté `jwt.verify` pour bloquer les "alg: none" attacks
|
||||
- sécurité dépendante de l'isolation cryptographique du secret (cf. pattern dérivation HMAC)
|
||||
- Validé le : 30-04-2026
|
||||
- Contexte technique : Node.js crypto / Vue / Vite PWA — RL799_V2
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Mail HTML
|
||||
↓ Bouton (GET https://app/visit?t=<token-signé>)
|
||||
Landing PWA /visit
|
||||
↓ JS lit le token, POST /api/.../redeem { token }
|
||||
↓ Backend valide HMAC + extrait contextId, retourne metadata
|
||||
↓ JS stocke contextId en sessionStorage
|
||||
↓ history.replaceState(null, '', '/inscription')
|
||||
↓ router.replace() pour synchroniser le router SPA
|
||||
Page d'inscription (URL clean)
|
||||
```
|
||||
|
||||
### Backend — signature
|
||||
|
||||
```typescript
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export function signAccessToken(contextId: string, expiresAt: Date): string {
|
||||
const expSeconds = Math.floor(expiresAt.getTime() / 1000);
|
||||
return jwt.sign(
|
||||
{ sub: contextId, purpose: 'rsvp-v1', exp: expSeconds },
|
||||
getDerivedSecret(),
|
||||
{ algorithm: 'HS256' },
|
||||
);
|
||||
}
|
||||
|
||||
export function redeemAccessToken(token: string):
|
||||
| { ok: true; contextId: string }
|
||||
| { ok: false; reason: 'expired' | 'invalid' } {
|
||||
try {
|
||||
const decoded = jwt.verify(token, getDerivedSecret(), {
|
||||
algorithms: ['HS256'],
|
||||
}) as { sub?: string; purpose?: string };
|
||||
if (decoded.purpose !== 'rsvp-v1') return { ok: false, reason: 'invalid' };
|
||||
if (typeof decoded.sub !== 'string') return { ok: false, reason: 'invalid' };
|
||||
return { ok: true, contextId: decoded.sub };
|
||||
} catch (err) {
|
||||
if (err instanceof jwt.TokenExpiredError) return { ok: false, reason: 'expired' };
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend — landing minimaliste
|
||||
|
||||
```typescript
|
||||
onMounted(async () => {
|
||||
const token = route.query.t as string;
|
||||
if (!token) return showError('Lien incomplet');
|
||||
|
||||
const result = await redeemToken(token);
|
||||
saveSessionContext(result); // sessionStorage
|
||||
|
||||
if (typeof window !== 'undefined' && window.history?.replaceState) {
|
||||
window.history.replaceState(null, '', '/inscription');
|
||||
}
|
||||
await router.replace({ name: 'inscription' });
|
||||
});
|
||||
```
|
||||
|
||||
### Choix d'expiration
|
||||
|
||||
- Magic link auth : court (15 min – 24 h), token consommable une fois
|
||||
- RSVP événement : long (jusqu'à la date)
|
||||
- Invitation one-shot : moyen (7-30 jours), invalidable à l'usage
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-isolation-cryptographique-hmac"></a>
|
||||
## Pattern : Isolation cryptographique — secret dérivé via HMAC
|
||||
|
||||
- Objectif : signer plusieurs types de tokens isolés cryptographiquement sans ajouter une nouvelle env var par usage.
|
||||
- Contexte : projet avec un `JWT_SECRET` racine (auth membre) qui doit signer un autre type de token (magic link, RSVP, webhook) sans cross-domain attack possible.
|
||||
- Quand l'utiliser : besoin d'un secret de signature isolé sans nouvelle clé à provisionner et à tourner.
|
||||
- Quand l'éviter : si le projet supporte déjà un système de gestion de clés multiples (KMS, Vault) — utiliser le mécanisme natif.
|
||||
- Avantage :
|
||||
- une seule env var racine à provisionner et tourner
|
||||
- HMAC est one-way : un attaquant qui obtient le secret dérivé ne peut pas remonter au racine
|
||||
- reproductible : `(JWT_SECRET, purpose)` → même secret, pas d'état à persister
|
||||
- versionnable via le purpose (`-v1`, `-v2`) : rotation possible en bumpant le purpose
|
||||
- Limites / vigilance :
|
||||
- n'est PAS un substitut à la rotation de clés : si `JWT_SECRET` est compromis, tous les secrets dérivés le sont aussi
|
||||
- HKDF est plus rigoureux pour la dérivation formelle ; pour un simple isolement d'usage, `HMAC(secret, purpose)` suffit
|
||||
- Validé le : 30-04-2026
|
||||
- Contexte technique : Node.js crypto — RL799_V2
|
||||
|
||||
### Implémentation
|
||||
|
||||
```typescript
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
||||
const TOKEN_PURPOSE = 'magic-link-v1';
|
||||
let cachedSecret: Buffer | null = null;
|
||||
|
||||
function getDerivedSecret(): Buffer {
|
||||
if (cachedSecret) return cachedSecret;
|
||||
const root = process.env.JWT_SECRET;
|
||||
if (!root) throw new Error('JWT_SECRET requis');
|
||||
cachedSecret = createHmac('sha256', root).update(TOKEN_PURPOSE).digest();
|
||||
return cachedSecret;
|
||||
}
|
||||
```
|
||||
|
||||
### Test d'isolation
|
||||
|
||||
```typescript
|
||||
test('rejette un token signé avec un autre purpose', async () => {
|
||||
const tokenAsMember = jwt.sign(
|
||||
{ sub: 'x' },
|
||||
process.env.JWT_SECRET!,
|
||||
{ algorithm: 'HS256', expiresIn: '15m' },
|
||||
);
|
||||
const result = redeemToken(tokenAsMember);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
```
|
||||
|
||||
### Applications
|
||||
|
||||
- Tokens magic link / RSVP / invitation
|
||||
- Signature de webhooks sortants
|
||||
- Tokens d'unsubscribe email (lien direct sans login)
|
||||
- Tokens d'accès aux ressources publiques limitées dans le temps
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user