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