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:
27 files changed
+1997
-79
No files matched your search
@@ -76,7 +76,19 @@ pnpm dev
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
Elle vit **hors de la base** : une sauvegarde volée ne doit pas suffire à lire ces colonnes. La perdre rend ces données irrécupérables — la sauvegarder séparément et documenter sa rotation.
|
||||
Elle vit **hors de la base** : une sauvegarde volée ne doit pas suffire à lire ces colonnes. La perdre rend ces données irrécupérables — la sauvegarder séparément et documenter sa rotation. Elle chiffre également les secrets de second facteur et le mot de passe du serveur d'envoi.
|
||||
|
||||
### Second facteur — accès de secours
|
||||
|
||||
Les rôles qui lisent les rémunérations ou distribuent les droits doivent porter un second facteur (matrice n° 15) : tant qu'il n'est pas activé, l'application ne leur ouvre aucun écran. Chaque activation délivre dix codes de secours, affichés **une seule fois**.
|
||||
|
||||
PlanFlow étant auto-hébergé, il n'y a pas d'éditeur à appeler si un administrateur perd à la fois son téléphone et ses codes. Le retrait se fait alors depuis le serveur :
|
||||
|
||||
```bash
|
||||
pnpm mfa:reset adresse@example.fr
|
||||
```
|
||||
|
||||
Le retrait révoque les sessions ouvertes et s'inscrit au journal d'audit. Il n'est délibérément pas exposé dans l'application : l'exécuter demande déjà un accès au serveur, c'est-à-dire davantage que ce que le second facteur protège.
|
||||
|
||||
## Vérifier
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"db:deploy": "prisma migrate deploy",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:studio": "prisma studio",
|
||||
"mfa:reset": "tsx scripts/mfa-reset.ts",
|
||||
"verify": "pnpm typecheck && pnpm lint && pnpm test"
|
||||
},
|
||||
"prisma": {
|
||||
@@ -33,6 +34,7 @@
|
||||
"next": "16.3.0",
|
||||
"nodemailer": "^9.0.5",
|
||||
"pg": "8.22.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"zod": "4.4.3"
|
||||
@@ -43,6 +45,7 @@
|
||||
"@types/node": "22.19.4",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/pg": "8.20.4",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"dotenv": "^17.4.2",
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// La mise en place lit la base directement pour rendre la suite rejouable ;
|
||||
// sans cela DATABASE_URL n'existe que pour le serveur, pas pour les tests.
|
||||
import 'dotenv/config';
|
||||
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
import { STORAGE_STATE } from './tests/e2e/storage';
|
||||
@@ -38,9 +42,12 @@ export default defineConfig({
|
||||
use: { ...devices['Desktop Chrome'], ...chromiumOverride },
|
||||
},
|
||||
// Parcours d'authentification : doit partir d'un navigateur vierge.
|
||||
// Dépend tout de même de la mise en place, qui pose le second facteur du
|
||||
// compte de direction — sans lequel la connexion ne peut pas être jouée.
|
||||
{
|
||||
name: 'anonyme',
|
||||
testMatch: /(auth|reglages|acces)\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
use: { ...devices['Desktop Chrome'], ...chromiumOverride },
|
||||
},
|
||||
{
|
||||
|
||||
Generated
+199
@@ -26,6 +26,9 @@ importers:
|
||||
pg:
|
||||
specifier: 8.22.0
|
||||
version: 8.22.0
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
react:
|
||||
specifier: 19.2.8
|
||||
version: 19.2.8
|
||||
@@ -51,6 +54,9 @@ importers:
|
||||
'@types/pg':
|
||||
specifier: 8.20.4
|
||||
version: 8.20.4
|
||||
'@types/qrcode':
|
||||
specifier: ^1.5.6
|
||||
version: 1.5.6
|
||||
'@types/react':
|
||||
specifier: 19.2.18
|
||||
version: 19.2.18
|
||||
@@ -1165,6 +1171,9 @@ packages:
|
||||
'@types/pg@8.20.4':
|
||||
resolution: {integrity: sha512-Jz7UDOlIiFJuacC0TlBoLyNtmwlA/wpIyPDd3tvUqlRM+HzkWy2xUgpFpaXtbfTAFF6sIGq5lsCDBdJnhky1Xg==}
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||
|
||||
'@types/react-dom@19.2.4':
|
||||
resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==}
|
||||
peerDependencies:
|
||||
@@ -1432,6 +1441,10 @@ packages:
|
||||
ajv@8.20.0:
|
||||
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
|
||||
|
||||
ansi-regex@5.0.1:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1557,6 +1570,10 @@ packages:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
camelcase@5.3.1:
|
||||
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
caniuse-lite@1.0.30001809:
|
||||
resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==}
|
||||
|
||||
@@ -1578,6 +1595,9 @@ packages:
|
||||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
cliui@6.0.0:
|
||||
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -1681,6 +1701,10 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decamelize@1.2.0:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
@@ -1713,6 +1737,9 @@ packages:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
dijkstrajs@1.0.3:
|
||||
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||
|
||||
doctrine@2.1.0:
|
||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1734,6 +1761,9 @@ packages:
|
||||
elkjs@0.11.1:
|
||||
resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==}
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
emoji-regex@9.2.2:
|
||||
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||
|
||||
@@ -1981,6 +2011,10 @@ packages:
|
||||
resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
find-up@4.1.0:
|
||||
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
find-up@5.0.0:
|
||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2031,6 +2065,10 @@ packages:
|
||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
get-caller-file@2.0.5:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2194,6 +2232,10 @@ packages:
|
||||
resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-fullwidth-code-point@3.0.0:
|
||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-generator-function@1.1.2:
|
||||
resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2470,6 +2512,10 @@ packages:
|
||||
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
locate-path@5.0.0:
|
||||
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
locate-path@6.0.0:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2623,14 +2669,26 @@ packages:
|
||||
resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
p-limit@2.3.0:
|
||||
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
p-limit@3.1.0:
|
||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-locate@4.1.0:
|
||||
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-locate@5.0.0:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-try@2.2.0:
|
||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2710,6 +2768,10 @@ packages:
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
pngjs@5.0.0:
|
||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
possible-typed-array-names@1.1.0:
|
||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2776,6 +2838,11 @@ packages:
|
||||
pure-rand@6.1.0:
|
||||
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
|
||||
|
||||
qrcode@1.5.4:
|
||||
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
@@ -2809,10 +2876,17 @@ packages:
|
||||
remeda@2.33.4:
|
||||
resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-main-filename@2.0.0:
|
||||
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -2882,6 +2956,9 @@ packages:
|
||||
seq-queue@0.0.5:
|
||||
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
|
||||
|
||||
set-blocking@2.0.0:
|
||||
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2965,6 +3042,10 @@ packages:
|
||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
string.prototype.includes@2.0.1:
|
||||
resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2988,6 +3069,10 @@ packages:
|
||||
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-bom@3.0.0:
|
||||
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -3215,6 +3300,9 @@ packages:
|
||||
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
which-module@2.0.1:
|
||||
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||
|
||||
which-typed-array@1.1.22:
|
||||
resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3233,13 +3321,28 @@ packages:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
xtend@4.0.2:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
|
||||
y18n@4.0.3:
|
||||
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
yargs@15.4.1:
|
||||
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
yocto-queue@0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -4150,6 +4253,10 @@ snapshots:
|
||||
pg-protocol: 1.15.0
|
||||
pg-types: 2.2.0
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
dependencies:
|
||||
'@types/node': 22.19.4
|
||||
|
||||
'@types/react-dom@19.2.4(@types/react@19.2.18)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.18
|
||||
@@ -4457,6 +4564,8 @@ snapshots:
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
@@ -4611,6 +4720,8 @@ snapshots:
|
||||
|
||||
callsites@3.1.0: {}
|
||||
|
||||
camelcase@5.3.1: {}
|
||||
|
||||
caniuse-lite@1.0.30001809: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
@@ -4628,6 +4739,12 @@ snapshots:
|
||||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
cliui@6.0.0:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 6.2.0
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@@ -4722,6 +4839,8 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
deepmerge-ts@7.1.5: {}
|
||||
@@ -4750,6 +4869,8 @@ snapshots:
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
dijkstrajs@1.0.3: {}
|
||||
|
||||
doctrine@2.1.0:
|
||||
dependencies:
|
||||
esutils: 2.0.3
|
||||
@@ -4771,6 +4892,8 @@ snapshots:
|
||||
|
||||
elkjs@0.11.1: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
emoji-regex@9.2.2: {}
|
||||
|
||||
empathic@2.0.0: {}
|
||||
@@ -5189,6 +5312,11 @@ snapshots:
|
||||
fast-querystring: 1.1.2
|
||||
safe-regex2: 5.1.1
|
||||
|
||||
find-up@4.1.0:
|
||||
dependencies:
|
||||
locate-path: 5.0.0
|
||||
path-exists: 4.0.0
|
||||
|
||||
find-up@5.0.0:
|
||||
dependencies:
|
||||
locate-path: 6.0.0
|
||||
@@ -5240,6 +5368,8 @@ snapshots:
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
@@ -5402,6 +5532,8 @@ snapshots:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
|
||||
is-generator-function@1.1.2:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -5623,6 +5755,10 @@ snapshots:
|
||||
lightningcss-win32-arm64-msvc: 1.33.0
|
||||
lightningcss-win32-x64-msvc: 1.33.0
|
||||
|
||||
locate-path@5.0.0:
|
||||
dependencies:
|
||||
p-locate: 4.1.0
|
||||
|
||||
locate-path@6.0.0:
|
||||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
@@ -5789,14 +5925,24 @@ snapshots:
|
||||
object-keys: 1.1.1
|
||||
safe-push-apply: 1.0.0
|
||||
|
||||
p-limit@2.3.0:
|
||||
dependencies:
|
||||
p-try: 2.2.0
|
||||
|
||||
p-limit@3.1.0:
|
||||
dependencies:
|
||||
yocto-queue: 0.1.0
|
||||
|
||||
p-locate@4.1.0:
|
||||
dependencies:
|
||||
p-limit: 2.3.0
|
||||
|
||||
p-locate@5.0.0:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
p-try@2.2.0: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
@@ -5866,6 +6012,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
pngjs@5.0.0: {}
|
||||
|
||||
possible-typed-array-names@1.1.0: {}
|
||||
|
||||
postcss@8.5.23:
|
||||
@@ -5929,6 +6077,12 @@ snapshots:
|
||||
|
||||
pure-rand@6.1.0: {}
|
||||
|
||||
qrcode@1.5.4:
|
||||
dependencies:
|
||||
dijkstrajs: 1.0.3
|
||||
pngjs: 5.0.0
|
||||
yargs: 15.4.1
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
rc9@3.0.1:
|
||||
@@ -5969,8 +6123,12 @@ snapshots:
|
||||
|
||||
remeda@2.33.4: {}
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
require-main-filename@2.0.0: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
@@ -6049,6 +6207,8 @@ snapshots:
|
||||
|
||||
seq-queue@0.0.5: {}
|
||||
|
||||
set-blocking@2.0.0: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
@@ -6164,6 +6324,12 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
internal-slot: 1.1.0
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
is-fullwidth-code-point: 3.0.0
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
string.prototype.includes@2.0.1:
|
||||
dependencies:
|
||||
call-bind: 1.0.9
|
||||
@@ -6215,6 +6381,10 @@ snapshots:
|
||||
define-properties: 1.2.1
|
||||
es-object-atoms: 1.1.2
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
strip-bom@3.0.0: {}
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
@@ -6442,6 +6612,8 @@ snapshots:
|
||||
is-weakmap: 2.0.2
|
||||
is-weakset: 2.0.4
|
||||
|
||||
which-module@2.0.1: {}
|
||||
|
||||
which-typed-array@1.1.22:
|
||||
dependencies:
|
||||
available-typed-arrays: 1.0.7
|
||||
@@ -6463,10 +6635,37 @@ snapshots:
|
||||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
dependencies:
|
||||
camelcase: 5.3.1
|
||||
decamelize: 1.2.0
|
||||
|
||||
yargs@15.4.1:
|
||||
dependencies:
|
||||
cliui: 6.0.0
|
||||
decamelize: 1.2.0
|
||||
find-up: 4.1.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
require-main-filename: 2.0.0
|
||||
set-blocking: 2.0.0
|
||||
string-width: 4.2.3
|
||||
which-module: 2.0.1
|
||||
y18n: 4.0.3
|
||||
yargs-parser: 18.1.3
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zeptomatch@2.1.0:
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Session" ADD COLUMN "mfaSatisfied" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "mfaLastStep" INTEGER;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MfaRecoveryCode" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"codeHash" TEXT NOT NULL,
|
||||
"usedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "MfaRecoveryCode_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MfaRecoveryCode_codeHash_key" ON "MfaRecoveryCode"("codeHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MfaRecoveryCode_userId_idx" ON "MfaRecoveryCode"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MfaRecoveryCode" ADD CONSTRAINT "MfaRecoveryCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+25
-2
@@ -110,13 +110,33 @@ model User {
|
||||
/// Deuxième facteur, exigé des rôles administrateur et RH (matrice n° 15).
|
||||
mfaSecretEnc Bytes?
|
||||
mfaEnrolledAt DateTime?
|
||||
/// Dernier pas TOTP employé. Interdit le rejeu d'un code dans sa fenêtre :
|
||||
/// sans lui, le facteur protège du mot de passe volé, pas du code lu
|
||||
/// par-dessus l'épaule.
|
||||
mfaLastStep Int?
|
||||
lastSignInAt DateTime?
|
||||
failedAttempts Int @default(0)
|
||||
lockedUntil DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
memberships Membership[]
|
||||
sessions Session[]
|
||||
memberships Membership[]
|
||||
sessions Session[]
|
||||
recoveryCodes MfaRecoveryCode[]
|
||||
}
|
||||
|
||||
/// Code de secours à usage unique. Perdre son téléphone ne doit pas fermer
|
||||
/// définitivement l'accès — et la parade ne doit pas être de désactiver le
|
||||
/// second facteur par un simple courriel au support.
|
||||
model MfaRecoveryCode {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
codeHash String @unique
|
||||
usedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
/// Lien User ↔ Account. Porte le salarié : `userId` est nullable, car tous les
|
||||
@@ -190,6 +210,9 @@ model Session {
|
||||
userAgent String?
|
||||
revokedAt DateTime?
|
||||
revokedBy String?
|
||||
/// Faux tant que le second facteur n'a pas été présenté. Une session en
|
||||
/// attente ne donne accès à rien : elle sert seulement à porter le défi.
|
||||
mfaSatisfied Boolean @default(true)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
||||
@@ -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());
|
||||
@@ -1,7 +1,11 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { MfaSettings } from '@/components/settings/MfaSettings';
|
||||
import { AppShell } from '@/components/shell/AppShell';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import { mfaRequired } from '@/domain/access/mfa-policy';
|
||||
import { currentSession } from '@/server/auth/session';
|
||||
|
||||
/**
|
||||
@@ -16,6 +20,17 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
const session = await currentSession();
|
||||
if (!session) redirect('/connexion');
|
||||
|
||||
// Un rôle qui lit les rémunérations ou distribue les droits doit porter un
|
||||
// second facteur (matrice n° 15). L'obligation est tenue ici, au point de
|
||||
// passage de toutes les routes applicatives.
|
||||
//
|
||||
// L'écran d'enrôlement **remplace** le contenu au lieu de rediriger vers
|
||||
// lui : une redirection depuis un layout se joue aussi pendant la navigation
|
||||
// qui suit la connexion, et Next y répond par une page vide. Substituer le
|
||||
// contenu tient la même garantie sans dépendre du chemin demandé.
|
||||
const enrolmentDue =
|
||||
mfaRequired(session.actor.permissions) && !session.user.mfaEnrolled;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
initials={session.user.initials}
|
||||
@@ -23,7 +38,22 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
roleName={session.roleName}
|
||||
accountName={session.accountName}
|
||||
>
|
||||
{children}
|
||||
{enrolmentDue ? <EnrolmentGate /> : children}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function EnrolmentGate() {
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Sécurité"
|
||||
subtitle="Votre rôle donne accès aux rémunérations ou à la distribution des droits : un second facteur est exigé avant d’aller plus loin."
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader title="Second facteur" />
|
||||
<MfaSettings enrolled={false} required />
|
||||
</Card>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,7 @@ export const NAVIGATION: NavSection[] = [
|
||||
{ id: 'sites', label: 'Établissements', href: '/reglages/etablissements' },
|
||||
{ id: 'registre', label: 'Registre de paramétrage', href: '/reglages/registre' },
|
||||
{ id: 'email', label: 'Envoi de courrier', href: '/reglages/email' },
|
||||
{ id: 'securite', label: 'Sécurité', href: '/reglages/securite' },
|
||||
{ id: 'convention', label: 'Convention collective' },
|
||||
{ id: 'postes', label: 'Postes et étiquettes' },
|
||||
{ id: 'roles', label: 'Rôles et permissions' },
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -4,7 +4,10 @@ import { cookies, headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { answerChallenge } from '@/server/auth/mfa';
|
||||
import {
|
||||
pendingChallenge,
|
||||
satisfyMfa,
|
||||
SESSION_COOKIE,
|
||||
SESSION_COOKIE_OPTIONS,
|
||||
signIn,
|
||||
@@ -57,7 +60,44 @@ export async function signInAction(
|
||||
expires: result.expiresAt,
|
||||
});
|
||||
|
||||
redirect('/');
|
||||
// La session existe mais ne résout aucun acteur tant que le second facteur
|
||||
// n'est pas présenté : rediriger vers l'application produirait une boucle.
|
||||
redirect(result.mfaPending ? '/connexion/verification' : '/');
|
||||
}
|
||||
|
||||
export interface ChallengeState {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Éprouve le second facteur.
|
||||
*
|
||||
* L'échec ne détruit pas la session en attente : elle expire d'elle-même en dix
|
||||
* minutes, et la supprimer au premier code mal recopié renverrait ressaisir le
|
||||
* mot de passe sans motif.
|
||||
*/
|
||||
export async function verifyMfaAction(
|
||||
_previous: ChallengeState,
|
||||
formData: FormData,
|
||||
): Promise<ChallengeState> {
|
||||
const store = await cookies();
|
||||
const token = store.get(SESSION_COOKIE)?.value;
|
||||
if (!token) return { error: 'Session expirée. Reconnectez-vous.' };
|
||||
|
||||
const challenge = await pendingChallenge(token);
|
||||
if (!challenge) return { error: 'Session expirée. Reconnectez-vous.' };
|
||||
|
||||
const code = String(formData.get('code') ?? '');
|
||||
const result = await answerChallenge(challenge.userId, code);
|
||||
if (!result.ok) return { error: result.error };
|
||||
|
||||
const expiresAt = await satisfyMfa(token);
|
||||
store.set(SESSION_COOKIE, token, {
|
||||
...SESSION_COOKIE_OPTIONS,
|
||||
expires: expiresAt,
|
||||
});
|
||||
|
||||
redirect(result.usedRecoveryCode ? '/reglages/securite' : '/');
|
||||
}
|
||||
|
||||
export async function signOutAction(): Promise<void> {
|
||||
|
||||
@@ -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é.' };
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -53,8 +53,24 @@ export interface SignInInput {
|
||||
userAgent?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Durée d'une session en attente du second facteur.
|
||||
*
|
||||
* Elle ne donne accès à rien, mais elle atteste d'un mot de passe juste : lui
|
||||
* laisser douze heures offrirait autant de temps pour éprouver les six chiffres
|
||||
* depuis un poste laissé ouvert.
|
||||
*/
|
||||
const MFA_CHALLENGE_MS = 10 * 60 * 1000;
|
||||
|
||||
export type SignInResult =
|
||||
| { ok: true; token: string; expiresAt: Date; userId: string }
|
||||
| {
|
||||
ok: true;
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
userId: string;
|
||||
/** Vrai quand la session attend encore le second facteur. */
|
||||
mfaPending: boolean;
|
||||
}
|
||||
| { ok: false; reason: 'invalid' | 'locked' };
|
||||
|
||||
export async function signIn(input: SignInInput): Promise<SignInResult> {
|
||||
@@ -94,7 +110,13 @@ export async function signIn(input: SignInInput): Promise<SignInResult> {
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
|
||||
// Le second facteur est exigé dès qu'il est enregistré, quel que soit le
|
||||
// rôle : un salarié qui a pris la peine de l'activer ne doit pas pouvoir
|
||||
// entrer sans lui.
|
||||
const mfaPending = user.mfaEnrolledAt !== null && user.mfaSecretEnc !== null;
|
||||
const expiresAt = new Date(
|
||||
Date.now() + (mfaPending ? MFA_CHALLENGE_MS : SESSION_DURATION_MS),
|
||||
);
|
||||
|
||||
await db.$transaction([
|
||||
db.session.create({
|
||||
@@ -104,6 +126,7 @@ export async function signIn(input: SignInInput): Promise<SignInResult> {
|
||||
expiresAt,
|
||||
ip: input.ip ?? null,
|
||||
userAgent: input.userAgent ?? null,
|
||||
mfaSatisfied: !mfaPending,
|
||||
},
|
||||
}),
|
||||
db.user.update({
|
||||
@@ -111,12 +134,70 @@ export async function signIn(input: SignInInput): Promise<SignInResult> {
|
||||
data: {
|
||||
failedAttempts: 0,
|
||||
lockedUntil: null,
|
||||
lastSignInAt: new Date(),
|
||||
// La date de dernière connexion n'est posée qu'une fois les deux
|
||||
// facteurs présentés : un mot de passe juste seul n'est pas une
|
||||
// connexion.
|
||||
...(mfaPending ? {} : { lastSignInAt: new Date() }),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { ok: true, token, expiresAt, userId: user.id };
|
||||
return { ok: true, token, expiresAt, userId: user.id, mfaPending };
|
||||
}
|
||||
|
||||
export interface PendingChallenge {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session ouverte mais en attente du second facteur.
|
||||
*
|
||||
* Distincte de `resolveSession`, qui refuse ces sessions : l'écran de défi est
|
||||
* le seul endroit où elles ont un sens, et les confondre reviendrait à laisser
|
||||
* une session à demi ouverte circuler dans l'application.
|
||||
*/
|
||||
export async function pendingChallenge(
|
||||
token: string,
|
||||
): Promise<PendingChallenge | null> {
|
||||
const session = await unscoped().session.findUnique({
|
||||
where: { tokenHash: hashToken(token) },
|
||||
include: { user: { select: { id: true, email: true } } },
|
||||
});
|
||||
|
||||
if (
|
||||
!session ||
|
||||
session.mfaSatisfied ||
|
||||
session.revokedAt ||
|
||||
session.expiresAt < new Date()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
userId: session.user.id,
|
||||
email: session.user.email,
|
||||
};
|
||||
}
|
||||
|
||||
/** Promeut une session en attente en session pleine. */
|
||||
export async function satisfyMfa(token: string): Promise<Date> {
|
||||
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
|
||||
const db = unscoped();
|
||||
|
||||
const session = await db.session.update({
|
||||
where: { tokenHash: hashToken(token) },
|
||||
data: { mfaSatisfied: true, expiresAt },
|
||||
});
|
||||
|
||||
await db.user.update({
|
||||
where: { id: session.userId },
|
||||
data: { lastSignInAt: new Date() },
|
||||
});
|
||||
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
export async function signOut(token: string): Promise<void> {
|
||||
@@ -143,6 +224,8 @@ export interface SessionUser {
|
||||
lastName: string;
|
||||
email: string;
|
||||
initials: string;
|
||||
/** Un second facteur est enregistré pour ce compte. */
|
||||
mfaEnrolled: boolean;
|
||||
}
|
||||
|
||||
export interface SessionContext {
|
||||
@@ -182,7 +265,15 @@ export async function resolveSession(
|
||||
},
|
||||
});
|
||||
|
||||
if (!session || session.revokedAt || session.expiresAt < new Date()) {
|
||||
// Une session en attente du second facteur ne résout aucun acteur : elle ne
|
||||
// porte qu'un défi. C'est ici, au point de passage unique, que la garantie
|
||||
// tient — pas dans chaque écran.
|
||||
if (
|
||||
!session ||
|
||||
!session.mfaSatisfied ||
|
||||
session.revokedAt ||
|
||||
session.expiresAt < new Date()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -220,6 +311,7 @@ export async function resolveSession(
|
||||
lastName,
|
||||
email,
|
||||
initials: `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(),
|
||||
mfaEnrolled: session.user.mfaEnrolledAt !== null,
|
||||
},
|
||||
accountName: membership.account.name,
|
||||
roleName: membership.role.name,
|
||||
|
||||
+33
-2
@@ -1,19 +1,50 @@
|
||||
import { test as setup, expect } from '@playwright/test';
|
||||
|
||||
import { rememberSecret } from './mfa';
|
||||
import { STORAGE_STATE } from './storage';
|
||||
import { resetMfa } from './support/db';
|
||||
import { totpCode, totpStep } from '../../src/domain/access/totp';
|
||||
|
||||
/**
|
||||
* Ouvre une session une fois et enregistre le cookie pour les autres tests.
|
||||
*
|
||||
* Chaque test se connecterait sinon, ce qui coûterait un argon2 par test —
|
||||
* volontairement lent — et ferait grimper le compteur d'échecs partagé.
|
||||
*
|
||||
* Le rôle de la direction exige un second facteur (matrice n° 15) : la mise en
|
||||
* place l'enrôle donc réellement. Le retrait préalable rend l'opération
|
||||
* rejouable — sans lui, un deuxième passage se heurterait au facteur posé par
|
||||
* le premier, dont le secret a disparu avec lui.
|
||||
*/
|
||||
const EMAIL = 'direction@example.test';
|
||||
const PASSWORD = 'planflow-demo-2026';
|
||||
|
||||
setup('authentifie la direction', async ({ page }) => {
|
||||
await resetMfa(EMAIL);
|
||||
|
||||
await page.goto('/connexion');
|
||||
await page.getByLabel('Adresse électronique').fill('direction@example.test');
|
||||
await page.getByLabel('Mot de passe').fill('planflow-demo-2026');
|
||||
await page.getByLabel('Adresse électronique').fill(EMAIL);
|
||||
await page.getByLabel('Mot de passe').fill(PASSWORD);
|
||||
await page.getByRole('button', { name: 'Se connecter' }).click();
|
||||
|
||||
// Le rôle exige le second facteur : l'application substitue l'enrôlement au
|
||||
// contenu tant qu'il n'est pas posé.
|
||||
await page.getByRole('button', { name: 'Activer le second facteur' }).click();
|
||||
|
||||
const shown = await page.locator('p.font-mono').first().textContent();
|
||||
const secret = (shown ?? '').replace(/\s/g, '');
|
||||
expect(secret.length).toBeGreaterThan(16);
|
||||
|
||||
const usedStep = totpStep(Date.now());
|
||||
await page
|
||||
.getByRole('textbox', { name: 'Recopiez le code affiché' })
|
||||
.fill(totpCode(secret, Date.now()));
|
||||
await page.getByRole('button', { name: 'Valider' }).click();
|
||||
await expect(page.getByTestId('recovery-codes')).toBeVisible();
|
||||
|
||||
rememberSecret(secret, usedStep);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
|
||||
await page.context().storageState({ path: STORAGE_STATE });
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { answerChallenge, rememberedSecret, waitForFreshCode } from './mfa';
|
||||
|
||||
const EMAIL = 'direction@example.test';
|
||||
const PASSWORD = 'planflow-demo-2026';
|
||||
|
||||
@@ -34,11 +36,22 @@ test('un mot de passe faux ne dit pas si le compte existe', async ({ page }) =>
|
||||
});
|
||||
|
||||
test('connexion, navigation, puis déconnexion', async ({ page }) => {
|
||||
const remembered = rememberedSecret();
|
||||
// Un code ne sert qu'une fois : attendre le pas suivant celui qu'a employé la
|
||||
// mise en place, plutôt que de se heurter au refus de rejeu. L'attente est
|
||||
// nulle dès que trente secondes se sont écoulées entre-temps.
|
||||
await waitForFreshCode(remembered.usedStep);
|
||||
|
||||
await page.goto('/connexion');
|
||||
await page.getByLabel('Adresse électronique').fill(EMAIL);
|
||||
await page.getByLabel('Mot de passe').fill(PASSWORD);
|
||||
await page.getByRole('button', { name: 'Se connecter' }).click();
|
||||
|
||||
// Le rôle de la direction exige un second facteur : le mot de passe seul
|
||||
// n'ouvre rien.
|
||||
await expect(page.getByRole('heading', { name: 'Vérification' })).toBeVisible();
|
||||
await answerChallenge(page, remembered.secret);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
|
||||
// L'identité affichée vient de la base, pas d'un libellé en dur.
|
||||
await expect(page.getByTitle(/Camille Ferrand/)).toBeVisible();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -3,6 +3,12 @@ import { expect, test } from '@playwright/test';
|
||||
/**
|
||||
* Ce test se connecte en manager : il ne peut donc pas réutiliser la session
|
||||
* partagée de la direction, d'où le projet « anonyme ».
|
||||
*
|
||||
* Les écrans qui n'éprouvent pas l'authentification ont été déplacés dans
|
||||
* `registre.spec.ts`, où ils réutilisent la session commune : la direction
|
||||
* porte désormais un second facteur, et deux connexions simultanées sur le même
|
||||
* compte se heurteraient au refus de rejeu d'un code — ce qui est le
|
||||
* comportement voulu, pas un défaut à contourner.
|
||||
*/
|
||||
async function signIn(page: import('@playwright/test').Page, email: string) {
|
||||
await page.goto('/connexion');
|
||||
@@ -12,37 +18,6 @@ async function signIn(page: import('@playwright/test').Page, email: string) {
|
||||
await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
|
||||
}
|
||||
|
||||
test('la direction lit et alimente le registre de paramétrage', async ({
|
||||
page,
|
||||
}) => {
|
||||
await signIn(page, 'direction@example.test');
|
||||
await page.goto('/reglages/registre');
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Registre de paramétrage juridique' }),
|
||||
).toBeVisible();
|
||||
|
||||
const parameter = `Durée quotidienne maximale ${Date.now()}`;
|
||||
// Ciblage par attribut `name` : les libellés portent un texte d'aide, et
|
||||
// celui de « Source » contient lui-même le mot « valeur », ce qui rend la
|
||||
// correspondance par libellé ambiguë.
|
||||
const form = page.locator('form').filter({ hasText: 'Consigner' });
|
||||
await form.locator('input[name="key"]').fill(parameter);
|
||||
await form.locator('input[name="value"]').fill('10 h');
|
||||
await form
|
||||
.locator('input[name="source"]')
|
||||
.fill('IDCC 1517 — texte consolidé Legifrance');
|
||||
await form.locator('input[name="population"]').fill('Tous les salariés');
|
||||
await page.getByRole('button', { name: 'Consigner' }).click();
|
||||
|
||||
const row = page.getByRole('row', { name: new RegExp(parameter) });
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
// Consigné n'est pas approuvé : la matrice exige un approbateur nommé.
|
||||
await row.getByRole('button', { name: 'Approuver' }).click();
|
||||
await expect(row.getByText(/Approuvé le/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('un manager ne peut ni voir ni modifier les établissements', async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -58,39 +33,3 @@ test('un manager ne peut ni voir ni modifier les établissements', async ({
|
||||
page.getByRole('heading', { name: 'Établissements' }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('un salarié sans compte applicatif est créable', async ({ page }) => {
|
||||
await signIn(page, 'direction@example.test');
|
||||
await page.goto('/equipe');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Équipe' })).toBeVisible();
|
||||
// L'effectif vient de la base, pas du module de démonstration.
|
||||
await expect(page.getByText('E0001')).toBeVisible();
|
||||
|
||||
// Nom unique par exécution : la base de test n'est pas remise à zéro entre
|
||||
// deux passages, et un nom fixe finirait par désigner plusieurs salariés.
|
||||
const matricule = `E9${Date.now() % 100000}`;
|
||||
const nom = `Sanscompte${matricule}`;
|
||||
const form = page.locator('form').filter({ hasText: 'Ajouter' });
|
||||
await form.locator('input[name="firstName"]').fill('Sans');
|
||||
await form.locator('input[name="lastName"]').fill(nom);
|
||||
await form.locator('input[name="employeeNumber"]').fill(matricule);
|
||||
await page.getByRole('button', { name: 'Ajouter' }).click();
|
||||
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
|
||||
|
||||
// Rechargement explicite : ce qui est vérifié ici est la persistance et la
|
||||
// présence dans l'annuaire, pas le moment exact où la revalidation atteint
|
||||
// le rendu courant.
|
||||
await page.reload();
|
||||
|
||||
// Un salarié sans adresse doit exister : la plupart des équipes de vente ne
|
||||
// se connectent jamais à l'outil.
|
||||
await expect(
|
||||
page.getByRole('cell', { name: matricule, exact: true }),
|
||||
).toBeVisible();
|
||||
// Le nom vit sur le dossier, pas sur le compte : un salarié sans accès
|
||||
// applicatif doit tout de même figurer nommément au registre du personnel.
|
||||
await expect(
|
||||
page.getByRole('link', { name: new RegExp(`Sans ${nom}`) }),
|
||||
).toBeVisible();
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user