Exiger un second facteur des rôles administrateur et RH

La matrice n° 15 l'impose ; les colonnes existaient en base depuis WP-01 mais
rien ne les remplissait. TOTP (RFC 6238) plutôt qu'un code envoyé par message :
le second facteur ne doit pas dépendre du canal qui sert déjà à réinitialiser le
mot de passe, faute de quoi un accès à la boîte donnerait les deux d'un coup.

L'algorithme est écrit ici plutôt qu'emprunté — trente lignes, et une dépendance
de moins sur le chemin d'authentification. Il est éprouvé contre les vecteurs
publiés de la RFC, ce qui distingue « le code change toutes les trente
secondes » de « le code est celui qu'attend l'application du téléphone ».

L'obligation est adossée aux capacités, pas à des rôles nommés qu'un client
renomme librement : distribuer les droits, ou lire les rémunérations. Elle est
tenue au point de passage de toutes les routes applicatives, où l'écran
d'enrôlement remplace le contenu. Il remplace plutôt qu'il ne redirige : une
redirection depuis un layout se joue aussi pendant la navigation qui suit la
connexion, et Next y répond par une page vide.

Le secret n'est enregistré qu'après qu'un code en a été tiré — l'enregistrer
d'avance laisserait des comptes porteurs d'un facteur que leur détenteur ne sait
pas produire, c'est-à-dire des comptes fermés. Le rejeu d'un code est refusé
dans sa propre fenêtre : sans cela le facteur protège d'un mot de passe volé,
pas d'un code lu par-dessus l'épaule.

Dix codes de secours accompagnent chaque activation, conservés hachés. Et parce
que PlanFlow est auto-hébergé et qu'il n'y a pas d'éditeur à appeler, un retrait
depuis le serveur existe — l'accès « break glass » que demande la même ligne de
la matrice. Sans lui, le second facteur deviendrait le risque principal plutôt
que la protection.

Un défaut trouvé par les tests, et il aurait été grave : la réactualisation de
la route après activation remplaçait l'écran par celui d'un compte déjà enrôlé,
emportant les codes de secours avant que leur destinataire ait pu les noter.

La suite de tests suit le produit : la mise en place enrôle réellement le compte
de direction et calcule les codes comme le ferait un téléphone. Les écrans qui
n'éprouvent pas l'authentification ont migré vers la session commune — deux
connexions parallèles sur un même compte se heurtent au refus de rejeu, ce qui
est le comportement voulu et non un défaut à contourner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-08 22:05:57 +00:00
1 parent 8701fe8105
commit 2047d7f5cc
27 files changed
+1997 -79

No files matched your search

