Merge pull request #19 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre

Exiger un second facteur des rôles administrateur et RH
This commit is contained in:
LogiFlow authored and GitHub committed 2026-08-09 00:06:28 +02:00
commit 5a15a25145
27 files changed
+1995 -77

No files matched your search

+13 -1
View File
@@ -76,7 +76,19 @@ pnpm dev
openssl rand -base64 32 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 ## Vérifier
+3
View File
@@ -21,6 +21,7 @@
"db:deploy": "prisma migrate deploy", "db:deploy": "prisma migrate deploy",
"db:seed": "tsx prisma/seed.ts", "db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio", "db:studio": "prisma studio",
"mfa:reset": "tsx scripts/mfa-reset.ts",
"verify": "pnpm typecheck && pnpm lint && pnpm test" "verify": "pnpm typecheck && pnpm lint && pnpm test"
}, },
"prisma": { "prisma": {
@@ -33,6 +34,7 @@
"next": "16.3.0", "next": "16.3.0",
"nodemailer": "^9.0.5", "nodemailer": "^9.0.5",
"pg": "8.22.0", "pg": "8.22.0",
"qrcode": "^1.5.4",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"zod": "4.4.3" "zod": "4.4.3"
@@ -43,6 +45,7 @@
"@types/node": "22.19.4", "@types/node": "22.19.4",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"@types/pg": "8.20.4", "@types/pg": "8.20.4",
"@types/qrcode": "^1.5.6",
"@types/react": "19.2.18", "@types/react": "19.2.18",
"@types/react-dom": "19.2.4", "@types/react-dom": "19.2.4",
"dotenv": "^17.4.2", "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 { defineConfig, devices } from '@playwright/test';
import { STORAGE_STATE } from './tests/e2e/storage'; import { STORAGE_STATE } from './tests/e2e/storage';
@@ -38,9 +42,12 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'], ...chromiumOverride }, use: { ...devices['Desktop Chrome'], ...chromiumOverride },
}, },
// Parcours d'authentification : doit partir d'un navigateur vierge. // 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', name: 'anonyme',
testMatch: /(auth|reglages|acces)\.spec\.ts/, testMatch: /(auth|reglages|acces)\.spec\.ts/,
dependencies: ['setup'],
use: { ...devices['Desktop Chrome'], ...chromiumOverride }, use: { ...devices['Desktop Chrome'], ...chromiumOverride },
}, },
{ {
+199
View File
@@ -26,6 +26,9 @@ importers:
pg: pg:
specifier: 8.22.0 specifier: 8.22.0
version: 8.22.0 version: 8.22.0
qrcode:
specifier: ^1.5.4
version: 1.5.4
react: react:
specifier: 19.2.8 specifier: 19.2.8
version: 19.2.8 version: 19.2.8
@@ -51,6 +54,9 @@ importers:
'@types/pg': '@types/pg':
specifier: 8.20.4 specifier: 8.20.4
version: 8.20.4 version: 8.20.4
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@types/react': '@types/react':
specifier: 19.2.18 specifier: 19.2.18
version: 19.2.18 version: 19.2.18
@@ -1165,6 +1171,9 @@ packages:
'@types/pg@8.20.4': '@types/pg@8.20.4':
resolution: {integrity: sha512-Jz7UDOlIiFJuacC0TlBoLyNtmwlA/wpIyPDd3tvUqlRM+HzkWy2xUgpFpaXtbfTAFF6sIGq5lsCDBdJnhky1Xg==} resolution: {integrity: sha512-Jz7UDOlIiFJuacC0TlBoLyNtmwlA/wpIyPDd3tvUqlRM+HzkWy2xUgpFpaXtbfTAFF6sIGq5lsCDBdJnhky1Xg==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/react-dom@19.2.4': '@types/react-dom@19.2.4':
resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==}
peerDependencies: peerDependencies:
@@ -1432,6 +1441,10 @@ packages:
ajv@8.20.0: ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} 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: ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1557,6 +1570,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001809: caniuse-lite@1.0.30001809:
resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==}
@@ -1578,6 +1595,9 @@ packages:
client-only@0.0.1: client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@@ -1681,6 +1701,10 @@ packages:
supports-color: supports-color:
optional: true optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
deep-is@0.1.4: deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -1713,6 +1737,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
doctrine@2.1.0: doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -1734,6 +1761,9 @@ packages:
elkjs@0.11.1: elkjs@0.11.1:
resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
emoji-regex@9.2.2: emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
@@ -1981,6 +2011,10 @@ packages:
resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==} resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
find-up@5.0.0: find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2031,6 +2065,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'} 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: get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2194,6 +2232,10 @@ packages:
resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
engines: {node: '>= 0.4'} 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: is-generator-function@1.1.2:
resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2470,6 +2512,10 @@ packages:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
locate-path@6.0.0: locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2623,14 +2669,26 @@ packages:
resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-limit@3.1.0: p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-locate@5.0.0: p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'} engines: {node: '>=10'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
parent-module@1.0.1: parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -2710,6 +2768,10 @@ packages:
engines: {node: '>=20'} engines: {node: '>=20'}
hasBin: true hasBin: true
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
possible-typed-array-names@1.1.0: possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2776,6 +2838,11 @@ packages:
pure-rand@6.1.0: pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
queue-microtask@1.2.3: queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -2809,10 +2876,17 @@ packages:
remeda@2.33.4: remeda@2.33.4:
resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} 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: require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resolve-from@4.0.0: resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -2882,6 +2956,9 @@ packages:
seq-queue@0.0.5: seq-queue@0.0.5:
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} 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: set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2965,6 +3042,10 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'} 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: string.prototype.includes@2.0.1:
resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2988,6 +3069,10 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-bom@3.0.0: strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -3215,6 +3300,9 @@ packages:
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which-typed-array@1.1.22: which-typed-array@1.1.22:
resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3233,13 +3321,28 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
xtend@4.0.2: xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'} engines: {node: '>=0.4'}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yallist@3.1.1: yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} 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: yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -4150,6 +4253,10 @@ snapshots:
pg-protocol: 1.15.0 pg-protocol: 1.15.0
pg-types: 2.2.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)': '@types/react-dom@19.2.4(@types/react@19.2.18)':
dependencies: dependencies:
'@types/react': 19.2.18 '@types/react': 19.2.18
@@ -4457,6 +4564,8 @@ snapshots:
json-schema-traverse: 1.0.0 json-schema-traverse: 1.0.0
require-from-string: 2.0.2 require-from-string: 2.0.2
ansi-regex@5.0.1: {}
ansi-styles@4.3.0: ansi-styles@4.3.0:
dependencies: dependencies:
color-convert: 2.0.1 color-convert: 2.0.1
@@ -4611,6 +4720,8 @@ snapshots:
callsites@3.1.0: {} callsites@3.1.0: {}
camelcase@5.3.1: {}
caniuse-lite@1.0.30001809: {} caniuse-lite@1.0.30001809: {}
chai@6.2.2: {} chai@6.2.2: {}
@@ -4628,6 +4739,12 @@ snapshots:
client-only@0.0.1: {} 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: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@@ -4722,6 +4839,8 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decamelize@1.2.0: {}
deep-is@0.1.4: {} deep-is@0.1.4: {}
deepmerge-ts@7.1.5: {} deepmerge-ts@7.1.5: {}
@@ -4750,6 +4869,8 @@ snapshots:
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
dijkstrajs@1.0.3: {}
doctrine@2.1.0: doctrine@2.1.0:
dependencies: dependencies:
esutils: 2.0.3 esutils: 2.0.3
@@ -4771,6 +4892,8 @@ snapshots:
elkjs@0.11.1: {} elkjs@0.11.1: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {} emoji-regex@9.2.2: {}
empathic@2.0.0: {} empathic@2.0.0: {}
@@ -5189,6 +5312,11 @@ snapshots:
fast-querystring: 1.1.2 fast-querystring: 1.1.2
safe-regex2: 5.1.1 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: find-up@5.0.0:
dependencies: dependencies:
locate-path: 6.0.0 locate-path: 6.0.0
@@ -5240,6 +5368,8 @@ snapshots:
gensync@1.0.0-beta.2: {} gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
get-intrinsic@1.3.0: get-intrinsic@1.3.0:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@@ -5402,6 +5532,8 @@ snapshots:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
is-fullwidth-code-point@3.0.0: {}
is-generator-function@1.1.2: is-generator-function@1.1.2:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
@@ -5623,6 +5755,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-arm64-msvc: 1.33.0
lightningcss-win32-x64-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: locate-path@6.0.0:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
@@ -5789,14 +5925,24 @@ snapshots:
object-keys: 1.1.1 object-keys: 1.1.1
safe-push-apply: 1.0.0 safe-push-apply: 1.0.0
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-limit@3.1.0: p-limit@3.1.0:
dependencies: dependencies:
yocto-queue: 0.1.0 yocto-queue: 0.1.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-locate@5.0.0: p-locate@5.0.0:
dependencies: dependencies:
p-limit: 3.1.0 p-limit: 3.1.0
p-try@2.2.0: {}
parent-module@1.0.1: parent-module@1.0.1:
dependencies: dependencies:
callsites: 3.1.0 callsites: 3.1.0
@@ -5866,6 +6012,8 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.2 fsevents: 2.3.2
pngjs@5.0.0: {}
possible-typed-array-names@1.1.0: {} possible-typed-array-names@1.1.0: {}
postcss@8.5.23: postcss@8.5.23:
@@ -5929,6 +6077,12 @@ snapshots:
pure-rand@6.1.0: {} 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: {} queue-microtask@1.2.3: {}
rc9@3.0.1: rc9@3.0.1:
@@ -5969,8 +6123,12 @@ snapshots:
remeda@2.33.4: {} remeda@2.33.4: {}
require-directory@2.1.1: {}
require-from-string@2.0.2: {} require-from-string@2.0.2: {}
require-main-filename@2.0.0: {}
resolve-from@4.0.0: {} resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {} resolve-pkg-maps@1.0.0: {}
@@ -6049,6 +6207,8 @@ snapshots:
seq-queue@0.0.5: {} seq-queue@0.0.5: {}
set-blocking@2.0.0: {}
set-function-length@1.2.2: set-function-length@1.2.2:
dependencies: dependencies:
define-data-property: 1.1.4 define-data-property: 1.1.4
@@ -6164,6 +6324,12 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
internal-slot: 1.1.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: string.prototype.includes@2.0.1:
dependencies: dependencies:
call-bind: 1.0.9 call-bind: 1.0.9
@@ -6215,6 +6381,10 @@ snapshots:
define-properties: 1.2.1 define-properties: 1.2.1
es-object-atoms: 1.1.2 es-object-atoms: 1.1.2
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
strip-bom@3.0.0: {} strip-bom@3.0.0: {}
strip-json-comments@3.1.1: {} strip-json-comments@3.1.1: {}
@@ -6442,6 +6612,8 @@ snapshots:
is-weakmap: 2.0.2 is-weakmap: 2.0.2
is-weakset: 2.0.4 is-weakset: 2.0.4
which-module@2.0.1: {}
which-typed-array@1.1.22: which-typed-array@1.1.22:
dependencies: dependencies:
available-typed-arrays: 1.0.7 available-typed-arrays: 1.0.7
@@ -6463,10 +6635,37 @@ snapshots:
word-wrap@1.2.5: {} 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: {} xtend@4.0.2: {}
y18n@4.0.3: {}
yallist@3.1.1: {} 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: {} yocto-queue@0.1.0: {}
zeptomatch@2.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;
+23
View File
@@ -110,6 +110,10 @@ model User {
/// Deuxième facteur, exigé des rôles administrateur et RH (matrice n° 15). /// Deuxième facteur, exigé des rôles administrateur et RH (matrice n° 15).
mfaSecretEnc Bytes? mfaSecretEnc Bytes?
mfaEnrolledAt DateTime? 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? lastSignInAt DateTime?
failedAttempts Int @default(0) failedAttempts Int @default(0)
lockedUntil DateTime? lockedUntil DateTime?
@@ -117,6 +121,22 @@ model User {
memberships Membership[] memberships Membership[]
sessions Session[] 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 /// Lien User ↔ Account. Porte le salarié : `userId` est nullable, car tous les
@@ -190,6 +210,9 @@ model Session {
userAgent String? userAgent String?
revokedAt DateTime? revokedAt DateTime?
revokedBy String? 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) 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 { redirect } from 'next/navigation';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { MfaSettings } from '@/components/settings/MfaSettings';
import { AppShell } from '@/components/shell/AppShell'; 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'; import { currentSession } from '@/server/auth/session';
/** /**
@@ -16,6 +20,17 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
const session = await currentSession(); const session = await currentSession();
if (!session) redirect('/connexion'); 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 ( return (
<AppShell <AppShell
initials={session.user.initials} initials={session.user.initials}
@@ -23,7 +38,22 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
roleName={session.roleName} roleName={session.roleName}
accountName={session.accountName} accountName={session.accountName}
> >
{children} {enrolmentDue ? <EnrolmentGate /> : children}
</AppShell> </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: 'sites', label: 'Établissements', href: '/reglages/etablissements' },
{ id: 'registre', label: 'Registre de paramétrage', href: '/reglages/registre' }, { id: 'registre', label: 'Registre de paramétrage', href: '/reglages/registre' },
{ id: 'email', label: 'Envoi de courrier', href: '/reglages/email' }, { id: 'email', label: 'Envoi de courrier', href: '/reglages/email' },
{ id: 'securite', label: 'Sécurité', href: '/reglages/securite' },
{ id: 'convention', label: 'Convention collective' }, { id: 'convention', label: 'Convention collective' },
{ id: 'postes', label: 'Postes et étiquettes' }, { id: 'postes', label: 'Postes et étiquettes' },
{ id: 'roles', label: 'Rôles et permissions' }, { 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 { redirect } from 'next/navigation';
import { z } from 'zod'; import { z } from 'zod';
import { answerChallenge } from '@/server/auth/mfa';
import { import {
pendingChallenge,
satisfyMfa,
SESSION_COOKIE, SESSION_COOKIE,
SESSION_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS,
signIn, signIn,
@@ -57,7 +60,44 @@ export async function signInAction(
expires: result.expiresAt, 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> { 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; 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 = 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' }; | { ok: false; reason: 'invalid' | 'locked' };
export async function signIn(input: SignInInput): Promise<SignInResult> { export async function signIn(input: SignInInput): Promise<SignInResult> {
@@ -94,7 +110,13 @@ export async function signIn(input: SignInInput): Promise<SignInResult> {
} }
const token = generateToken(); 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([ await db.$transaction([
db.session.create({ db.session.create({
@@ -104,6 +126,7 @@ export async function signIn(input: SignInInput): Promise<SignInResult> {
expiresAt, expiresAt,
ip: input.ip ?? null, ip: input.ip ?? null,
userAgent: input.userAgent ?? null, userAgent: input.userAgent ?? null,
mfaSatisfied: !mfaPending,
}, },
}), }),
db.user.update({ db.user.update({
@@ -111,12 +134,70 @@ export async function signIn(input: SignInInput): Promise<SignInResult> {
data: { data: {
failedAttempts: 0, failedAttempts: 0,
lockedUntil: null, 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> { export async function signOut(token: string): Promise<void> {
@@ -143,6 +224,8 @@ export interface SessionUser {
lastName: string; lastName: string;
email: string; email: string;
initials: string; initials: string;
/** Un second facteur est enregistré pour ce compte. */
mfaEnrolled: boolean;
} }
export interface SessionContext { 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; return null;
} }
@@ -220,6 +311,7 @@ export async function resolveSession(
lastName, lastName,
email, email,
initials: `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(), initials: `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(),
mfaEnrolled: session.user.mfaEnrolledAt !== null,
}, },
accountName: membership.account.name, accountName: membership.account.name,
roleName: membership.role.name, roleName: membership.role.name,
+33 -2
View File
@@ -1,19 +1,50 @@
import { test as setup, expect } from '@playwright/test'; import { test as setup, expect } from '@playwright/test';
import { rememberSecret } from './mfa';
import { STORAGE_STATE } from './storage'; 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. * 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 — * Chaque test se connecterait sinon, ce qui coûterait un argon2 par test —
* volontairement lent — et ferait grimper le compteur d'échecs partagé. * 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 }) => { setup('authentifie la direction', async ({ page }) => {
await resetMfa(EMAIL);
await page.goto('/connexion'); await page.goto('/connexion');
await page.getByLabel('Adresse électronique').fill('direction@example.test'); await page.getByLabel('Adresse électronique').fill(EMAIL);
await page.getByLabel('Mot de passe').fill('planflow-demo-2026'); await page.getByLabel('Mot de passe').fill(PASSWORD);
await page.getByRole('button', { name: 'Se connecter' }).click(); 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 expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
await page.context().storageState({ path: STORAGE_STATE }); await page.context().storageState({ path: STORAGE_STATE });
}); });
+13
View File
@@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { answerChallenge, rememberedSecret, waitForFreshCode } from './mfa';
const EMAIL = 'direction@example.test'; const EMAIL = 'direction@example.test';
const PASSWORD = 'planflow-demo-2026'; 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 }) => { 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.goto('/connexion');
await page.getByLabel('Adresse électronique').fill(EMAIL); await page.getByLabel('Adresse électronique').fill(EMAIL);
await page.getByLabel('Mot de passe').fill(PASSWORD); await page.getByLabel('Mot de passe').fill(PASSWORD);
await page.getByRole('button', { name: 'Se connecter' }).click(); 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(); await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
// L'identité affichée vient de la base, pas d'un libellé en dur. // L'identité affichée vient de la base, pas d'un libellé en dur.
await expect(page.getByTitle(/Camille Ferrand/)).toBeVisible(); 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 * 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 ». * 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) { async function signIn(page: import('@playwright/test').Page, email: string) {
await page.goto('/connexion'); 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(); 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 ({ test('un manager ne peut ni voir ni modifier les établissements', async ({
page, page,
}) => { }) => {
@@ -58,39 +33,3 @@ test('un manager ne peut ni voir ni modifier les établissements', async ({
page.getByRole('heading', { name: 'Établissements' }), page.getByRole('heading', { name: 'Établissements' }),
).toBeHidden(); ).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,
);
});
});