diff --git a/README.md b/README.md index 46eec8a..fb21299 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,19 @@ pnpm dev openssl rand -base64 32 ``` -Elle vit **hors de la base** : une sauvegarde volée ne doit pas suffire à lire ces colonnes. La perdre rend ces données irrécupérables — la sauvegarder séparément et documenter sa rotation. +Elle vit **hors de la base** : une sauvegarde volée ne doit pas suffire à lire ces colonnes. La perdre rend ces données irrécupérables — la sauvegarder séparément et documenter sa rotation. Elle chiffre également les secrets de second facteur et le mot de passe du serveur d'envoi. + +### Second facteur — accès de secours + +Les rôles qui lisent les rémunérations ou distribuent les droits doivent porter un second facteur (matrice n° 15) : tant qu'il n'est pas activé, l'application ne leur ouvre aucun écran. Chaque activation délivre dix codes de secours, affichés **une seule fois**. + +PlanFlow étant auto-hébergé, il n'y a pas d'éditeur à appeler si un administrateur perd à la fois son téléphone et ses codes. Le retrait se fait alors depuis le serveur : + +```bash +pnpm mfa:reset adresse@example.fr +``` + +Le retrait révoque les sessions ouvertes et s'inscrit au journal d'audit. Il n'est délibérément pas exposé dans l'application : l'exécuter demande déjà un accès au serveur, c'est-à-dire davantage que ce que le second facteur protège. ## Vérifier diff --git a/package.json b/package.json index 24edbef..6c34425 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "db:deploy": "prisma migrate deploy", "db:seed": "tsx prisma/seed.ts", "db:studio": "prisma studio", + "mfa:reset": "tsx scripts/mfa-reset.ts", "verify": "pnpm typecheck && pnpm lint && pnpm test" }, "prisma": { @@ -33,6 +34,7 @@ "next": "16.3.0", "nodemailer": "^9.0.5", "pg": "8.22.0", + "qrcode": "^1.5.4", "react": "19.2.8", "react-dom": "19.2.8", "zod": "4.4.3" @@ -43,6 +45,7 @@ "@types/node": "22.19.4", "@types/nodemailer": "^8.0.1", "@types/pg": "8.20.4", + "@types/qrcode": "^1.5.6", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", "dotenv": "^17.4.2", diff --git a/playwright.config.ts b/playwright.config.ts index 482749c..da63908 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,3 +1,7 @@ +// La mise en place lit la base directement pour rendre la suite rejouable ; +// sans cela DATABASE_URL n'existe que pour le serveur, pas pour les tests. +import 'dotenv/config'; + import { defineConfig, devices } from '@playwright/test'; import { STORAGE_STATE } from './tests/e2e/storage'; @@ -38,9 +42,12 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'], ...chromiumOverride }, }, // Parcours d'authentification : doit partir d'un navigateur vierge. + // Dépend tout de même de la mise en place, qui pose le second facteur du + // compte de direction — sans lequel la connexion ne peut pas être jouée. { name: 'anonyme', testMatch: /(auth|reglages|acces)\.spec\.ts/, + dependencies: ['setup'], use: { ...devices['Desktop Chrome'], ...chromiumOverride }, }, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b32848..445f77b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: pg: specifier: 8.22.0 version: 8.22.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 react: specifier: 19.2.8 version: 19.2.8 @@ -51,6 +54,9 @@ importers: '@types/pg': specifier: 8.20.4 version: 8.20.4 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1165,6 +1171,9 @@ packages: '@types/pg@8.20.4': resolution: {integrity: sha512-Jz7UDOlIiFJuacC0TlBoLyNtmwlA/wpIyPDd3tvUqlRM+HzkWy2xUgpFpaXtbfTAFF6sIGq5lsCDBdJnhky1Xg==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.4': resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} peerDependencies: @@ -1432,6 +1441,10 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -1557,6 +1570,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001809: resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} @@ -1578,6 +1595,9 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1681,6 +1701,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1713,6 +1737,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -1734,6 +1761,9 @@ packages: elkjs@0.11.1: resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -1981,6 +2011,10 @@ packages: resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==} engines: {node: '>=20'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2031,6 +2065,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2194,6 +2232,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -2470,6 +2512,10 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2623,14 +2669,26 @@ packages: resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} engines: {node: '>= 0.4'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2710,6 +2768,10 @@ packages: engines: {node: '>=20'} hasBin: true + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2776,6 +2838,11 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -2809,10 +2876,17 @@ packages: remeda@2.33.4: resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2882,6 +2956,9 @@ packages: seq-queue@0.0.5: resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -2965,6 +3042,10 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -2988,6 +3069,10 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -3215,6 +3300,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.22: resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} engines: {node: '>= 0.4'} @@ -3233,13 +3321,28 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -4150,6 +4253,10 @@ snapshots: pg-protocol: 1.15.0 pg-types: 2.2.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 22.19.4 + '@types/react-dom@19.2.4(@types/react@19.2.18)': dependencies: '@types/react': 19.2.18 @@ -4457,6 +4564,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -4611,6 +4720,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001809: {} chai@6.2.2: {} @@ -4628,6 +4739,12 @@ snapshots: client-only@0.0.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4722,6 +4839,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + deep-is@0.1.4: {} deepmerge-ts@7.1.5: {} @@ -4750,6 +4869,8 @@ snapshots: detect-libc@2.1.2: {} + dijkstrajs@1.0.3: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -4771,6 +4892,8 @@ snapshots: elkjs@0.11.1: {} + emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} empathic@2.0.0: {} @@ -5189,6 +5312,11 @@ snapshots: fast-querystring: 1.1.2 safe-regex2: 5.1.1 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -5240,6 +5368,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5402,6 +5532,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -5623,6 +5755,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -5789,14 +5925,24 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5866,6 +6012,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pngjs@5.0.0: {} + possible-typed-array-names@1.1.0: {} postcss@8.5.23: @@ -5929,6 +6077,12 @@ snapshots: pure-rand@6.1.0: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + queue-microtask@1.2.3: {} rc9@3.0.1: @@ -5969,8 +6123,12 @@ snapshots: remeda@2.33.4: {} + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -6049,6 +6207,8 @@ snapshots: seq-queue@0.0.5: {} + set-blocking@2.0.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -6164,6 +6324,12 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.9 @@ -6215,6 +6381,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-bom@3.0.0: {} strip-json-comments@3.1.1: {} @@ -6442,6 +6612,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.22: dependencies: available-typed-arrays: 1.0.7 @@ -6463,10 +6635,37 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + xtend@4.0.2: {} + y18n@4.0.3: {} + yallist@3.1.1: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yocto-queue@0.1.0: {} zeptomatch@2.1.0: diff --git a/prisma/migrations/20260808214826_mfa_second_factor/migration.sql b/prisma/migrations/20260808214826_mfa_second_factor/migration.sql new file mode 100644 index 0000000..aa73127 --- /dev/null +++ b/prisma/migrations/20260808214826_mfa_second_factor/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6ad590d..fd10215 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -110,13 +110,33 @@ model User { /// Deuxième facteur, exigé des rôles administrateur et RH (matrice n° 15). mfaSecretEnc Bytes? mfaEnrolledAt DateTime? + /// Dernier pas TOTP employé. Interdit le rejeu d'un code dans sa fenêtre : + /// sans lui, le facteur protège du mot de passe volé, pas du code lu + /// par-dessus l'épaule. + mfaLastStep Int? lastSignInAt DateTime? failedAttempts Int @default(0) lockedUntil DateTime? createdAt DateTime @default(now()) - memberships Membership[] - sessions Session[] + memberships Membership[] + sessions Session[] + recoveryCodes MfaRecoveryCode[] +} + +/// Code de secours à usage unique. Perdre son téléphone ne doit pas fermer +/// définitivement l'accès — et la parade ne doit pas être de désactiver le +/// second facteur par un simple courriel au support. +model MfaRecoveryCode { + id String @id @default(cuid()) + userId String + codeHash String @unique + usedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) } /// Lien User ↔ Account. Porte le salarié : `userId` est nullable, car tous les @@ -190,6 +210,9 @@ model Session { userAgent String? revokedAt DateTime? revokedBy String? + /// Faux tant que le second facteur n'a pas été présenté. Une session en + /// attente ne donne accès à rien : elle sert seulement à porter le défi. + mfaSatisfied Boolean @default(true) user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/scripts/mfa-reset.ts b/scripts/mfa-reset.ts new file mode 100644 index 0000000..35ac259 --- /dev/null +++ b/scripts/mfa-reset.ts @@ -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 { + const email = process.argv[2]?.toLowerCase().trim(); + + if (!email) { + console.error( + 'Usage : pnpm exec tsx scripts/mfa-reset.ts ', + ); + 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()); diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index e7aaef8..953ab8b 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -1,7 +1,11 @@ import { redirect } from 'next/navigation'; import type { ReactNode } from 'react'; +import { MfaSettings } from '@/components/settings/MfaSettings'; import { AppShell } from '@/components/shell/AppShell'; +import { PageBody, PageHeader } from '@/components/shell/PageHeader'; +import { Card, CardHeader } from '@/components/ui/Card'; +import { mfaRequired } from '@/domain/access/mfa-policy'; import { currentSession } from '@/server/auth/session'; /** @@ -16,6 +20,17 @@ export default async function AppLayout({ children }: { children: ReactNode }) { const session = await currentSession(); if (!session) redirect('/connexion'); + // Un rôle qui lit les rémunérations ou distribue les droits doit porter un + // second facteur (matrice n° 15). L'obligation est tenue ici, au point de + // passage de toutes les routes applicatives. + // + // L'écran d'enrôlement **remplace** le contenu au lieu de rediriger vers + // lui : une redirection depuis un layout se joue aussi pendant la navigation + // qui suit la connexion, et Next y répond par une page vide. Substituer le + // contenu tient la même garantie sans dépendre du chemin demandé. + const enrolmentDue = + mfaRequired(session.actor.permissions) && !session.user.mfaEnrolled; + return ( - {children} + {enrolmentDue ? : children} ); } + +function EnrolmentGate() { + return ( + + + + + + + + ); +} diff --git a/src/app/(app)/reglages/securite/page.tsx b/src/app/(app)/reglages/securite/page.tsx new file mode 100644 index 0000000..4c47617 --- /dev/null +++ b/src/app/(app)/reglages/securite/page.tsx @@ -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 ( + + Second facteur actif + ) : ( + + {required ? 'Exigé, non activé' : 'Non activé'} + + ) + } + /> + + + + + + + ); +} diff --git a/src/app/(auth)/connexion/verification/MfaChallengeForm.tsx b/src/app/(auth)/connexion/verification/MfaChallengeForm.tsx new file mode 100644 index 0000000..db2cc1a --- /dev/null +++ b/src/app/(auth)/connexion/verification/MfaChallengeForm.tsx @@ -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 ( +
+ + + + + {state.error ? ( +

+ {state.error} +

+ ) : null} +
+ ); +} diff --git a/src/app/(auth)/connexion/verification/page.tsx b/src/app/(auth)/connexion/verification/page.tsx new file mode 100644 index 0000000..3729103 --- /dev/null +++ b/src/app/(auth)/connexion/verification/page.tsx @@ -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 ( +
+
+
+ + + PlanFlow + +
+ +
+

Vérification

+

+ Saisissez le code affiché par votre application + d’authentification pour {challenge.email}. +

+ +
+
+
+ ); +} diff --git a/src/components/settings/MfaSettings.tsx b/src/components/settings/MfaSettings.tsx new file mode 100644 index 0000000..47129a7 --- /dev/null +++ b/src/components/settings/MfaSettings.tsx @@ -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(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 ; + } + + if (active) { + return ( +
+

+ Un second facteur est actif sur votre compte. Il vous sera demandé à + chaque connexion. +

+ + {required ? ( +

+ Votre rôle donne accès aux rémunérations ou à la distribution des + droits : le second facteur est exigé et ne peut pas + être retiré. +

+ ) : ( +
+ + +
+ )} + + {disableState.error ? ( +

+ {disableState.error} +

+ ) : null} +
+ ); + } + + if (!offer?.secret) { + return ( +
+

+ 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. +

+ {required ? ( +

+ Votre rôle l’exige : tant qu’il n’est pas activé, l’accès aux + écrans reste fermé. +

+ ) : null} +
+ +
+ {disableState.message ? ( +

{disableState.message}

+ ) : null} +
+ ); + } + + return ( +
+ + +
    +
  1. +

    + 1. Ajoutez le compte à votre application +

    + {offer.qr ? ( +
    + ) : null} +

    + Sans appareil photo, saisissez cette clé : +

    +

    + {formatSecret(offer.secret)} +

    +
  2. +
  3. + +
  4. +
+ +
+ + + {confirmState.error ? ( + + {confirmState.error} + + ) : null} +
+
+ ); +} + +/** + * 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 ( +
+

+ Second facteur activé. Conservez ces codes de secours : + ils sont votre seule issue si vous perdez votre téléphone. +

+
    + {codes.map((code) => ( +
  • + {code} +
  • + ))} +
+

+ Affichés une seule fois : ils ne sont pas conservés en clair. Chacun ne + sert qu’une fois. +

+
+ ); +} diff --git a/src/components/shell/navigation.ts b/src/components/shell/navigation.ts index b6be184..a584b0e 100644 --- a/src/components/shell/navigation.ts +++ b/src/components/shell/navigation.ts @@ -84,6 +84,7 @@ export const NAVIGATION: NavSection[] = [ { id: 'sites', label: 'Établissements', href: '/reglages/etablissements' }, { id: 'registre', label: 'Registre de paramétrage', href: '/reglages/registre' }, { id: 'email', label: 'Envoi de courrier', href: '/reglages/email' }, + { id: 'securite', label: 'Sécurité', href: '/reglages/securite' }, { id: 'convention', label: 'Convention collective' }, { id: 'postes', label: 'Postes et étiquettes' }, { id: 'roles', label: 'Rôles et permissions' }, diff --git a/src/domain/access/mfa-policy.ts b/src/domain/access/mfa-policy.ts new file mode 100644 index 0000000..f18be8f --- /dev/null +++ b/src/domain/access/mfa-policy.ts @@ -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): boolean { + return MFA_REQUIRED_CAPABILITIES.some((code) => permissions.has(code)); +} diff --git a/src/domain/access/totp.ts b/src/domain/access/totp.ts new file mode 100644 index 0000000..8fecec5 --- /dev/null +++ b/src/domain/access/totp.ts @@ -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(); +} diff --git a/src/server/auth/actions.ts b/src/server/auth/actions.ts index 4bb5a9f..2d96b62 100644 --- a/src/server/auth/actions.ts +++ b/src/server/auth/actions.ts @@ -4,7 +4,10 @@ import { cookies, headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { z } from 'zod'; +import { answerChallenge } from '@/server/auth/mfa'; import { + pendingChallenge, + satisfyMfa, SESSION_COOKIE, SESSION_COOKIE_OPTIONS, signIn, @@ -57,7 +60,44 @@ export async function signInAction( expires: result.expiresAt, }); - redirect('/'); + // La session existe mais ne résout aucun acteur tant que le second facteur + // n'est pas présenté : rediriger vers l'application produirait une boucle. + redirect(result.mfaPending ? '/connexion/verification' : '/'); +} + +export interface ChallengeState { + error?: string; +} + +/** + * Éprouve le second facteur. + * + * L'échec ne détruit pas la session en attente : elle expire d'elle-même en dix + * minutes, et la supprimer au premier code mal recopié renverrait ressaisir le + * mot de passe sans motif. + */ +export async function verifyMfaAction( + _previous: ChallengeState, + formData: FormData, +): Promise { + 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 { diff --git a/src/server/auth/mfa-actions.ts b/src/server/auth/mfa-actions.ts new file mode 100644 index 0000000..3ffc16b --- /dev/null +++ b/src/server/auth/mfa-actions.ts @@ -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 { + 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 { + 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 { + 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é.' }; +} diff --git a/src/server/auth/mfa.ts b/src/server/auth/mfa.ts new file mode 100644 index 0000000..0a83596 --- /dev/null +++ b/src/server/auth/mfa.ts @@ -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 { + 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 { + 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)}`; +} diff --git a/src/server/auth/session.ts b/src/server/auth/session.ts index f8619c5..eb01ec7 100644 --- a/src/server/auth/session.ts +++ b/src/server/auth/session.ts @@ -53,8 +53,24 @@ export interface SignInInput { userAgent?: string | null; } +/** + * Durée d'une session en attente du second facteur. + * + * Elle ne donne accès à rien, mais elle atteste d'un mot de passe juste : lui + * laisser douze heures offrirait autant de temps pour éprouver les six chiffres + * depuis un poste laissé ouvert. + */ +const MFA_CHALLENGE_MS = 10 * 60 * 1000; + export type SignInResult = - | { ok: true; token: string; expiresAt: Date; userId: string } + | { + ok: true; + token: string; + expiresAt: Date; + userId: string; + /** Vrai quand la session attend encore le second facteur. */ + mfaPending: boolean; + } | { ok: false; reason: 'invalid' | 'locked' }; export async function signIn(input: SignInInput): Promise { @@ -94,7 +110,13 @@ export async function signIn(input: SignInInput): Promise { } const token = generateToken(); - const expiresAt = new Date(Date.now() + SESSION_DURATION_MS); + // Le second facteur est exigé dès qu'il est enregistré, quel que soit le + // rôle : un salarié qui a pris la peine de l'activer ne doit pas pouvoir + // entrer sans lui. + const mfaPending = user.mfaEnrolledAt !== null && user.mfaSecretEnc !== null; + const expiresAt = new Date( + Date.now() + (mfaPending ? MFA_CHALLENGE_MS : SESSION_DURATION_MS), + ); await db.$transaction([ db.session.create({ @@ -104,6 +126,7 @@ export async function signIn(input: SignInInput): Promise { expiresAt, ip: input.ip ?? null, userAgent: input.userAgent ?? null, + mfaSatisfied: !mfaPending, }, }), db.user.update({ @@ -111,12 +134,70 @@ export async function signIn(input: SignInInput): Promise { data: { failedAttempts: 0, lockedUntil: null, - lastSignInAt: new Date(), + // La date de dernière connexion n'est posée qu'une fois les deux + // facteurs présentés : un mot de passe juste seul n'est pas une + // connexion. + ...(mfaPending ? {} : { lastSignInAt: new Date() }), }, }), ]); - return { ok: true, token, expiresAt, userId: user.id }; + return { ok: true, token, expiresAt, userId: user.id, mfaPending }; +} + +export interface PendingChallenge { + sessionId: string; + userId: string; + email: string; +} + +/** + * Session ouverte mais en attente du second facteur. + * + * Distincte de `resolveSession`, qui refuse ces sessions : l'écran de défi est + * le seul endroit où elles ont un sens, et les confondre reviendrait à laisser + * une session à demi ouverte circuler dans l'application. + */ +export async function pendingChallenge( + token: string, +): Promise { + 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 { + 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 { @@ -143,6 +224,8 @@ export interface SessionUser { lastName: string; email: string; initials: string; + /** Un second facteur est enregistré pour ce compte. */ + mfaEnrolled: boolean; } export interface SessionContext { @@ -182,7 +265,15 @@ export async function resolveSession( }, }); - if (!session || session.revokedAt || session.expiresAt < new Date()) { + // Une session en attente du second facteur ne résout aucun acteur : elle ne + // porte qu'un défi. C'est ici, au point de passage unique, que la garantie + // tient — pas dans chaque écran. + if ( + !session || + !session.mfaSatisfied || + session.revokedAt || + session.expiresAt < new Date() + ) { return null; } @@ -220,6 +311,7 @@ export async function resolveSession( lastName, email, initials: `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(), + mfaEnrolled: session.user.mfaEnrolledAt !== null, }, accountName: membership.account.name, roleName: membership.role.name, diff --git a/tests/e2e/auth.setup.ts b/tests/e2e/auth.setup.ts index f64382b..c554877 100644 --- a/tests/e2e/auth.setup.ts +++ b/tests/e2e/auth.setup.ts @@ -1,19 +1,50 @@ import { test as setup, expect } from '@playwright/test'; +import { rememberSecret } from './mfa'; import { STORAGE_STATE } from './storage'; +import { resetMfa } from './support/db'; +import { totpCode, totpStep } from '../../src/domain/access/totp'; /** * Ouvre une session une fois et enregistre le cookie pour les autres tests. * * Chaque test se connecterait sinon, ce qui coûterait un argon2 par test — * volontairement lent — et ferait grimper le compteur d'échecs partagé. + * + * Le rôle de la direction exige un second facteur (matrice n° 15) : la mise en + * place l'enrôle donc réellement. Le retrait préalable rend l'opération + * rejouable — sans lui, un deuxième passage se heurterait au facteur posé par + * le premier, dont le secret a disparu avec lui. */ +const EMAIL = 'direction@example.test'; +const PASSWORD = 'planflow-demo-2026'; + setup('authentifie la direction', async ({ page }) => { + await resetMfa(EMAIL); + await page.goto('/connexion'); - await page.getByLabel('Adresse électronique').fill('direction@example.test'); - await page.getByLabel('Mot de passe').fill('planflow-demo-2026'); + await page.getByLabel('Adresse électronique').fill(EMAIL); + await page.getByLabel('Mot de passe').fill(PASSWORD); await page.getByRole('button', { name: 'Se connecter' }).click(); + // Le rôle exige le second facteur : l'application substitue l'enrôlement au + // contenu tant qu'il n'est pas posé. + await page.getByRole('button', { name: 'Activer le second facteur' }).click(); + + const shown = await page.locator('p.font-mono').first().textContent(); + const secret = (shown ?? '').replace(/\s/g, ''); + expect(secret.length).toBeGreaterThan(16); + + const usedStep = totpStep(Date.now()); + await page + .getByRole('textbox', { name: 'Recopiez le code affiché' }) + .fill(totpCode(secret, Date.now())); + await page.getByRole('button', { name: 'Valider' }).click(); + await expect(page.getByTestId('recovery-codes')).toBeVisible(); + + rememberSecret(secret, usedStep); + + await page.goto('/'); await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible(); await page.context().storageState({ path: STORAGE_STATE }); }); diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts index e27ae3d..33e80e0 100644 --- a/tests/e2e/auth.spec.ts +++ b/tests/e2e/auth.spec.ts @@ -1,5 +1,7 @@ import { expect, test } from '@playwright/test'; +import { answerChallenge, rememberedSecret, waitForFreshCode } from './mfa'; + const EMAIL = 'direction@example.test'; const PASSWORD = 'planflow-demo-2026'; @@ -34,11 +36,22 @@ test('un mot de passe faux ne dit pas si le compte existe', async ({ page }) => }); test('connexion, navigation, puis déconnexion', async ({ page }) => { + const remembered = rememberedSecret(); + // Un code ne sert qu'une fois : attendre le pas suivant celui qu'a employé la + // mise en place, plutôt que de se heurter au refus de rejeu. L'attente est + // nulle dès que trente secondes se sont écoulées entre-temps. + await waitForFreshCode(remembered.usedStep); + await page.goto('/connexion'); await page.getByLabel('Adresse électronique').fill(EMAIL); await page.getByLabel('Mot de passe').fill(PASSWORD); await page.getByRole('button', { name: 'Se connecter' }).click(); + // Le rôle de la direction exige un second facteur : le mot de passe seul + // n'ouvre rien. + await expect(page.getByRole('heading', { name: 'Vérification' })).toBeVisible(); + await answerChallenge(page, remembered.secret); + await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible(); // L'identité affichée vient de la base, pas d'un libellé en dur. await expect(page.getByTitle(/Camille Ferrand/)).toBeVisible(); diff --git a/tests/e2e/mfa.ts b/tests/e2e/mfa.ts new file mode 100644 index 0000000..57c6a25 --- /dev/null +++ b/tests/e2e/mfa.ts @@ -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 { + 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 { + await page.getByLabel('Code').fill(codeNow(secret)); + await page.getByRole('button', { name: 'Vérifier' }).click(); +} diff --git a/tests/e2e/registre.spec.ts b/tests/e2e/registre.spec.ts new file mode 100644 index 0000000..8c98047 --- /dev/null +++ b/tests/e2e/registre.spec.ts @@ -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(); +}); diff --git a/tests/e2e/reglages.spec.ts b/tests/e2e/reglages.spec.ts index 8b99c6a..032b928 100644 --- a/tests/e2e/reglages.spec.ts +++ b/tests/e2e/reglages.spec.ts @@ -3,6 +3,12 @@ import { expect, test } from '@playwright/test'; /** * Ce test se connecte en manager : il ne peut donc pas réutiliser la session * partagée de la direction, d'où le projet « anonyme ». + * + * Les écrans qui n'éprouvent pas l'authentification ont été déplacés dans + * `registre.spec.ts`, où ils réutilisent la session commune : la direction + * porte désormais un second facteur, et deux connexions simultanées sur le même + * compte se heurteraient au refus de rejeu d'un code — ce qui est le + * comportement voulu, pas un défaut à contourner. */ async function signIn(page: import('@playwright/test').Page, email: string) { await page.goto('/connexion'); @@ -12,37 +18,6 @@ async function signIn(page: import('@playwright/test').Page, email: string) { await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible(); } -test('la direction lit et alimente le registre de paramétrage', async ({ - page, -}) => { - await signIn(page, 'direction@example.test'); - await page.goto('/reglages/registre'); - - await expect( - page.getByRole('heading', { name: 'Registre de paramétrage juridique' }), - ).toBeVisible(); - - const parameter = `Durée quotidienne maximale ${Date.now()}`; - // Ciblage par attribut `name` : les libellés portent un texte d'aide, et - // celui de « Source » contient lui-même le mot « valeur », ce qui rend la - // correspondance par libellé ambiguë. - const form = page.locator('form').filter({ hasText: 'Consigner' }); - await form.locator('input[name="key"]').fill(parameter); - await form.locator('input[name="value"]').fill('10 h'); - await form - .locator('input[name="source"]') - .fill('IDCC 1517 — texte consolidé Legifrance'); - await form.locator('input[name="population"]').fill('Tous les salariés'); - await page.getByRole('button', { name: 'Consigner' }).click(); - - const row = page.getByRole('row', { name: new RegExp(parameter) }); - await expect(row).toBeVisible(); - - // Consigné n'est pas approuvé : la matrice exige un approbateur nommé. - await row.getByRole('button', { name: 'Approuver' }).click(); - await expect(row.getByText(/Approuvé le/)).toBeVisible(); -}); - test('un manager ne peut ni voir ni modifier les établissements', async ({ page, }) => { @@ -58,39 +33,3 @@ test('un manager ne peut ni voir ni modifier les établissements', async ({ page.getByRole('heading', { name: 'Établissements' }), ).toBeHidden(); }); - -test('un salarié sans compte applicatif est créable', async ({ page }) => { - await signIn(page, 'direction@example.test'); - await page.goto('/equipe'); - - await expect(page.getByRole('heading', { name: 'Équipe' })).toBeVisible(); - // L'effectif vient de la base, pas du module de démonstration. - await expect(page.getByText('E0001')).toBeVisible(); - - // Nom unique par exécution : la base de test n'est pas remise à zéro entre - // deux passages, et un nom fixe finirait par désigner plusieurs salariés. - const matricule = `E9${Date.now() % 100000}`; - const nom = `Sanscompte${matricule}`; - const form = page.locator('form').filter({ hasText: 'Ajouter' }); - await form.locator('input[name="firstName"]').fill('Sans'); - await form.locator('input[name="lastName"]').fill(nom); - await form.locator('input[name="employeeNumber"]').fill(matricule); - await page.getByRole('button', { name: 'Ajouter' }).click(); - await expect(page.getByText('Salarié ajouté.')).toBeVisible(); - - // Rechargement explicite : ce qui est vérifié ici est la persistance et la - // présence dans l'annuaire, pas le moment exact où la revalidation atteint - // le rendu courant. - await page.reload(); - - // Un salarié sans adresse doit exister : la plupart des équipes de vente ne - // se connectent jamais à l'outil. - await expect( - page.getByRole('cell', { name: matricule, exact: true }), - ).toBeVisible(); - // Le nom vit sur le dossier, pas sur le compte : un salarié sans accès - // applicatif doit tout de même figurer nommément au registre du personnel. - await expect( - page.getByRole('link', { name: new RegExp(`Sans ${nom}`) }), - ).toBeVisible(); -}); diff --git a/tests/e2e/securite.spec.ts b/tests/e2e/securite.spec.ts new file mode 100644 index 0000000..d15ea2d --- /dev/null +++ b/tests/e2e/securite.spec.ts @@ -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 { + 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'); +}); diff --git a/tests/e2e/support/db.ts b/tests/e2e/support/db.ts new file mode 100644 index 0000000..39cbc19 --- /dev/null +++ b/tests/e2e/support/db.ts @@ -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 { + 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 } }); +} diff --git a/tests/unit/totp.test.ts b/tests/unit/totp.test.ts new file mode 100644 index 0000000..e89b42b --- /dev/null +++ b/tests/unit/totp.test.ts @@ -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, + ); + }); +});