+13 -1
View File
@@ -76,7 +76,19 @@ pnpm dev
openssl rand -base64 32
```
Elle vit **hors de la base** : une sauvegarde volée ne doit pas suffire à lire ces colonnes. La perdre rend ces données irrécupérables — la sauvegarder séparément et documenter sa rotation.
Elle vit **hors de la base** : une sauvegarde volée ne doit pas suffire à lire ces colonnes. La perdre rend ces données irrécupérables — la sauvegarder séparément et documenter sa rotation. Elle chiffre également les secrets de second facteur et le mot de passe du serveur d'envoi.
### Second facteur — accès de secours
Les rôles qui lisent les rémunérations ou distribuent les droits doivent porter un second facteur (matrice n° 15) : tant qu'il n'est pas activé, l'application ne leur ouvre aucun écran. Chaque activation délivre dix codes de secours, affichés **une seule fois**.
PlanFlow étant auto-hébergé, il n'y a pas d'éditeur à appeler si un administrateur perd à la fois son téléphone et ses codes. Le retrait se fait alors depuis le serveur :
```bash
pnpm mfa:reset adresse@example.fr
```
Le retrait révoque les sessions ouvertes et s'inscrit au journal d'audit. Il n'est délibérément pas exposé dans l'application : l'exécuter demande déjà un accès au serveur, c'est-à-dire davantage que ce que le second facteur protège.
## Vérifier
+3
View File
@@ -21,6 +21,7 @@
"db:deploy": "prisma migrate deploy",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"mfa:reset": "tsx scripts/mfa-reset.ts",
"verify": "pnpm typecheck && pnpm lint && pnpm test"
},
"prisma": {
@@ -33,6 +34,7 @@
"next": "16.3.0",
"nodemailer": "^9.0.5",
"pg": "8.22.0",
"qrcode": "^1.5.4",
"react": "19.2.8",
"react-dom": "19.2.8",
"zod": "4.4.3"
@@ -43,6 +45,7 @@
"@types/node": "22.19.4",
"@types/nodemailer": "^8.0.1",
"@types/pg": "8.20.4",
"@types/qrcode": "^1.5.6",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"dotenv": "^17.4.2",
+7
View File
@@ -1,3 +1,7 @@
// La mise en place lit la base directement pour rendre la suite rejouable ;
// sans cela DATABASE_URL n'existe que pour le serveur, pas pour les tests.
import 'dotenv/config';
import { defineConfig, devices } from '@playwright/test';
import { STORAGE_STATE } from './tests/e2e/storage';
@@ -38,9 +42,12 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'], ...chromiumOverride },
},
// Parcours d'authentification : doit partir d'un navigateur vierge.
// Dépend tout de même de la mise en place, qui pose le second facteur du
// compte de direction — sans lequel la connexion ne peut pas être jouée.
{
name: 'anonyme',
testMatch: /(auth|reglages|acces)\.spec\.ts/,
dependencies: ['setup'],
use: { ...devices['Desktop Chrome'], ...chromiumOverride },
},
{
+199
View File
@@ -26,6 +26,9 @@ importers:
pg:
specifier: 8.22.0
version: 8.22.0
qrcode:
specifier: ^1.5.4
version: 1.5.4
react:
specifier: 19.2.8
version: 19.2.8
@@ -51,6 +54,9 @@ importers:
'@types/pg':
specifier: 8.20.4
version: 8.20.4
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@types/react':
specifier: 19.2.18
version: 19.2.18
@@ -1165,6 +1171,9 @@ packages:
'@types/pg@8.20.4':
resolution: {integrity: sha512-Jz7UDOlIiFJuacC0TlBoLyNtmwlA/wpIyPDd3tvUqlRM+HzkWy2xUgpFpaXtbfTAFF6sIGq5lsCDBdJnhky1Xg==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/react-dom@19.2.4':
resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==}
peerDependencies:
@@ -1432,6 +1441,10 @@ packages:
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
@@ -1557,6 +1570,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001809:
resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==}
@@ -1578,6 +1595,9 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -1681,6 +1701,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -1713,6 +1737,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@@ -1734,6 +1761,9 @@ packages:
elkjs@0.11.1:
resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
@@ -1981,6 +2011,10 @@ packages:
resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==}
engines: {node: '>=20'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -2031,6 +2065,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@@ -2194,6 +2232,10 @@ packages:
resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
engines: {node: '>= 0.4'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-generator-function@1.1.2:
resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
engines: {node: '>= 0.4'}
@@ -2470,6 +2512,10 @@ packages:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -2623,14 +2669,26 @@ packages:
resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==}
engines: {node: '>= 0.4'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -2710,6 +2768,10 @@ packages:
engines: {node: '>=20'}
hasBin: true
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -2776,6 +2838,11 @@ packages:
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -2809,10 +2876,17 @@ packages:
remeda@2.33.4:
resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -2882,6 +2956,9 @@ packages:
seq-queue@0.0.5:
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -2965,6 +3042,10 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string.prototype.includes@2.0.1:
resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
engines: {node: '>= 0.4'}
@@ -2988,6 +3069,10 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
@@ -3215,6 +3300,9 @@ packages:
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
engines: {node: '>= 0.4'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which-typed-array@1.1.22:
resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
engines: {node: '>= 0.4'}
@@ -3233,13 +3321,28 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -4150,6 +4253,10 @@ snapshots:
pg-protocol: 1.15.0
pg-types: 2.2.0
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 22.19.4
'@types/react-dom@19.2.4(@types/react@19.2.18)':
dependencies:
'@types/react': 19.2.18
@@ -4457,6 +4564,8 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
@@ -4611,6 +4720,8 @@ snapshots:
callsites@3.1.0: {}
camelcase@5.3.1: {}
caniuse-lite@1.0.30001809: {}
chai@6.2.2: {}
@@ -4628,6 +4739,12 @@ snapshots:
client-only@0.0.1: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -4722,6 +4839,8 @@ snapshots:
dependencies:
ms: 2.1.3
decamelize@1.2.0: {}
deep-is@0.1.4: {}
deepmerge-ts@7.1.5: {}
@@ -4750,6 +4869,8 @@ snapshots:
detect-libc@2.1.2: {}
dijkstrajs@1.0.3: {}
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -4771,6 +4892,8 @@ snapshots:
elkjs@0.11.1: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
empathic@2.0.0: {}
@@ -5189,6 +5312,11 @@ snapshots:
fast-querystring: 1.1.2
safe-regex2: 5.1.1
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -5240,6 +5368,8 @@ snapshots:
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -5402,6 +5532,8 @@ snapshots:
dependencies:
call-bound: 1.0.4
is-fullwidth-code-point@3.0.0: {}
is-generator-function@1.1.2:
dependencies:
call-bound: 1.0.4
@@ -5623,6 +5755,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.33.0
lightningcss-win32-x64-msvc: 1.33.0
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -5789,14 +5925,24 @@ snapshots:
object-keys: 1.1.1
safe-push-apply: 1.0.0
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
p-try@2.2.0: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -5866,6 +6012,8 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
pngjs@5.0.0: {}
possible-typed-array-names@1.1.0: {}
postcss@8.5.23:
@@ -5929,6 +6077,12 @@ snapshots:
pure-rand@6.1.0: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
queue-microtask@1.2.3: {}
rc9@3.0.1:
@@ -5969,8 +6123,12 @@ snapshots:
remeda@2.33.4: {}
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
require-main-filename@2.0.0: {}
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -6049,6 +6207,8 @@ snapshots:
seq-queue@0.0.5: {}
set-blocking@2.0.0: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -6164,6 +6324,12 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string.prototype.includes@2.0.1:
dependencies:
call-bind: 1.0.9
@@ -6215,6 +6381,10 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.2
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
strip-bom@3.0.0: {}
strip-json-comments@3.1.1: {}
@@ -6442,6 +6612,8 @@ snapshots:
is-weakmap: 2.0.2
is-weakset: 2.0.4
which-module@2.0.1: {}
which-typed-array@1.1.22:
dependencies:
available-typed-arrays: 1.0.7
@@ -6463,10 +6635,37 @@ snapshots:
word-wrap@1.2.5: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
xtend@4.0.2: {}
y18n@4.0.3: {}
yallist@3.1.1: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yocto-queue@0.1.0: {}
zeptomatch@2.1.0:
@@ -0,0 +1,25 @@
-- AlterTable
ALTER TABLE "Session" ADD COLUMN "mfaSatisfied" BOOLEAN NOT NULL DEFAULT true;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "mfaLastStep" INTEGER;
-- CreateTable
CREATE TABLE "MfaRecoveryCode" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"codeHash" TEXT NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MfaRecoveryCode_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "MfaRecoveryCode_codeHash_key" ON "MfaRecoveryCode"("codeHash");
-- CreateIndex
CREATE INDEX "MfaRecoveryCode_userId_idx" ON "MfaRecoveryCode"("userId");
-- AddForeignKey
ALTER TABLE "MfaRecoveryCode" ADD CONSTRAINT "MfaRecoveryCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+25 -2
View File
@@ -110,13 +110,33 @@ model User {
/// Deuxième facteur, exigé des rôles administrateur et RH (matrice n° 15).
mfaSecretEnc Bytes?
mfaEnrolledAt DateTime?
/// Dernier pas TOTP employé. Interdit le rejeu d'un code dans sa fenêtre :
/// sans lui, le facteur protège du mot de passe volé, pas du code lu
/// par-dessus l'épaule.
mfaLastStep Int?
lastSignInAt DateTime?
failedAttempts Int @default(0)
lockedUntil DateTime?
createdAt DateTime @default(now())
memberships Membership[]
sessions Session[]
memberships Membership[]
sessions Session[]
recoveryCodes MfaRecoveryCode[]
}
/// Code de secours à usage unique. Perdre son téléphone ne doit pas fermer
/// définitivement l'accès — et la parade ne doit pas être de désactiver le
/// second facteur par un simple courriel au support.
model MfaRecoveryCode {
id String @id @default(cuid())
userId String
codeHash String @unique
usedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
/// Lien User ↔ Account. Porte le salarié : `userId` est nullable, car tous les
@@ -190,6 +210,9 @@ model Session {
userAgent String?
revokedAt DateTime?
revokedBy String?
/// Faux tant que le second facteur n'a pas été présenté. Une session en
/// attente ne donne accès à rien : elle sert seulement à porter le défi.
mfaSatisfied Boolean @default(true)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+90
View File
@@ -0,0 +1,90 @@
/**
* Retrait du second facteur depuis le serveur — accès « break glass »
* (matrice n° 15).
*
* PlanFlow est auto-hébergé : il n'y a pas d'éditeur à appeler. Sans cette
* issue, un administrateur qui perd à la fois son téléphone et ses codes de
* secours ferme définitivement l'accès de l'entreprise à ses propres données —
* et le second facteur, censé protéger, deviendrait le risque principal.
*
* Elle n'est délibérément pas exposée dans l'application : l'exécuter demande
* un accès au serveur, c'est-à-dire déjà davantage que ce que le second facteur
* protège. Le retrait est inscrit au journal d'audit, comme l'exige la matrice
* pour un accès de ce type.
*
* pnpm exec tsx scripts/mfa-reset.ts adresse@example.fr
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main(): Promise<void> {
const email = process.argv[2]?.toLowerCase().trim();
if (!email) {
console.error(
'Usage : pnpm exec tsx scripts/mfa-reset.ts <adresse électronique>',
);
process.exitCode = 1;
return;
}
const user = await prisma.user.findUnique({
where: { email },
select: { id: true, mfaEnrolledAt: true, memberships: { select: { id: true, accountId: true } } },
});
if (!user) {
console.error(`Aucun compte pour ${email}.`);
process.exitCode = 1;
return;
}
if (!user.mfaEnrolledAt) {
console.log(`${email} n'a pas de second facteur enregistré. Rien à faire.`);
return;
}
await prisma.$transaction([
prisma.user.update({
where: { id: user.id },
data: { mfaSecretEnc: null, mfaEnrolledAt: null, mfaLastStep: null },
}),
prisma.mfaRecoveryCode.deleteMany({ where: { userId: user.id } }),
// Les sessions ouvertes tombent avec le facteur : laisser vivre une session
// établie sous l'ancien facteur viderait le retrait de son sens.
prisma.session.updateMany({
where: { userId: user.id, revokedAt: null },
data: { revokedAt: new Date(), revokedBy: 'mfa-reset' },
}),
]);
const membership = user.memberships[0];
if (membership) {
await prisma.auditLog.create({
data: {
accountId: membership.accountId,
actorMembershipId: membership.id,
action: 'security.mfa.break_glass_reset',
entityType: 'User',
entityId: user.id,
before: { enrolled: true },
after: { enrolled: false, via: 'scripts/mfa-reset.ts' },
},
});
}
console.log(
`Second facteur retiré pour ${email}. Les sessions ouvertes sont révoquées.`,
);
console.log(
'Le compte doit en réactiver un à la prochaine connexion si son rôle l’exige.',
);
}
main()
.catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
+31 -1
View File
@@ -1,7 +1,11 @@
import { redirect } from 'next/navigation';
import type { ReactNode } from 'react';
import { MfaSettings } from '@/components/settings/MfaSettings';
import { AppShell } from '@/components/shell/AppShell';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Card, CardHeader } from '@/components/ui/Card';
import { mfaRequired } from '@/domain/access/mfa-policy';
import { currentSession } from '@/server/auth/session';
/**
@@ -16,6 +20,17 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
const session = await currentSession();
if (!session) redirect('/connexion');
// Un rôle qui lit les rémunérations ou distribue les droits doit porter un
// second facteur (matrice n° 15). L'obligation est tenue ici, au point de
// passage de toutes les routes applicatives.
//
// L'écran d'enrôlement **remplace** le contenu au lieu de rediriger vers
// lui : une redirection depuis un layout se joue aussi pendant la navigation
// qui suit la connexion, et Next y répond par une page vide. Substituer le
// contenu tient la même garantie sans dépendre du chemin demandé.
const enrolmentDue =
mfaRequired(session.actor.permissions) && !session.user.mfaEnrolled;
return (
<AppShell
initials={session.user.initials}
@@ -23,7 +38,22 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
roleName={session.roleName}
accountName={session.accountName}
>
{children}
{enrolmentDue ? <EnrolmentGate /> : children}
</AppShell>
);
}
function EnrolmentGate() {
return (
<PageBody>
<PageHeader
title="Sécurité"
subtitle="Votre rôle donne accès aux rémunérations ou à la distribution des droits : un second facteur est exigé avant d’aller plus loin."
/>
<Card>
<CardHeader title="Second facteur" />
<MfaSettings enrolled={false} required />
</Card>
</PageBody>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { MfaSettings } from '@/components/settings/MfaSettings';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader } from '@/components/ui/Card';
import { mfaRequired } from '@/domain/access/mfa-policy';
import { requireSession } from '@/server/context';
export const metadata = { title: 'Sécurité · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function SecurityPage() {
const session = await requireSession();
const required = mfaRequired(session.actor.permissions);
return (
<PageBody>
<PageHeader
title="Sécurité"
subtitle="Le second facteur protège votre compte, pas celui des autres : chacun gère le sien."
actions={
session.user.mfaEnrolled ? (
<Badge tone="ok">Second facteur actif</Badge>
) : (
<Badge tone={required ? 'danger' : 'warn'}>
{required ? 'Exigé, non activé' : 'Non activé'}
</Badge>
)
}
/>
<Card>
<CardHeader title="Second facteur" />
<MfaSettings enrolled={session.user.mfaEnrolled} required={required} />
</Card>
</PageBody>
);
}
@@ -0,0 +1,46 @@
'use client';
import { useActionState } from 'react';
import { Button } from '@/components/ui/Button';
import { verifyMfaAction, type ChallengeState } from '@/server/auth/actions';
const empty: ChallengeState = {};
export function MfaChallengeForm() {
const [state, formAction, pending] = useActionState(verifyMfaAction, empty);
return (
<form action={formAction} className="flex flex-col gap-4">
<label className="flex flex-col gap-1">
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
Code
</span>
<input
name="code"
// `one-time-code` permet aux gestionnaires de mots de passe de
// proposer le code sans le faire recopier.
autoComplete="one-time-code"
inputMode="text"
autoFocus
required
className="tnum h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
<span className="text-micro text-ink-3">
Six chiffres, ou l’un de vos codes de secours si vous n’avez pas votre
téléphone.
</span>
</label>
<Button type="submit" variant="primary" disabled={pending}>
Vérifier
</Button>
{state.error ? (
<p role="alert" className="text-xs text-danger">
{state.error}
</p>
) : null}
</form>
);
}
@@ -0,0 +1,45 @@
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { MfaChallengeForm } from '@/app/(auth)/connexion/verification/MfaChallengeForm';
import { pendingChallenge, SESSION_COOKIE } from '@/server/auth/session';
export const metadata = { title: 'Vérification · PlanFlow' };
export const dynamic = 'force-dynamic';
/**
* Second facteur, à la connexion.
*
* La session existe déjà mais ne résout aucun acteur : elle ne porte que ce
* défi, et expire d'elle-même en dix minutes.
*/
export default async function VerificationPage() {
const token = (await cookies()).get(SESSION_COOKIE)?.value;
const challenge = token ? await pendingChallenge(token) : null;
// Sans défi en cours, il n'y a rien à vérifier : ou la session est pleine, ou
// elle a expiré. Dans les deux cas la page de connexion tranche.
if (!challenge) redirect('/connexion');
return (
<main className="flex min-h-dvh items-center justify-center bg-canvas p-6">
<div className="w-full max-w-sm">
<div className="mb-6 flex items-center gap-2.5">
<span aria-hidden className="size-6 rounded-2 bg-accent" />
<span className="text-xl font-semibold tracking-[-0.015em]">
PlanFlow
</span>
</div>
<div className="rounded-3 border border-line-1 bg-surface p-6 shadow-e1">
<h1 className="text-lg font-semibold">Vérification</h1>
<p className="mt-1 mb-5 text-sm text-ink-2">
Saisissez le code affiché par votre application
d’authentification pour {challenge.email}.
</p>
<MfaChallengeForm />
</div>
</div>
</main>
);
}
+219
View File
@@ -0,0 +1,219 @@
'use client';
import { useActionState, useState, useTransition } from 'react';
import { Button } from '@/components/ui/Button';
import { formatSecret } from '@/domain/access/totp';
import {
confirmEnrolmentAction,
disableMfaAction,
startEnrolmentAction,
type MfaState,
type OfferState,
} from '@/server/auth/mfa-actions';
const empty: MfaState = {};
/**
* Second facteur du compte courant.
*
* Trois états successifs et un seul écran : rien d'activé, un enrôlement en
* cours, un facteur en place. Les séparer en pages ferait perdre le secret
* proposé au premier retour arrière.
*/
export function MfaSettings({
enrolled,
required,
}: {
enrolled: boolean;
required: boolean;
}) {
const [offer, setOffer] = useState<OfferState | null>(null);
const [starting, startTransition] = useTransition();
const [confirmState, confirm, confirming] = useActionState(
confirmEnrolmentAction,
empty,
);
const [disableState, disable, disabling] = useActionState(
disableMfaAction,
empty,
);
const codes = confirmState.recoveryCodes;
const active = (enrolled || confirmState.ok) && !disableState.ok;
if (codes && codes.length > 0) {
return <RecoveryCodes codes={codes} />;
}
if (active) {
return (
<div className="flex flex-col gap-3 p-4">
<p className="text-sm text-ink-2">
Un second facteur est actif sur votre compte. Il vous sera demandé à
chaque connexion.
</p>
{required ? (
<p className="rounded-3 border border-info bg-info-soft p-3 text-sm text-info-soft-ink">
Votre rôle donne accès aux rémunérations ou à la distribution des
droits : le second facteur est <strong>exigé</strong> et ne peut pas
être retiré.
</p>
) : (
<form action={disable} className="flex flex-wrap items-end gap-3">
<label className="flex flex-col gap-1">
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
Mot de passe
</span>
<input
name="password"
type="password"
autoComplete="current-password"
required
className="h-8 w-64 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
<span className="text-micro text-ink-3">
Redemandé pour qu’un poste laissé ouvert ne suffise pas à
désarmer la protection.
</span>
</label>
<Button type="submit" disabled={disabling}>
Désactiver
</Button>
</form>
)}
{disableState.error ? (
<p role="alert" className="text-xs text-danger">
{disableState.error}
</p>
) : null}
</div>
);
}
if (!offer?.secret) {
return (
<div className="flex flex-col gap-3 p-4">
<p className="text-sm text-ink-2">
Un code à six chiffres, produit par une application
d’authentification, s’ajoute au mot de passe. Il change toutes les
trente secondes et ne transite par aucun réseau.
</p>
{required ? (
<p role="alert" className="rounded-3 border border-warn bg-warn-soft p-3 text-sm text-warn-soft-ink">
Votre rôle l’exige : tant qu’il n’est pas activé, l’accès aux
écrans reste fermé.
</p>
) : null}
<div>
<Button
variant="primary"
disabled={starting}
onClick={() =>
startTransition(async () => {
setOffer(await startEnrolmentAction());
})
}
>
Activer le second facteur
</Button>
</div>
{disableState.message ? (
<p className="text-xs text-ok-soft-ink">{disableState.message}</p>
) : null}
</div>
);
}
return (
<form action={confirm} className="flex flex-col gap-4 p-4">
<input type="hidden" name="secret" value={offer.secret} />
<ol className="flex flex-col gap-4 text-sm text-ink-2">
<li>
<p className="font-medium text-ink-1">
1. Ajoutez le compte à votre application
</p>
{offer.qr ? (
<div
aria-label="QR code d’enrôlement"
className="mt-2 w-40 [&>svg]:h-full [&>svg]:w-full"
// Le SVG vient du serveur, produit à partir d'une URI que nous
// avons nous-mêmes composée : il n'y a pas de contenu tiers.
dangerouslySetInnerHTML={{ __html: offer.qr }}
/>
) : null}
<p className="mt-2 text-micro text-ink-3">
Sans appareil photo, saisissez cette clé :
</p>
<p className="font-mono text-sm break-all text-ink-1">
{formatSecret(offer.secret)}
</p>
</li>
<li>
<label className="flex flex-col gap-1">
<span className="font-medium text-ink-1">
2. Recopiez le code affiché
</span>
<input
name="code"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9 ]*"
required
className="tnum h-9 w-40 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
</label>
</li>
</ol>
<div className="flex items-center gap-3">
<Button type="submit" variant="primary" disabled={confirming}>
Valider
</Button>
<Button type="button" onClick={() => setOffer(null)}>
Annuler
</Button>
{confirmState.error ? (
<span role="alert" className="text-xs text-danger">
{confirmState.error}
</span>
) : null}
</div>
</form>
);
}
/**
* Codes de secours, affichés une seule fois.
*
* Ils sont conservés hachés : ni le support ni un administrateur ne peuvent les
* relire. La seule issue, ensuite, est d'en régénérer une série.
*/
function RecoveryCodes({ codes }: { codes: string[] }) {
return (
<div className="flex flex-col gap-3 p-4">
<p className="text-sm text-ink-1">
<strong>Second facteur activé.</strong> Conservez ces codes de secours :
ils sont votre seule issue si vous perdez votre téléphone.
</p>
<ul
data-testid="recovery-codes"
className="grid gap-1 rounded-3 border border-line-2 bg-surface-2 p-3 font-mono text-sm sm:grid-cols-2"
>
{codes.map((code) => (
<li key={code} className="tnum">
{code}
</li>
))}
</ul>
<p className="text-micro text-ink-3">
Affichés une seule fois : ils ne sont pas conservés en clair. Chacun ne
sert qu’une fois.
</p>
</div>
);
}
+1
View File
@@ -84,6 +84,7 @@ export const NAVIGATION: NavSection[] = [
{ id: 'sites', label: 'Établissements', href: '/reglages/etablissements' },
{ id: 'registre', label: 'Registre de paramétrage', href: '/reglages/registre' },
{ id: 'email', label: 'Envoi de courrier', href: '/reglages/email' },
{ id: 'securite', label: 'Sécurité', href: '/reglages/securite' },
{ id: 'convention', label: 'Convention collective' },
{ id: 'postes', label: 'Postes et étiquettes' },
{ id: 'roles', label: 'Rôles et permissions' },
+22
View File
@@ -0,0 +1,22 @@
/**
* Qui doit porter un second facteur — matrice n° 15.
*
* La matrice dit « administrateurs et RH ». Ces deux mots ne désignent pas des
* rôles nommés — un client renomme ses rôles librement — mais ce que le rôle
* permet de faire. La règle est donc adossée aux capacités :
*
* - `settings.roles.manage` — qui distribue les droits peut se les donner ;
* - `members.salary.view` — qui lit les rémunérations lit la donnée RH la plus
* sensible après la santé.
*
* Une liste plutôt qu'une condition dispersée : ce qui déclenche l'obligation
* doit se lire d'un seul endroit.
*/
export const MFA_REQUIRED_CAPABILITIES = [
'settings.roles.manage',
'members.salary.view',
] as const;
export function mfaRequired(permissions: ReadonlySet<string>): boolean {
return MFA_REQUIRED_CAPABILITIES.some((code) => permissions.has(code));
}
+183
View File
@@ -0,0 +1,183 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
/**
* Second facteur temporel (TOTP, RFC 6238) — matrice n° 15.
*
* Implémenté ici plutôt qu'emprunté : l'algorithme tient en trente lignes, et
* une dépendance de plus sur le chemin d'authentification est une surface de
* plus à surveiller.
*
* Le choix du TOTP plutôt que d'un code envoyé par message tient à une raison
* simple : le second facteur ne doit pas dépendre du canal qui sert déjà à
* réinitialiser le mot de passe. Un accès à la boîte électronique donnerait
* sinon les deux facteurs d'un coup.
*/
/** 30 secondes — la valeur qu'attendent toutes les applications d'authentification. */
export const TOTP_STEP_SECONDS = 30;
export const TOTP_DIGITS = 6;
/**
* Tolérance d'un pas de part et d'autre.
*
* Zéro rejetterait un téléphone désynchronisé de quelques secondes, ce qui est
* courant ; élargir davantage allongerait d'autant la fenêtre exploitable par
* un code intercepté.
*/
export const TOTP_WINDOW = 1;
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
export function base32Encode(bytes: Uint8Array): string {
let bits = 0;
let value = 0;
let output = '';
for (const byte of bytes) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) {
output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
}
return output;
}
export function base32Decode(encoded: string): Uint8Array {
// Les utilisateurs recopient parfois le secret à la main : espaces, minuscules
// et remplissage « = » sont tolérés plutôt que refusés sans explication.
const cleaned = encoded.replace(/[\s=]/g, '').toUpperCase();
let bits = 0;
let value = 0;
const output: number[] = [];
for (const character of cleaned) {
const index = BASE32_ALPHABET.indexOf(character);
if (index === -1) {
throw new Error('Secret TOTP invalide : caractère hors alphabet base32');
}
value = (value << 5) | index;
bits += 5;
if (bits >= 8) {
output.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return Uint8Array.from(output);
}
/** Numéro de pas courant. Sert aussi à interdire le rejeu d'un code déjà employé. */
export function totpStep(atMs: number): number {
return Math.floor(atMs / 1000 / TOTP_STEP_SECONDS);
}
export function totpCodeAtStep(secret: string, step: number): string {
const key = base32Decode(secret);
const counter = Buffer.alloc(8);
// Compteur sur 64 bits big-endian. `writeBigUInt64BE` évite le débordement
// qu'un décalage 32 bits produirait au-delà de 2038.
counter.writeBigUInt64BE(BigInt(step));
const digest = createHmac('sha1', key).update(counter).digest();
// Troncature dynamique (RFC 4226 §5.3).
const offset = digest[digest.length - 1]! & 0x0f;
const binary =
((digest[offset]! & 0x7f) << 24) |
((digest[offset + 1]! & 0xff) << 16) |
((digest[offset + 2]! & 0xff) << 8) |
(digest[offset + 3]! & 0xff);
return String(binary % 10 ** TOTP_DIGITS).padStart(TOTP_DIGITS, '0');
}
export function totpCode(secret: string, atMs: number): string {
return totpCodeAtStep(secret, totpStep(atMs));
}
export interface TotpVerification {
ok: boolean;
/** Pas employé, à mémoriser pour refuser le rejeu du même code. */
step?: number;
}
/**
* Vérifie un code.
*
* `lastUsedStep` interdit qu'un code intercepté serve une seconde fois dans sa
* fenêtre de validité : sans cela, le TOTP protège d'un mot de passe volé mais
* pas d'un code lu par-dessus l'épaule.
*/
export function verifyTotp(
secret: string,
code: string,
atMs: number,
lastUsedStep: number | null = null,
): TotpVerification {
const cleaned = code.replace(/\s/g, '');
if (!/^\d{6}$/.test(cleaned)) return { ok: false };
const current = totpStep(atMs);
for (let offset = -TOTP_WINDOW; offset <= TOTP_WINDOW; offset += 1) {
const step = current + offset;
if (lastUsedStep !== null && step <= lastUsedStep) continue;
if (equals(totpCodeAtStep(secret, step), cleaned)) {
return { ok: true, step };
}
}
return { ok: false };
}
/**
* Comparaison à durée constante.
*
* Sur six chiffres le gain est théorique, mais un comparateur qui s'arrête au
* premier écart n'a aucune raison d'être employé ici.
*/
function equals(a: string, b: string): boolean {
const left = Buffer.from(a, 'utf8');
const right = Buffer.from(b, 'utf8');
if (left.length !== right.length) return false;
return timingSafeEqual(left, right);
}
export interface OtpauthInput {
secret: string;
/** Ce que l'application affichera dans sa liste : l'adresse du salarié. */
account: string;
issuer: string;
}
/**
* URI `otpauth://` que lit une application d'authentification.
*
* L'émetteur est répété dans le chemin **et** en paramètre : les applications
* n'ont jamais convergé sur l'une des deux formes, et n'en fournir qu'une donne
* des entrées mal nommées chez la moitié des utilisateurs.
*/
export function otpauthUri({ secret, account, issuer }: OtpauthInput): string {
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`;
const parameters = new URLSearchParams({
secret,
issuer,
algorithm: 'SHA1',
digits: String(TOTP_DIGITS),
period: String(TOTP_STEP_SECONDS),
});
return `otpauth://totp/${label}?${parameters.toString()}`;
}
/** Groupes de quatre — un secret recopié à la main l'est sans erreur. */
export function formatSecret(secret: string): string {
return secret.replace(/(.{4})/g, '$1 ').trim();
}
+41 -1
View File
@@ -4,7 +4,10 @@ import { cookies, headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import { answerChallenge } from '@/server/auth/mfa';
import {
pendingChallenge,
satisfyMfa,
SESSION_COOKIE,
SESSION_COOKIE_OPTIONS,
signIn,
@@ -57,7 +60,44 @@ export async function signInAction(
expires: result.expiresAt,
});
redirect('/');
// La session existe mais ne résout aucun acteur tant que le second facteur
// n'est pas présenté : rediriger vers l'application produirait une boucle.
redirect(result.mfaPending ? '/connexion/verification' : '/');
}
export interface ChallengeState {
error?: string;
}
/**
* Éprouve le second facteur.
*
* L'échec ne détruit pas la session en attente : elle expire d'elle-même en dix
* minutes, et la supprimer au premier code mal recopié renverrait ressaisir le
* mot de passe sans motif.
*/
export async function verifyMfaAction(
_previous: ChallengeState,
formData: FormData,
): Promise<ChallengeState> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (!token) return { error: 'Session expirée. Reconnectez-vous.' };
const challenge = await pendingChallenge(token);
if (!challenge) return { error: 'Session expirée. Reconnectez-vous.' };
const code = String(formData.get('code') ?? '');
const result = await answerChallenge(challenge.userId, code);
if (!result.ok) return { error: result.error };
const expiresAt = await satisfyMfa(token);
store.set(SESSION_COOKIE, token, {
...SESSION_COOKIE_OPTIONS,
expires: expiresAt,
});
redirect(result.usedRecoveryCode ? '/reglages/securite' : '/');
}
export async function signOutAction(): Promise<void> {
+145
View File
@@ -0,0 +1,145 @@
'use server';
import { revalidatePath } from 'next/cache';
import { mfaRequired } from '@/domain/access/mfa-policy';
import { recordAudit } from '@/server/audit';
import { requireSession } from '@/server/context';
import { confirmEnrolment, disableMfa, enrolmentOffer } from '@/server/auth/mfa';
import { verifyPassword } from '@/server/auth/session';
import { withTenant } from '@/server/tenant';
import { unscoped } from '@/server/tenant';
/**
* Enrôlement et retrait du second facteur.
*
* Aucune capacité n'est exigée : chacun gère **son** facteur. Nul ne peut en
* poser un sur le compte d'autrui, ce qui reviendrait à en prendre le contrôle.
*/
export interface MfaState {
error?: string;
ok?: boolean;
message?: string;
/** Rendus une seule fois, à l'écran qui vient de les produire. */
recoveryCodes?: string[];
}
export interface OfferState extends MfaState {
secret?: string;
uri?: string;
qr?: string;
}
/**
* Propose un secret.
*
* Il n'est **pas** enregistré à ce stade : il transite par le formulaire, et
* n'entre en base qu'une fois qu'un code en a été tiré. Un secret enregistré
* d'avance ferme le compte de qui abandonne l'enrôlement en cours de route.
*/
export async function startEnrolmentAction(): Promise<OfferState> {
const session = await requireSession();
const offer = enrolmentOffer(session.user.email, session.accountName);
// Import différé : le générateur de QR n'a rien à faire dans le lot commun
// des pages qui n'affichent jamais de code.
const { toString: renderQr } = await import('qrcode');
const qr = await renderQr(offer.uri, {
type: 'svg',
margin: 0,
errorCorrectionLevel: 'M',
});
return { ok: true, secret: offer.secret, uri: offer.uri, qr };
}
export async function confirmEnrolmentAction(
_previous: MfaState,
formData: FormData,
): Promise<MfaState> {
const session = await requireSession();
const userId = session.actor.userId;
const secret = String(formData.get('secret') ?? '');
const code = String(formData.get('code') ?? '');
if (!secret) return { error: 'Recommencez l’activation.' };
// Un membership sans compte utilisateur n'a pas de session : le cas ne peut
// pas se produire ici, mais le type le permet et l'ignorer masquerait un
// câblage fautif.
if (!userId) return { error: 'Compte introuvable.' };
const result = await confirmEnrolment(userId, secret, code);
if (!result.ok) return { error: result.error ?? 'Code refusé.' };
await withTenant(session.actor.accountId, (db) =>
recordAudit(db, {
actorMembershipId: session.actor.membershipId,
action: 'security.mfa.enable',
entityType: 'User',
entityId: userId,
after: { enrolled: true },
}),
);
// Pas de `revalidatePath` ici, délibérément : réactualiser la route
// remplacerait l'écran par celui d'un compte déjà enrôlé, et emporterait les
// codes de secours avant que leur destinataire ait pu les noter. Ils ne sont
// affichés qu'une fois — les perdre à l'instant même où ils sont produits est
// précisément ce qui rend un second facteur dangereux.
return {
ok: true,
message: 'Second facteur activé.',
...(result.recoveryCodes ? { recoveryCodes: result.recoveryCodes } : {}),
};
}
/**
* Retire le second facteur.
*
* Le mot de passe est redemandé : sans cela, un poste laissé ouvert suffirait à
* désarmer la protection que le facteur est censé apporter à ce poste précis.
*/
export async function disableMfaAction(
_previous: MfaState,
formData: FormData,
): Promise<MfaState> {
const session = await requireSession();
const userId = session.actor.userId;
const password = String(formData.get('password') ?? '');
if (!userId) return { error: 'Compte introuvable.' };
// L'écran masque déjà le bouton, mais un bouton masqué n'est pas un contrôle :
// l'obligation se tient là où l'effet se produit.
if (mfaRequired(session.actor.permissions)) {
return {
error:
'Votre rôle exige un second facteur : il ne peut pas être retiré. Faites d’abord modifier votre rôle.',
};
}
const user = await unscoped().user.findUnique({
where: { id: userId },
select: { passwordHash: true },
});
if (!user?.passwordHash || !(await verifyPassword(user.passwordHash, password))) {
return { error: 'Mot de passe incorrect.' };
}
await disableMfa(userId);
await withTenant(session.actor.accountId, (db) =>
recordAudit(db, {
actorMembershipId: session.actor.membershipId,
action: 'security.mfa.disable',
entityType: 'User',
entityId: userId,
before: { enrolled: true },
after: { enrolled: false },
}),
);
revalidatePath('/reglages/securite');
return { ok: true, message: 'Second facteur désactivé.' };
}
+173
View File
@@ -0,0 +1,173 @@
import 'server-only';
import { randomBytes } from 'node:crypto';
import {
base32Encode,
otpauthUri,
verifyTotp,
} from '@/domain/access/totp';
import { decrypt, encrypt, generateToken, hashToken } from '@/server/crypto';
import { unscoped } from '@/server/tenant';
/**
* Second facteur — matrice n° 15.
*
* Le secret est chiffré au repos avec la même clé que le NIR et l'IBAN, hors
* base : une sauvegarde volée ne doit pas permettre de fabriquer les codes.
*/
/** 20 octets — la taille recommandée par la RFC 4226 pour HMAC-SHA1. */
const SECRET_BYTES = 20;
/**
* Dix codes de secours.
*
* Assez pour qu'un téléphone perdu ne ferme pas l'accès, assez peu pour qu'ils
* tiennent sur une feuille qu'on range. Ils sont conservés hachés : les relire
* est impossible, en régénérer est la seule voie.
*/
const RECOVERY_CODE_COUNT = 10;
export function generateTotpSecret(): string {
return base32Encode(randomBytes(SECRET_BYTES));
}
export interface EnrolmentOffer {
secret: string;
uri: string;
}
export function enrolmentOffer(email: string, issuer: string): EnrolmentOffer {
const secret = generateTotpSecret();
return { secret, uri: otpauthUri({ secret, account: email, issuer }) };
}
/**
* Confirme un enrôlement.
*
* Le secret n'est enregistré **qu'après** qu'un code en a été tiré : enregistrer
* d'abord laisserait des comptes porteurs d'un facteur que leur détenteur ne
* sait pas produire — c'est-à-dire des comptes fermés.
*/
export async function confirmEnrolment(
userId: string,
secret: string,
code: string,
): Promise<{ ok: boolean; recoveryCodes?: string[]; error?: string }> {
const verified = verifyTotp(secret, code, Date.now());
if (!verified.ok) {
return {
ok: false,
error:
'Ce code ne correspond pas. Vérifiez l’heure de votre téléphone, puis réessayez avec le code affiché.',
};
}
const codes = Array.from({ length: RECOVERY_CODE_COUNT }, () =>
formatRecoveryCode(generateToken()),
);
const db = unscoped();
await db.$transaction([
db.user.update({
where: { id: userId },
data: {
// `Uint8Array` et non `Buffer` : c'est ce qu'attend Prisma 7 pour Bytes.
mfaSecretEnc: new Uint8Array(encrypt(secret)),
mfaEnrolledAt: new Date(),
mfaLastStep: verified.step ?? null,
},
}),
// Un renouvellement remplace les anciens : garder les deux séries
// doublerait les portes sans que personne ne sache lesquelles courent.
db.mfaRecoveryCode.deleteMany({ where: { userId } }),
db.mfaRecoveryCode.createMany({
data: codes.map((code) => ({ userId, codeHash: hashToken(code) })),
}),
]);
return { ok: true, recoveryCodes: codes };
}
export async function disableMfa(userId: string): Promise<void> {
const db = unscoped();
await db.$transaction([
db.user.update({
where: { id: userId },
data: { mfaSecretEnc: null, mfaEnrolledAt: null, mfaLastStep: null },
}),
db.mfaRecoveryCode.deleteMany({ where: { userId } }),
]);
}
export type ChallengeResult =
| { ok: true; usedRecoveryCode: boolean; remainingCodes: number }
| { ok: false; error: string };
/**
* Éprouve un code, temporel ou de secours.
*
* Les deux entrent par le même champ : demander à quelqu'un qui a perdu son
* téléphone de trouver d'abord le bon formulaire ajoute une étape au moment
* précis où il est déjà en difficulté.
*/
export async function answerChallenge(
userId: string,
code: string,
): Promise<ChallengeResult> {
const db = unscoped();
const user = await db.user.findUnique({
where: { id: userId },
select: { mfaSecretEnc: true, mfaLastStep: true },
});
if (!user?.mfaSecretEnc) {
return { ok: false, error: 'Aucun second facteur n’est enregistré.' };
}
const cleaned = code.replace(/\s/g, '');
const verified = verifyTotp(
decrypt(user.mfaSecretEnc),
cleaned,
Date.now(),
user.mfaLastStep,
);
if (verified.ok) {
await db.user.update({
where: { id: userId },
data: { mfaLastStep: verified.step ?? null },
});
const remaining = await db.mfaRecoveryCode.count({
where: { userId, usedAt: null },
});
return { ok: true, usedRecoveryCode: false, remainingCodes: remaining };
}
// Un code de secours ne s'use qu'une fois. La condition `usedAt: null` est
// portée par la mise à jour elle-même : deux envois simultanés du même code
// ne peuvent pas en consommer deux fois la valeur.
const consumed = await db.mfaRecoveryCode.updateMany({
where: { userId, codeHash: hashToken(cleaned.toLowerCase()), usedAt: null },
data: { usedAt: new Date() },
});
if (consumed.count === 1) {
const remaining = await db.mfaRecoveryCode.count({
where: { userId, usedAt: null },
});
return { ok: true, usedRecoveryCode: true, remainingCodes: remaining };
}
return {
ok: false,
error: 'Code refusé. Utilisez le code affiché maintenant, ou un code de secours.',
};
}
/** Format lisible, en minuscules : un code de secours se recopie à la main. */
function formatRecoveryCode(token: string): string {
const compact = token.replace(/[^a-z0-9]/gi, '').toLowerCase().slice(0, 16);
return `${compact.slice(0, 4)}-${compact.slice(4, 8)}-${compact.slice(8, 12)}-${compact.slice(12, 16)}`;
}
+97 -5
View File
@@ -53,8 +53,24 @@ export interface SignInInput {
userAgent?: string | null;
}
/**
* Durée d'une session en attente du second facteur.
*
* Elle ne donne accès à rien, mais elle atteste d'un mot de passe juste : lui
* laisser douze heures offrirait autant de temps pour éprouver les six chiffres
* depuis un poste laissé ouvert.
*/
const MFA_CHALLENGE_MS = 10 * 60 * 1000;
export type SignInResult =
| { ok: true; token: string; expiresAt: Date; userId: string }
| {
ok: true;
token: string;
expiresAt: Date;
userId: string;
/** Vrai quand la session attend encore le second facteur. */
mfaPending: boolean;
}
| { ok: false; reason: 'invalid' | 'locked' };
export async function signIn(input: SignInInput): Promise<SignInResult> {
@@ -94,7 +110,13 @@ export async function signIn(input: SignInInput): Promise<SignInResult> {
}
const token = generateToken();
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
// Le second facteur est exigé dès qu'il est enregistré, quel que soit le
// rôle : un salarié qui a pris la peine de l'activer ne doit pas pouvoir
// entrer sans lui.
const mfaPending = user.mfaEnrolledAt !== null && user.mfaSecretEnc !== null;
const expiresAt = new Date(
Date.now() + (mfaPending ? MFA_CHALLENGE_MS : SESSION_DURATION_MS),
);
await db.$transaction([
db.session.create({
@@ -104,6 +126,7 @@ export async function signIn(input: SignInInput): Promise<SignInResult> {
expiresAt,
ip: input.ip ?? null,
userAgent: input.userAgent ?? null,
mfaSatisfied: !mfaPending,
},
}),
db.user.update({
@@ -111,12 +134,70 @@ export async function signIn(input: SignInInput): Promise<SignInResult> {
data: {
failedAttempts: 0,
lockedUntil: null,
lastSignInAt: new Date(),
// La date de dernière connexion n'est posée qu'une fois les deux
// facteurs présentés : un mot de passe juste seul n'est pas une
// connexion.
...(mfaPending ? {} : { lastSignInAt: new Date() }),
},
}),
]);
return { ok: true, token, expiresAt, userId: user.id };
return { ok: true, token, expiresAt, userId: user.id, mfaPending };
}
export interface PendingChallenge {
sessionId: string;
userId: string;
email: string;
}
/**
* Session ouverte mais en attente du second facteur.
*
* Distincte de `resolveSession`, qui refuse ces sessions : l'écran de défi est
* le seul endroit où elles ont un sens, et les confondre reviendrait à laisser
* une session à demi ouverte circuler dans l'application.
*/
export async function pendingChallenge(
token: string,
): Promise<PendingChallenge | null> {
const session = await unscoped().session.findUnique({
where: { tokenHash: hashToken(token) },
include: { user: { select: { id: true, email: true } } },
});
if (
!session ||
session.mfaSatisfied ||
session.revokedAt ||
session.expiresAt < new Date()
) {
return null;
}
return {
sessionId: session.id,
userId: session.user.id,
email: session.user.email,
};
}
/** Promeut une session en attente en session pleine. */
export async function satisfyMfa(token: string): Promise<Date> {
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
const db = unscoped();
const session = await db.session.update({
where: { tokenHash: hashToken(token) },
data: { mfaSatisfied: true, expiresAt },
});
await db.user.update({
where: { id: session.userId },
data: { lastSignInAt: new Date() },
});
return expiresAt;
}
export async function signOut(token: string): Promise<void> {
@@ -143,6 +224,8 @@ export interface SessionUser {
lastName: string;
email: string;
initials: string;
/** Un second facteur est enregistré pour ce compte. */
mfaEnrolled: boolean;
}
export interface SessionContext {
@@ -182,7 +265,15 @@ export async function resolveSession(
},
});
if (!session || session.revokedAt || session.expiresAt < new Date()) {
// Une session en attente du second facteur ne résout aucun acteur : elle ne
// porte qu'un défi. C'est ici, au point de passage unique, que la garantie
// tient — pas dans chaque écran.
if (
!session ||
!session.mfaSatisfied ||
session.revokedAt ||
session.expiresAt < new Date()
) {
return null;
}
@@ -220,6 +311,7 @@ export async function resolveSession(
lastName,
email,
initials: `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(),
mfaEnrolled: session.user.mfaEnrolledAt !== null,
},
accountName: membership.account.name,
roleName: membership.role.name,
+33 -2
View File
@@ -1,19 +1,50 @@
import { test as setup, expect } from '@playwright/test';
import { rememberSecret } from './mfa';
import { STORAGE_STATE } from './storage';
import { resetMfa } from './support/db';
import { totpCode, totpStep } from '../../src/domain/access/totp';
/**
* Ouvre une session une fois et enregistre le cookie pour les autres tests.
*
* Chaque test se connecterait sinon, ce qui coûterait un argon2 par test —
* volontairement lent — et ferait grimper le compteur d'échecs partagé.
*
* Le rôle de la direction exige un second facteur (matrice n° 15) : la mise en
* place l'enrôle donc réellement. Le retrait préalable rend l'opération
* rejouable — sans lui, un deuxième passage se heurterait au facteur posé par
* le premier, dont le secret a disparu avec lui.
*/
const EMAIL = 'direction@example.test';
const PASSWORD = 'planflow-demo-2026';
setup('authentifie la direction', async ({ page }) => {
await resetMfa(EMAIL);
await page.goto('/connexion');
await page.getByLabel('Adresse électronique').fill('direction@example.test');
await page.getByLabel('Mot de passe').fill('planflow-demo-2026');
await page.getByLabel('Adresse électronique').fill(EMAIL);
await page.getByLabel('Mot de passe').fill(PASSWORD);
await page.getByRole('button', { name: 'Se connecter' }).click();
// Le rôle exige le second facteur : l'application substitue l'enrôlement au
// contenu tant qu'il n'est pas posé.
await page.getByRole('button', { name: 'Activer le second facteur' }).click();
const shown = await page.locator('p.font-mono').first().textContent();
const secret = (shown ?? '').replace(/\s/g, '');
expect(secret.length).toBeGreaterThan(16);
const usedStep = totpStep(Date.now());
await page
.getByRole('textbox', { name: 'Recopiez le code affiché' })
.fill(totpCode(secret, Date.now()));
await page.getByRole('button', { name: 'Valider' }).click();
await expect(page.getByTestId('recovery-codes')).toBeVisible();
rememberSecret(secret, usedStep);
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
await page.context().storageState({ path: STORAGE_STATE });
});
+13
View File
@@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test';
import { answerChallenge, rememberedSecret, waitForFreshCode } from './mfa';
const EMAIL = 'direction@example.test';
const PASSWORD = 'planflow-demo-2026';
@@ -34,11 +36,22 @@ test('un mot de passe faux ne dit pas si le compte existe', async ({ page }) =>
});
test('connexion, navigation, puis déconnexion', async ({ page }) => {
const remembered = rememberedSecret();
// Un code ne sert qu'une fois : attendre le pas suivant celui qu'a employé la
// mise en place, plutôt que de se heurter au refus de rejeu. L'attente est
// nulle dès que trente secondes se sont écoulées entre-temps.
await waitForFreshCode(remembered.usedStep);
await page.goto('/connexion');
await page.getByLabel('Adresse électronique').fill(EMAIL);
await page.getByLabel('Mot de passe').fill(PASSWORD);
await page.getByRole('button', { name: 'Se connecter' }).click();
// Le rôle de la direction exige un second facteur : le mot de passe seul
// n'ouvre rien.
await expect(page.getByRole('heading', { name: 'Vérification' })).toBeVisible();
await answerChallenge(page, remembered.secret);
await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
// L'identité affichée vient de la base, pas d'un libellé en dur.
await expect(page.getByTitle(/Camille Ferrand/)).toBeVisible();
+66
View File
@@ -0,0 +1,66 @@
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import type { Page } from '@playwright/test';
import { totpCode, totpStep } from '../../src/domain/access/totp';
/**
* Second facteur, côté tests.
*
* Le propriétaire de démonstration porte un rôle qui l'exige : la suite doit
* donc l'enrôler pour de bon, en calculant les codes comme le ferait une
* application d'authentification. Court-circuiter la vérification testerait un
* produit que personne n'utilise.
*/
export const MFA_SECRET_FILE = 'test-results/.auth/mfa-secret.txt';
export interface RememberedSecret {
secret: string;
/** Dernier pas consommé, pour ne pas se heurter au refus de rejeu. */
usedStep: number;
}
export function rememberSecret(secret: string, usedStep: number): void {
mkdirSync(dirname(MFA_SECRET_FILE), { recursive: true });
writeFileSync(
MFA_SECRET_FILE,
JSON.stringify({ secret, usedStep } satisfies RememberedSecret),
'utf8',
);
}
export function rememberedSecret(): RememberedSecret {
return JSON.parse(readFileSync(MFA_SECRET_FILE, 'utf8')) as RememberedSecret;
}
export function codeNow(secret: string): string {
return totpCode(secret, Date.now());
}
/**
* Attend le pas suivant.
*
* Un code n'est accepté qu'une fois : deux connexions d'affilée dans la même
* fenêtre de trente secondes échoueraient sans cette attente — ce qui est le
* comportement voulu, pas un défaut à contourner en production.
*/
export async function waitForFreshCode(after: number): Promise<void> {
while (totpStep(Date.now()) <= after) {
await new Promise((resolve) => setTimeout(resolve, 400));
}
}
export function stepNow(): number {
return totpStep(Date.now());
}
/** Répond au défi affiché après le mot de passe. */
export async function answerChallenge(
page: Page,
secret: string,
): Promise<void> {
await page.getByLabel('Code').fill(codeNow(secret));
await page.getByRole('button', { name: 'Vérifier' }).click();
}
+75
View File
@@ -0,0 +1,75 @@
import { expect, test } from '@playwright/test';
/**
* Registre de paramétrage et création d'un salarié sans accès applicatif.
*
* Ces parcours n'éprouvent pas l'authentification : ils réutilisent la session
* commune plutôt que d'ouvrir la leur. La direction porte un second facteur, et
* un code TOTP ne sert qu'une fois — deux connexions parallèles sur ce compte
* échoueraient, à raison.
*/
test('la direction lit et alimente le registre de paramétrage', async ({
page,
}) => {
await page.goto('/reglages/registre');
await expect(
page.getByRole('heading', { name: 'Registre de paramétrage juridique' }),
).toBeVisible();
const parameter = `Durée quotidienne maximale ${Date.now()}`;
// Ciblage par attribut `name` : les libellés portent un texte d'aide, et
// celui de « Source » contient lui-même le mot « valeur », ce qui rend la
// correspondance par libellé ambiguë.
const form = page.locator('form').filter({ hasText: 'Consigner' });
await form.locator('input[name="key"]').fill(parameter);
await form.locator('input[name="value"]').fill('10 h');
await form
.locator('input[name="source"]')
.fill('IDCC 1517 — texte consolidé Legifrance');
await form.locator('input[name="population"]').fill('Tous les salariés');
await page.getByRole('button', { name: 'Consigner' }).click();
const row = page.getByRole('row', { name: new RegExp(parameter) });
await expect(row).toBeVisible();
// Consigné n'est pas approuvé : la matrice exige un approbateur nommé.
await row.getByRole('button', { name: 'Approuver' }).click();
await expect(row.getByText(/Approuvé le/)).toBeVisible();
});
test('un salarié sans compte applicatif est créable', async ({ page }) => {
await page.goto('/equipe');
await expect(page.getByRole('heading', { name: 'Équipe' })).toBeVisible();
// L'effectif vient de la base, pas du module de démonstration.
await expect(page.getByText('E0001')).toBeVisible();
// Nom unique par exécution : la base de test n'est pas remise à zéro entre
// deux passages, et un nom fixe finirait par désigner plusieurs salariés.
const matricule = `E9${Date.now() % 100000}`;
const nom = `Sanscompte${matricule}`;
const form = page.locator('form').filter({ hasText: 'Ajouter' });
await form.locator('input[name="firstName"]').fill('Sans');
await form.locator('input[name="lastName"]').fill(nom);
await form.locator('input[name="employeeNumber"]').fill(matricule);
await page.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
// Rechargement explicite : ce qui est vérifié ici est la persistance et la
// présence dans l'annuaire, pas le moment exact où la revalidation atteint
// le rendu courant.
await page.reload();
// Un salarié sans adresse doit exister : la plupart des équipes de vente ne
// se connectent jamais à l'outil.
await expect(
page.getByRole('cell', { name: matricule, exact: true }),
).toBeVisible();
// Le nom vit sur le dossier, pas sur le compte : un salarié sans accès
// applicatif doit tout de même figurer nommément au registre du personnel.
await expect(
page.getByRole('link', { name: new RegExp(`Sans ${nom}`) }),
).toBeVisible();
});
+6 -67
View File
@@ -3,6 +3,12 @@ import { expect, test } from '@playwright/test';
/**
* Ce test se connecte en manager : il ne peut donc pas réutiliser la session
* partagée de la direction, d'où le projet « anonyme ».
*
* Les écrans qui n'éprouvent pas l'authentification ont été déplacés dans
* `registre.spec.ts`, où ils réutilisent la session commune : la direction
* porte désormais un second facteur, et deux connexions simultanées sur le même
* compte se heurteraient au refus de rejeu d'un code — ce qui est le
* comportement voulu, pas un défaut à contourner.
*/
async function signIn(page: import('@playwright/test').Page, email: string) {
await page.goto('/connexion');
@@ -12,37 +18,6 @@ async function signIn(page: import('@playwright/test').Page, email: string) {
await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
}
test('la direction lit et alimente le registre de paramétrage', async ({
page,
}) => {
await signIn(page, 'direction@example.test');
await page.goto('/reglages/registre');
await expect(
page.getByRole('heading', { name: 'Registre de paramétrage juridique' }),
).toBeVisible();
const parameter = `Durée quotidienne maximale ${Date.now()}`;
// Ciblage par attribut `name` : les libellés portent un texte d'aide, et
// celui de « Source » contient lui-même le mot « valeur », ce qui rend la
// correspondance par libellé ambiguë.
const form = page.locator('form').filter({ hasText: 'Consigner' });
await form.locator('input[name="key"]').fill(parameter);
await form.locator('input[name="value"]').fill('10 h');
await form
.locator('input[name="source"]')
.fill('IDCC 1517 — texte consolidé Legifrance');
await form.locator('input[name="population"]').fill('Tous les salariés');
await page.getByRole('button', { name: 'Consigner' }).click();
const row = page.getByRole('row', { name: new RegExp(parameter) });
await expect(row).toBeVisible();
// Consigné n'est pas approuvé : la matrice exige un approbateur nommé.
await row.getByRole('button', { name: 'Approuver' }).click();
await expect(row.getByText(/Approuvé le/)).toBeVisible();
});
test('un manager ne peut ni voir ni modifier les établissements', async ({
page,
}) => {
@@ -58,39 +33,3 @@ test('un manager ne peut ni voir ni modifier les établissements', async ({
page.getByRole('heading', { name: 'Établissements' }),
).toBeHidden();
});
test('un salarié sans compte applicatif est créable', async ({ page }) => {
await signIn(page, 'direction@example.test');
await page.goto('/equipe');
await expect(page.getByRole('heading', { name: 'Équipe' })).toBeVisible();
// L'effectif vient de la base, pas du module de démonstration.
await expect(page.getByText('E0001')).toBeVisible();
// Nom unique par exécution : la base de test n'est pas remise à zéro entre
// deux passages, et un nom fixe finirait par désigner plusieurs salariés.
const matricule = `E9${Date.now() % 100000}`;
const nom = `Sanscompte${matricule}`;
const form = page.locator('form').filter({ hasText: 'Ajouter' });
await form.locator('input[name="firstName"]').fill('Sans');
await form.locator('input[name="lastName"]').fill(nom);
await form.locator('input[name="employeeNumber"]').fill(matricule);
await page.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
// Rechargement explicite : ce qui est vérifié ici est la persistance et la
// présence dans l'annuaire, pas le moment exact où la revalidation atteint
// le rendu courant.
await page.reload();
// Un salarié sans adresse doit exister : la plupart des équipes de vente ne
// se connectent jamais à l'outil.
await expect(
page.getByRole('cell', { name: matricule, exact: true }),
).toBeVisible();
// Le nom vit sur le dossier, pas sur le compte : un salarié sans accès
// applicatif doit tout de même figurer nommément au registre du personnel.
await expect(
page.getByRole('link', { name: new RegExp(`Sans ${nom}`) }),
).toBeVisible();
});
+193
View File
@@ -0,0 +1,193 @@
import { expect, test, type Page } from '@playwright/test';
import { codeNow, stepNow, waitForFreshCode } from './mfa';
/**
* Second facteur — matrice n° 15.
*
* Les codes sont calculés depuis le secret affiché, exactement comme le ferait
* l'application d'un téléphone. Court-circuiter la vérification testerait un
* produit que personne n'utilise.
*
* La direction est enrôlée par la mise en place et son rôle l'exige : c'est sur
* elle qu'on éprouve le refus de retrait. Le cycle complet — activer, se
* connecter, désactiver — se joue sur un compte ordinaire, créé pour l'occasion.
*/
const OWNER_PASSWORD = 'planflow-demo-2026';
const EMPLOYEE_PASSWORD = 'les mesanges du soir';
test('la direction ne peut pas retirer un facteur que son rôle exige', async ({
page,
}) => {
await page.goto('/reglages/securite');
await expect(page.getByText('Second facteur actif')).toBeVisible();
await expect(page.getByText(/exigé.+ne peut pas être retiré/)).toBeVisible();
// Le formulaire de retrait n'est pas seulement masqué : il n'existe pas.
await expect(page.getByRole('button', { name: 'Désactiver' })).toHaveCount(0);
});
// Le parcours attend deux fois le pas TOTP suivant — trente secondes chacune —
// parce qu'un code ne sert qu'une fois. C'est le comportement voulu, pas une
// lenteur à corriger.
test('un compte ordinaire active, éprouve, puis retire son second facteur', async ({
page,
browser,
}) => {
test.setTimeout(150_000);
const account = await createAccessibleEmployee(page);
const context = await browser.newContext({ storageState: undefined });
const own = await context.newPage();
await signIn(own, account.email, EMPLOYEE_PASSWORD);
// Un salarié ordinaire n'est pas contraint au second facteur : il entre
// directement, sans écran d'enrôlement.
await expect(own.getByRole('button', { name: 'Déconnexion' })).toBeVisible();
await own.goto('/reglages/securite');
await expect(own.getByText('Non activé')).toBeVisible();
await own.getByRole('button', { name: 'Activer le second facteur' }).click();
const secret = await readSecret(own);
const codeField = own.getByRole('textbox', {
name: 'Recopiez le code affiché',
});
// Un code faux n'enregistre rien : poser un secret sans preuve que son
// détenteur sait le produire fermerait le compte.
await codeField.fill('000000');
await own.getByRole('button', { name: 'Valider' }).click();
await expect(own.getByText(/ne correspond pas/)).toBeVisible();
const enrolStep = stepNow();
await codeField.fill(codeNow(secret));
await own.getByRole('button', { name: 'Valider' }).click();
const codes = own.getByTestId('recovery-codes');
await expect(codes).toBeVisible();
await expect(codes.getByRole('listitem')).toHaveCount(10);
const recoveryCode = (await codes.getByRole('listitem').first().textContent())!.trim();
// Le mot de passe seul n'ouvre plus rien.
const second = await browser.newContext({ storageState: undefined });
const fresh = await second.newPage();
await signIn(fresh, account.email, EMPLOYEE_PASSWORD);
await expect(fresh.getByRole('heading', { name: 'Vérification' })).toBeVisible();
// Une session en attente n'ouvre aucun écran : elle ne porte qu'un défi.
await fresh.goto('/equipe');
await expect(fresh).toHaveURL(/connexion/);
await signIn(fresh, account.email, EMPLOYEE_PASSWORD);
await fresh.getByLabel('Code').fill('123456');
await fresh.getByRole('button', { name: 'Vérifier' }).click();
await expect(fresh.getByText(/Code refusé/)).toBeVisible();
await waitForFreshCode(enrolStep);
await fresh.getByLabel('Code').fill(codeNow(secret));
await fresh.getByRole('button', { name: 'Vérifier' }).click();
await expect(fresh).not.toHaveURL(/verification/);
await second.close();
// Un code de secours ouvre aussi, et ne sert qu'une fois.
const third = await browser.newContext({ storageState: undefined });
const rescued = await third.newPage();
await signIn(rescued, account.email, EMPLOYEE_PASSWORD);
await rescued.getByLabel('Code').fill(recoveryCode);
await rescued.getByRole('button', { name: 'Vérifier' }).click();
await expect(rescued).toHaveURL(/reglages\/securite/);
const fourth = await browser.newContext({ storageState: undefined });
const replay = await fourth.newPage();
await signIn(replay, account.email, EMPLOYEE_PASSWORD);
await replay.getByLabel('Code').fill(recoveryCode);
await replay.getByRole('button', { name: 'Vérifier' }).click();
await expect(replay.getByText(/Code refusé/)).toBeVisible();
await fourth.close();
// Le retrait exige le mot de passe : un poste laissé ouvert ne suffit pas.
await rescued.goto('/reglages/securite');
await rescued.getByLabel('Mot de passe').fill('pas le bon mot de passe');
await rescued.getByRole('button', { name: 'Désactiver' }).click();
await expect(rescued.getByText('Mot de passe incorrect.')).toBeVisible();
await rescued.getByLabel('Mot de passe').fill(EMPLOYEE_PASSWORD);
await rescued.getByRole('button', { name: 'Désactiver' }).click();
await expect(rescued.getByText('Second facteur désactivé.')).toBeVisible();
await third.close();
await context.close();
});
async function signIn(page: Page, email: string, password: string) {
await page.goto('/connexion');
await page.getByLabel('Adresse électronique').fill(email);
await page.getByLabel('Mot de passe').fill(password);
await page.getByRole('button', { name: 'Se connecter' }).click();
// Attendre que la page ait quitté le formulaire : naviguer pendant que
// l'action est encore en vol l'annulerait, et la session ne serait jamais
// posée.
await page.waitForURL((url) => !url.pathname.endsWith('/connexion'));
}
/** Le secret est affiché en clair pour qui n'a pas d'appareil photo. */
async function readSecret(page: Page): Promise<string> {
const shown = await page.locator('p.font-mono').first().textContent();
return (shown ?? '').replace(/\s/g, '');
}
/**
* Crée un salarié et lui ouvre un accès.
*
* Chaque exécution le sien : le second facteur est porté par la personne, et
* deux passages qui se partageraient un compte se heurteraient au facteur posé
* par le précédent.
*/
async function createAccessibleEmployee(page: Page) {
const suffix = `${Date.now()}-mfa`;
const lastName = `Facteur${suffix}`;
const email = `facteur.${suffix}@exemple.test`;
await page.goto('/equipe');
const form = page.locator('form').filter({ hasText: 'Ajouter' });
await form.getByLabel('Prénom').fill('Noé');
await form.getByLabel('Nom', { exact: true }).fill(lastName);
await form.getByLabel('Matricule').fill(`MFA${suffix}`);
await form.getByLabel('Adresse électronique').fill(email);
await form.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
await page.getByRole('link', { name: new RegExp(lastName) }).click();
await page
.locator('form')
.filter({ hasText: 'Adresse d’invitation' })
.getByRole('button')
.click();
const link = page.getByTestId('invitation-link');
await expect(link).toBeVisible();
const url = (await link.textContent())!.trim();
const guest = await page.context().browser()!.newContext({
storageState: undefined,
});
const guestPage = await guest.newPage();
await guestPage.goto(url);
await guestPage.getByLabel('Choisissez un mot de passe').fill(EMPLOYEE_PASSWORD);
await guestPage.getByLabel('Confirmez').fill(EMPLOYEE_PASSWORD);
await guestPage.getByRole('button', { name: 'Activer mon accès' }).click();
await expect(guestPage.getByText(/Votre accès est actif/)).toBeVisible();
await guest.close();
return { email, lastName };
}
test('le mot de passe du propriétaire reste inchangé', async ({ page }) => {
// Garde-fou : les tests ci-dessus manipulent l'authentification, et une
// dérive silencieuse fermerait la suite entière au passage suivant.
await page.goto('/reglages/securite');
await expect(page.getByRole('heading', { name: 'Sécurité' })).toBeVisible();
expect(OWNER_PASSWORD).toBe('planflow-demo-2026');
});
+35
View File
@@ -0,0 +1,35 @@
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
/**
* Accès direct à la base, pour la mise en place des tests seulement.
*
* Certains états ne se posent pas par l'interface — retirer un second facteur
* dont on a perdu le secret, par exemple. Les fabriquer ici garde la suite
* rejouable sans ajouter au produit une porte qui n'aurait pas lieu d'exister.
*/
let client: PrismaClient | null = null;
function db(): PrismaClient {
// Prisma 7 exige un adaptateur : le client applicatif n'est pas réutilisable
// ici, il vit derrière `server-only`.
client ??= new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
return client;
}
/** Remet un compte à l'état « aucun second facteur », sans effet s'il n'en a pas. */
export async function resetMfa(email: string): Promise<void> {
const user = await db().user.findUnique({
where: { email },
select: { id: true },
});
if (!user) return;
await db().user.update({
where: { id: user.id },
data: { mfaSecretEnc: null, mfaEnrolledAt: null, mfaLastStep: null },
});
await db().mfaRecoveryCode.deleteMany({ where: { userId: user.id } });
}
+174
View File
@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest';
import {
base32Decode,
base32Encode,
formatSecret,
otpauthUri,
totpCode,
totpCodeAtStep,
totpStep,
TOTP_STEP_SECONDS,
verifyTotp,
} from '@/domain/access/totp';
import { mfaRequired } from '@/domain/access/mfa-policy';
/**
* Vecteurs de la RFC 6238, appendice B.
*
* Le secret y est la chaîne ASCII « 12345678901234567890 ». Éprouver contre des
* valeurs publiées est ce qui distingue « le code change toutes les trente
* secondes » de « le code est celui qu'attend l'application du téléphone ».
*/
const RFC_SECRET = base32Encode(
Uint8Array.from(Buffer.from('12345678901234567890', 'ascii')),
);
describe('base32', () => {
it('fait l’aller-retour', () => {
const bytes = Uint8Array.from([0, 1, 2, 250, 255, 128, 64]);
expect(base32Decode(base32Encode(bytes))).toEqual(bytes);
});
it('encode selon l’alphabet standard', () => {
expect(base32Encode(Uint8Array.from(Buffer.from('foobar', 'ascii')))).toBe(
'MZXW6YTBOI',
);
});
it('tolère espaces, minuscules et remplissage', () => {
// Un secret recopié à la main arrive rarement propre.
expect(base32Decode('mzxw 6ytb oi==')).toEqual(
base32Decode('MZXW6YTBOI'),
);
});
it('refuse un caractère hors alphabet', () => {
expect(() => base32Decode('MZXW6YTB01')).toThrow(/base32/);
});
});
describe('TOTP — vecteurs RFC 6238', () => {
// La RFC publie huit chiffres ; PlanFlow en produit six, soit les six
// derniers du même calcul.
const vectors: Array<[seconds: number, expected: string]> = [
[59, '287082'],
[1_111_111_109, '081804'],
[1_111_111_111, '050471'],
[1_234_567_890, '005924'],
[2_000_000_000, '279037'],
];
for (const [seconds, expected] of vectors) {
it(`produit ${expected} à T=${seconds}`, () => {
expect(totpCode(RFC_SECRET, seconds * 1000)).toBe(expected);
});
}
});
describe('pas de temps', () => {
it('avance toutes les trente secondes', () => {
expect(totpStep(0)).toBe(0);
expect(totpStep((TOTP_STEP_SECONDS - 1) * 1000)).toBe(0);
expect(totpStep(TOTP_STEP_SECONDS * 1000)).toBe(1);
});
});
describe('vérification', () => {
const now = 1_700_000_000_000;
it('accepte le code courant', () => {
expect(verifyTotp(RFC_SECRET, totpCode(RFC_SECRET, now), now).ok).toBe(true);
});
it('tolère un pas de dérive de part et d’autre', () => {
// Une horloge de téléphone en avance de quelques secondes est courante ;
// refuser cette dérive rendrait le facteur inutilisable un jour sur deux.
const step = totpStep(now);
for (const offset of [-1, 1]) {
const code = totpCodeAtStep(RFC_SECRET, step + offset);
expect(verifyTotp(RFC_SECRET, code, now).ok).toBe(true);
}
});
it('refuse au-delà de la tolérance', () => {
const code = totpCodeAtStep(RFC_SECRET, totpStep(now) + 2);
expect(verifyTotp(RFC_SECRET, code, now).ok).toBe(false);
});
it('refuse le rejeu d’un code déjà employé', () => {
// Sans cela, le facteur protège d'un mot de passe volé mais pas d'un code
// lu par-dessus l'épaule pendant ses trente secondes.
const step = totpStep(now);
const code = totpCodeAtStep(RFC_SECRET, step);
expect(verifyTotp(RFC_SECRET, code, now, null)).toEqual({ ok: true, step });
expect(verifyTotp(RFC_SECRET, code, now, step).ok).toBe(false);
});
it('refuse aussi un code antérieur au dernier employé', () => {
const step = totpStep(now);
const previous = totpCodeAtStep(RFC_SECRET, step - 1);
expect(verifyTotp(RFC_SECRET, previous, now, step).ok).toBe(false);
});
it('refuse ce qui n’a pas la forme d’un code', () => {
expect(verifyTotp(RFC_SECRET, '12345', now).ok).toBe(false);
expect(verifyTotp(RFC_SECRET, 'abcdef', now).ok).toBe(false);
expect(verifyTotp(RFC_SECRET, '', now).ok).toBe(false);
});
it('ignore les espaces de saisie', () => {
const code = totpCode(RFC_SECRET, now);
const spaced = `${code.slice(0, 3)} ${code.slice(3)}`;
expect(verifyTotp(RFC_SECRET, spaced, now).ok).toBe(true);
});
});
describe('URI otpauth', () => {
const uri = otpauthUri({
secret: 'JBSWY3DPEHPK3PXP',
account: 'camille@example.fr',
issuer: 'Maison Rivage',
});
it('porte l’émetteur dans le chemin et en paramètre', () => {
// Les applications n'ont jamais convergé : n'en fournir qu'une donne des
// entrées mal nommées chez la moitié des utilisateurs.
expect(uri).toContain('otpauth://totp/Maison%20Rivage:');
expect(uri).toContain('issuer=Maison+Rivage');
});
it('déclare les paramètres attendus', () => {
expect(uri).toContain('algorithm=SHA1');
expect(uri).toContain('digits=6');
expect(uri).toContain('period=30');
});
it('échappe l’adresse', () => {
expect(uri).toContain('camille%40example.fr');
});
});
describe('secret lisible', () => {
it('se groupe par quatre', () => {
expect(formatSecret('JBSWY3DPEHPK3PXP')).toBe('JBSW Y3DP EHPK 3PXP');
});
});
describe('obligation de second facteur', () => {
it('vise qui distribue les droits', () => {
expect(mfaRequired(new Set(['settings.roles.manage']))).toBe(true);
});
it('vise qui lit les rémunérations', () => {
expect(mfaRequired(new Set(['members.salary.view']))).toBe(true);
});
it('épargne un salarié ordinaire', () => {
// Imposer le facteur à toute l'équipe de vente le rendrait contournable
// par la première demande d'assistance.
expect(mfaRequired(new Set(['planning.view', 'timeoff.request']))).toBe(
false,
);
});
});