diff --git a/.env.example b/.env.example index a7be19c..c407580 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,27 @@ JWT_EXPIRES_IN=7d # Chemins dans le container (ne pas modifier) DB_PATH=/app/data/crowdlending.db UPLOAD_DIR=/app/uploads + +# --- SMTP (optionnel — configurable aussi depuis Administration > SMTP) --- +# La DB est prioritaire sur ces variables. Elles servent de valeurs par défaut +# au premier démarrage ou si aucune config n'a été sauvegardée en base. + +# Activer l'envoi d'emails (0 = désactivé, 1 = activé) +# SMTP_ENABLED=1 + +# Serveur SMTP +# SMTP_HOST=smtp.example.com +# SMTP_PORT=465 +# SMTP_SECURE=1 # 1 = TLS (port 465), 0 = STARTTLS (port 587) + +# Authentification +# SMTP_EMAIL=noreply@example.com +# SMTP_USERNAME=noreply@example.com +# SMTP_PASSWORD=your-smtp-password + +# Autoriser les certificats auto-signés (déconseillé en production) +# SMTP_ALLOW_UNAUTH=0 + +# Identité de la plateforme (utilisée dans les emails et sur la page de connexion) +# APP_NAME=Crowdlending Tracker +# APP_URL=https://votre-domaine.com diff --git a/backend/package-lock.json b/backend/package-lock.json index f0b57b1..4427617 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -19,6 +19,8 @@ "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", "nodemailer": "^8.0.11", + "otplib": "^13.4.1", + "qrcode": "^1.5.4", "sharp": "^0.34.5", "xlsx": "^0.18.5", "zod": "^3.23.8" @@ -499,6 +501,83 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@otplib/core": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.1.tgz", + "integrity": "sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA==", + "license": "MIT" + }, + "node_modules/@otplib/hotp": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.1.tgz", + "integrity": "sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@otplib/uri": "13.4.1" + } + }, + "node_modules/@otplib/plugin-base32-scure": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.1.tgz", + "integrity": "sha512-Fs/r5qisC05SRhT6xWXaypB6PVC0vgWf6zztmi0J5RnQ09OJiPDWCJFH6cDm6ANsrdvB9di7X+Jb7L13BoEbUA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@scure/base": "^2.2.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.1.tgz", + "integrity": "sha512-PJfVW8/1hdS6CfxLheKPZSLTwDq4TijZbN4yRjxlv0ODdzmxpM+wGwWr1JXMdy0xJPxLziydQD5gdVqrR4/gAg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.2.0", + "@otplib/core": "13.4.1" + } + }, + "node_modules/@otplib/totp": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.1.tgz", + "integrity": "sha512-QOkBVPrf6AM4qZaReZPSk9/I8ATVdZpIISJz115MqeVtcrbcr5llPZ0J7804tpnjnp1vCRkI5Qjd47HhgVteBQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@otplib/hotp": "13.4.1", + "@otplib/uri": "13.4.1" + } + }, + "node_modules/@otplib/uri": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.1.tgz", + "integrity": "sha512-xaIm7bvICMhoB2rZIR5luiaMdssWR5nY5nXnR1fdezUgZuEO58D6zrGzLp7pQuBmlpmL0HagnscDQFoskp9yiA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1" + } + }, + "node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -521,6 +600,30 @@ "node": ">=0.8" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -731,6 +834,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/cfb": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", @@ -750,6 +862,17 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/codepage": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", @@ -759,6 +882,24 @@ "node": ">=0.8" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/concat-stream": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", @@ -854,6 +995,15 @@ "ms": "2.0.0" } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -906,6 +1056,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -947,6 +1103,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1104,6 +1266,19 @@ "node": ">= 0.8" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1146,6 +1321,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1307,6 +1491,15 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -1362,6 +1555,18 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -1652,6 +1857,56 @@ "wrappy": "1" } }, + "node_modules/otplib": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.1.tgz", + "integrity": "sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@otplib/hotp": "13.4.1", + "@otplib/plugin-base32-scure": "13.4.1", + "@otplib/plugin-crypto-noble": "13.4.1", + "@otplib/totp": "13.4.1", + "@otplib/uri": "13.4.1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1661,12 +1916,30 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -1723,6 +1996,23 @@ "once": "^1.3.1" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -1798,6 +2088,21 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1881,6 +2186,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -2092,6 +2403,32 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -2223,6 +2560,12 @@ "node": ">= 0.8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/wmf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", @@ -2241,6 +2584,20 @@ "node": ">=0.8" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -2277,6 +2634,47 @@ "node": ">=0.4" } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/backend/package.json b/backend/package.json index 6ee6883..2c3d3b2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,6 +22,8 @@ "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", "nodemailer": "^8.0.11", + "otplib": "^13.4.1", + "qrcode": "^1.5.4", "sharp": "^0.34.5", "xlsx": "^0.18.5", "zod": "^3.23.8" diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 2e5b66d..f6728ef 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -939,7 +939,6 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_corrections_plateforme ON corrections_s } } -export default db; // ── Table user_preferences ─────────────────────────────────────────────────── // Stockage générique des préférences UI par utilisateur. @@ -1671,85 +1670,111 @@ db.exec(` console.log('[DB] Tables catégories/secteurs plateforme+investissement OK'); } -// ── Migration ponctuelle : correction date_cible aberrantes (>2100) ────────── -// Certains prêts différés importés ont une date_cible avec un siècle erroné. -// On recalcule date_souscription + duree_mois et on régénère la simulation. + +// ── Migration : table de configuration SMTP ────────────────────────────────── +db.exec(` + CREATE TABLE IF NOT EXISTS smtp_config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enabled INTEGER NOT NULL DEFAULT 0, + host TEXT NOT NULL DEFAULT '', + port INTEGER NOT NULL DEFAULT 587, + secure INTEGER NOT NULL DEFAULT 0, + email TEXT NOT NULL DEFAULT '', + username TEXT NOT NULL DEFAULT '', + password TEXT NOT NULL DEFAULT '', + allow_unauth INTEGER NOT NULL DEFAULT 0, + app_name TEXT NOT NULL DEFAULT 'Crowdlending Tracker', + app_url TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); + +// ── Migration : email_verified sur users ───────────────────────────────────── { - function fixAddMonths(isoDate, months) { - const [y, m, d] = isoDate.split('-').map(Number); - let nm = m + months; - let ny = y; - while (nm > 12) { nm -= 12; ny++; } - const maxDay = new Date(Date.UTC(ny, nm, 0)).getUTCDate(); - const nd = Math.min(d, maxDay); - return `${String(ny).padStart(4,'0')}-${String(nm).padStart(2,'0')}-${String(nd).padStart(2,'0')}`; - } - - const toFix = db.prepare(` - SELECT i.id, i.date_souscription, i.duree_mois, - i.montant_investi, i.taux_interet, i.type_remb, i.freq_interets, - i.date_premiere_echeance, i.date_debut_simul, i.echeance_fin_de_mois - FROM investissements i - WHERE i.statut IN ('en_cours','en_retard','procedure') - AND i.type_remb = 'differe' - AND i.date_cible > '2100-01-01' - AND i.duree_mois IS NOT NULL - `).all(); - - if (toFix.length > 0) { - const updateDate = db.prepare(`UPDATE investissements SET date_cible=?, updated_at=datetime('now') WHERE id=?`); - const fixAll = db.transaction(() => { - for (const inv of toFix) { - const newDate = fixAddMonths(inv.date_souscription, inv.duree_mois); - updateDate.run(newDate, inv.id); - generateSimul(db, { ...inv, date_cible: newDate }); - console.log(`[DB] Fix date_cible id=${inv.id} → ${newDate}`); - } - }); - fixAll(); - console.log(`[DB] ${toFix.length} date_cible aberrantes corrigées.`); + const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); + if (!userCols.includes('email_verified')) { + // DEFAULT 1 pour ne pas bloquer les comptes existants + db.exec('ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 1'); + console.log('[DB] users.email_verified ajouté'); } } -// ── Migration : table smtp_config ───────────────────────────────────────── +// ── Migration : table email_verification_tokens ─────────────────────────────── +db.exec(` + CREATE TABLE IF NOT EXISTS email_verification_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + used INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); + +// ── Migration : table password_reset_tokens ─────────────────────────────────── +db.exec(` + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + used INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); + +// ── Migrations 2FA ──────────────────────────────────────────────────────────── { - db.exec(` - CREATE TABLE IF NOT EXISTS smtp_config ( - id INTEGER PRIMARY KEY CHECK (id = 1), - enabled INTEGER NOT NULL DEFAULT 0, - host TEXT, - port INTEGER NOT NULL DEFAULT 587, - secure INTEGER NOT NULL DEFAULT 0, - email TEXT, - username TEXT, - password TEXT, - allow_unauth INTEGER NOT NULL DEFAULT 0, - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - ) - `); - - // Seed row unique (id=1) si elle n'existe pas encore - const existing = db.prepare('SELECT id FROM smtp_config WHERE id = 1').get(); - if (!existing) { - // Pré-remplir depuis les variables d'environnement si disponibles - db.prepare(` - INSERT INTO smtp_config (id, enabled, host, port, email, username, password) - VALUES (1, 0, ?, ?, ?, ?, ?) - `).run( - process.env.SMTP_HOST || null, - parseInt(process.env.SMTP_PORT || '587', 10), - process.env.SMTP_EMAIL || null, - process.env.SMTP_USERNAME || null, - process.env.SMTP_PASSWORD || null, - ); + const userCols2 = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); + if (!userCols2.includes('totp_secret')) { + db.exec('ALTER TABLE users ADD COLUMN totp_secret TEXT'); + console.log('[DB] users.totp_secret ajouté'); + } + if (!userCols2.includes('totp_enabled')) { + db.exec('ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0'); + console.log('[DB] users.totp_enabled ajouté'); } - - // Ajout des colonnes app_name et app_url si absentes - const smtpCols = db.prepare('PRAGMA table_info(smtp_config)').all().map(c => c.name); - if (!smtpCols.includes('app_name')) - db.exec(`ALTER TABLE smtp_config ADD COLUMN app_name TEXT DEFAULT 'Crowdlending'`); - if (!smtpCols.includes('app_url')) - db.exec(`ALTER TABLE smtp_config ADD COLUMN app_url TEXT DEFAULT ''`); - - console.log('[DB] Table smtp_config OK'); } + +// Sessions temporaires 2FA (entre /login et /2fa/verify) +db.exec(` + CREATE TABLE IF NOT EXISTS two_fa_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + used INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_2fa_sess_token ON two_fa_sessions(token)'); + +// Codes OTP envoyés par email +db.exec(` + CREATE TABLE IF NOT EXISTS two_fa_email_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code TEXT NOT NULL, + expires_at TEXT NOT NULL, + used INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_2fa_email_uid ON two_fa_email_codes(user_id)'); + +// Appareils de confiance (30 jours) +db.exec(` + CREATE TABLE IF NOT EXISTS two_fa_trusted_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_2fa_dev_token ON two_fa_trusted_devices(token)'); +db.exec('CREATE INDEX IF NOT EXISTS idx_2fa_dev_uid ON two_fa_trusted_devices(user_id)'); + +console.log('[DB] Migrations 2FA OK'); + +export default db; diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 0cb929d..2643462 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -48,13 +48,24 @@ const router = Router(); /** Liste tous les utilisateurs */ router.get('/users', (req, res) => { const users = db.prepare(` - SELECT id, email, display_name, role, created_at + SELECT id, email, display_name, role, email_verified, created_at FROM users ORDER BY id ASC `).all(); res.json(users); }); +/** Vérifier manuellement l'email d'un utilisateur */ +router.patch('/users/:id/verify-email', (req, res, next) => { + try { + const targetId = Number(req.params.id); + const r = db.prepare("UPDATE users SET email_verified=1, updated_at=datetime('now') WHERE id=?").run(targetId); + if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable'); + db.prepare('UPDATE email_verification_tokens SET used=1 WHERE user_id=? AND used=0').run(targetId); + res.json({ ok: true }); + } catch (e) { next(e); } +}); + /** Crée un utilisateur */ const CreateUserSchema = z.object({ email: z.string().email(), @@ -387,7 +398,7 @@ router.get('/smtp', (req, res) => { username: row.username || '', hasPassword: !!(row.password), allowUnauth: !!row.allow_unauth, - appName: row.app_name || 'Crowdlending', + appName: row.app_name || 'Crowdlending Tracker', appUrl: row.app_url || '', }); }); @@ -450,7 +461,7 @@ router.put('/smtp', (req, res, next) => { body.username ?? null, passwordToStore, body.allowUnauth ? 1 : 0, - body.appName ?? 'Crowdlending', + body.appName ?? 'Crowdlending Tracker', body.appUrl ?? '', ); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index b9b650f..2fc0534 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -1,9 +1,11 @@ import { Router } from 'express'; import bcrypt from 'bcryptjs'; +import crypto from 'node:crypto'; import { z } from 'zod'; import db from '../db/index.js'; import { signToken, requireAuth } from '../middleware/auth.js'; import { HttpError } from '../middleware/errorHandler.js'; +import { sendMail, buildEmailHtml, getSmtpConfig } from '../utils/mailer.js'; const router = Router(); @@ -18,7 +20,7 @@ const LoginSchema = z.object({ password: z.string().min(1), }); -router.post('/register', (req, res, next) => { +router.post('/register', async (req, res, next) => { try { const body = RegisterSchema.parse(req.body); const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(body.email); @@ -28,10 +30,15 @@ router.post('/register', (req, res, next) => { const isFirst = db.prepare('SELECT COUNT(*) AS n FROM users').get().n === 0; const role = isFirst ? 'admin' : 'user'; + // Le 1er utilisateur (admin) est auto-vérifié ; les suivants si SMTP désactivé + const cfg = getSmtpConfig(); + const smtpReady = cfg.enabled && cfg.host && cfg.email; + const autoVerified = isFirst || !smtpReady ? 1 : 0; + const hash = bcrypt.hashSync(body.password, 10); const result = db - .prepare('INSERT INTO users (email, password_hash, display_name, role) VALUES (?, ?, ?, ?)') - .run(body.email, hash, body.displayName || null, role); + .prepare('INSERT INTO users (email, password_hash, display_name, role, email_verified) VALUES (?, ?, ?, ?, ?)') + .run(body.email, hash, body.displayName || null, role, autoVerified); const userId = result.lastInsertRowid; @@ -47,36 +54,104 @@ router.post('/register', (req, res, next) => { 'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)' ).run(userId, `Compte courant — ${fullName}`, 'compte_courant', invResult.lastInsertRowid); + // Si l'utilisateur doit vérifier son email → envoyer l'email de bienvenue + if (!autoVerified) { + const vToken = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + db.prepare('INSERT INTO email_verification_tokens (user_id, token, expires_at) VALUES (?,?,?)').run(userId, vToken, expiresAt); + + const appUrl = cfg.appUrl?.replace(/\/$/, '') || ''; + try { + await sendMail({ + to: body.email, + subject: `Bienvenue sur ${cfg.appName} — Vérifiez votre adresse email`, + html: buildEmailHtml({ + title: `Bienvenue ${prenom || ''} !`, + body: `
Votre compte a été créé avec succès. Pour commencer à utiliser ${cfg.appName}, veuillez confirmer votre adresse email en cliquant sur le bouton ci-dessous.
+Ce lien est valable 24 heures.
`, + ctaLabel: 'Vérifier mon adresse email', + ctaUrl: `${appUrl}/verify-email?token=${vToken}`, + }), + }); + } catch (mailErr) { + console.error('[auth] Échec envoi email bienvenue:', mailErr.message); + // Ne pas faire échouer l'inscription si le mail échoue + } + + return res.status(201).json({ requiresVerification: true, email: body.email }); + } + const token = signToken({ sub: userId, email: body.email }); res.status(201).json({ token, - user: { id: userId, email: body.email, displayName: body.displayName || null, role }, + user: { id: userId, email: body.email, displayName: body.displayName || null, role, email_verified: 1 }, }); } catch (e) { next(e); } }); -router.post('/login', (req, res, next) => { +const LoginSchema2FA = LoginSchema.extend({ + deviceToken: z.string().optional(), +}); + +router.post('/login', async (req, res, next) => { try { - const body = LoginSchema.parse(req.body); + const body = LoginSchema2FA.parse(req.body); const user = db - .prepare('SELECT id, email, password_hash, display_name, role FROM users WHERE email = ?') + .prepare('SELECT id, email, password_hash, display_name, role, email_verified, totp_enabled FROM users WHERE email = ?') .get(body.email); if (!user) throw new HttpError(401, 'Invalid credentials'); const ok = bcrypt.compareSync(body.password, user.password_hash); if (!ok) throw new HttpError(401, 'Invalid credentials'); + if (!user.email_verified) { + return res.status(403).json({ + error: 'Veuillez vérifier votre adresse email avant de vous connecter.', + code: 'EMAIL_NOT_VERIFIED', + email: user.email, + }); + } + + // ── 2FA ────────────────────────────────────────────────────────────── + if (user.totp_enabled) { + // Vérifier si l'appareil est déjà de confiance + if (body.deviceToken) { + const dev = db.prepare( + 'SELECT id FROM two_fa_trusted_devices WHERE token = ? AND user_id = ? AND expires_at > datetime(\'now\')' + ).get(body.deviceToken, user.id); + if (dev) { + // Appareil de confiance — émettre le JWT directement + const token = signToken({ sub: user.id, email: user.email }); + return res.json({ + token, + user: { id: user.id, email: user.email, displayName: user.display_name, role: user.role, email_verified: 1, totp_enabled: 1 }, + }); + } + } + + // Créer une session 2FA temporaire (5 minutes) + const sessionToken = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + db.prepare('INSERT INTO two_fa_sessions (user_id, token, expires_at) VALUES (?,?,?)').run(user.id, sessionToken, expiresAt); + + return res.json({ + requires2FA: true, + sessionToken, + email: user.email, + }); + } + const token = signToken({ sub: user.id, email: user.email }); res.json({ token, - user: { id: user.id, email: user.email, displayName: user.display_name, role: user.role }, + user: { id: user.id, email: user.email, displayName: user.display_name, role: user.role, email_verified: 1, totp_enabled: 0 }, }); } catch (e) { next(e); } }); router.get('/me', requireAuth, (req, res) => { const user = db - .prepare('SELECT id, email, display_name, role FROM users WHERE id = ?') + .prepare('SELECT id, email, display_name, role, email_verified FROM users WHERE id = ?') .get(req.user.id); res.json({ user }); }); @@ -88,12 +163,12 @@ const UpdateMeSchema = z.object({ newPassword: z.string().min(8).optional(), }); -router.put('/me', requireAuth, (req, res, next) => { +router.put('/me', requireAuth, async (req, res, next) => { try { const body = UpdateMeSchema.parse(req.body); const current = db - .prepare('SELECT id, email, password_hash, display_name FROM users WHERE id = ?') + .prepare('SELECT id, email, password_hash, display_name, email_verified FROM users WHERE id = ?') .get(req.user.id); let newHash = undefined; @@ -114,18 +189,331 @@ router.put('/me', requireAuth, (req, res, next) => { const newEmail = body.email ?? current.email; const newDisplayName = body.displayName !== undefined ? body.displayName : current.display_name; const newPasswordHash = newHash ?? current.password_hash; + const emailChanged = newEmail !== current.email; + const newEmailVerified = emailChanged ? 0 : (current.email_verified ?? 1); db.prepare( - "UPDATE users SET email=?, display_name=?, password_hash=?, updated_at=datetime('now') WHERE id=?" - ).run(newEmail, newDisplayName, newPasswordHash, req.user.id); + "UPDATE users SET email=?, display_name=?, password_hash=?, email_verified=?, updated_at=datetime('now') WHERE id=?" + ).run(newEmail, newDisplayName, newPasswordHash, newEmailVerified, req.user.id); - const token = newEmail !== current.email - ? signToken({ sub: req.user.id, email: newEmail }) - : undefined; + // Si l'email change, envoyer un email de vérification sur la nouvelle adresse + if (emailChanged) { + const cfg = getSmtpConfig(); + if (cfg.enabled && cfg.host) { + try { + db.prepare('UPDATE email_verification_tokens SET used=1 WHERE user_id=? AND used=0').run(req.user.id); + const vToken = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + db.prepare('INSERT INTO email_verification_tokens (user_id, token, expires_at) VALUES (?,?,?)').run(req.user.id, vToken, expiresAt); + const appUrl = cfg.appUrl?.replace(/\/$/, '') || ''; + await sendMail({ + to: newEmail, + subject: `Vérifiez votre nouvelle adresse email — ${cfg.appName}`, + html: buildEmailHtml({ + title: 'Vérification de votre nouvel email', + body: `Vous avez modifié votre adresse email. Cliquez ci-dessous pour confirmer cette nouvelle adresse.
`, + ctaLabel: 'Vérifier mon adresse email', + ctaUrl: `${appUrl}/verify-email?token=${vToken}`, + }), + }); + } catch (_) { /* Ne pas bloquer la réponse si SMTP échoue */ } + } + } + + const newToken = emailChanged ? signToken({ sub: req.user.id, email: newEmail }) : undefined; + const updatedUser = db.prepare('SELECT id, email, display_name, role, email_verified FROM users WHERE id=?').get(req.user.id); res.json({ - user: { id: req.user.id, email: newEmail, display_name: newDisplayName }, - ...(token ? { token } : {}), + user: updatedUser, + ...(newToken ? { token: newToken } : {}), + ...(emailChanged ? { requiresVerification: true } : {}), + }); + } catch (e) { next(e); } +}); + +// ── Vérification d'adresse email ────────────────────────────────────────── +router.get('/verify-email', (req, res, next) => { + try { + const token = req.query.token; + if (!token) throw new HttpError(400, 'Token manquant.'); + + const row = db.prepare( + `SELECT evt.*, u.id AS uid FROM email_verification_tokens evt + JOIN users u ON u.id = evt.user_id + WHERE evt.token = ? AND evt.used = 0` + ).get(token); + + if (!row) throw new HttpError(400, 'Lien invalide ou déjà utilisé.'); + if (new Date(row.expires_at) < new Date()) throw new HttpError(400, 'Ce lien a expiré. Demandez un nouvel email de vérification.'); + + db.prepare("UPDATE users SET email_verified=1, updated_at=datetime('now') WHERE id=?").run(row.uid); + db.prepare('UPDATE email_verification_tokens SET used=1 WHERE id=?').run(row.id); + + res.json({ ok: true }); + } catch (e) { next(e); } +}); + +// ── Renvoi de l'email de vérification ───────────────────────────────────── +router.post('/resend-verification', async (req, res, next) => { + try { + const { email } = z.object({ email: z.string().email() }).parse(req.body); + const user = db.prepare('SELECT id, email, display_name, email_verified FROM users WHERE email=?').get(email); + + // Répondre OK même si l'email n'existe pas + if (!user || user.email_verified) return res.json({ ok: true }); + + const cfg = getSmtpConfig(); + if (!cfg.enabled) throw new HttpError(503, "SMTP désactivé — impossible d'envoyer l'email."); + + // Invalider les anciens tokens + db.prepare('UPDATE email_verification_tokens SET used=1 WHERE user_id=? AND used=0').run(user.id); + + const vToken = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + db.prepare('INSERT INTO email_verification_tokens (user_id, token, expires_at) VALUES (?,?,?)').run(user.id, vToken, expiresAt); + + const appUrl = cfg.appUrl?.replace(/\/$/, '') || ''; + const prenom = (user.display_name || user.email).split(' ')[0]; + + await sendMail({ + to: user.email, + subject: `Vérifiez votre adresse email — ${cfg.appName}`, + html: buildEmailHtml({ + title: 'Confirmez votre adresse email', + body: `Bonjour ${prenom},
+Cliquez sur le bouton ci-dessous pour vérifier votre adresse email. Ce lien est valable 24 heures.
`, + ctaLabel: 'Vérifier mon adresse email', + ctaUrl: `${appUrl}/verify-email?token=${vToken}`, + }), + }); + + res.json({ ok: true }); + } catch (e) { next(e); } +}); + +// ── Mot de passe oublié ─────────────────────────────────────────────────── +router.post('/forgot-password', async (req, res, next) => { + try { + const { email } = z.object({ email: z.string().email() }).parse(req.body); + const user = db.prepare('SELECT id, email, display_name FROM users WHERE email = ?').get(email); + + // Toujours répondre OK pour ne pas divulguer si l'email existe + if (!user) return res.json({ ok: true }); + + const cfg = getSmtpConfig(); + if (!cfg.enabled) throw new HttpError(503, 'La réinitialisation par email n\'est pas disponible (SMTP désactivé).'); + + // Invalider les anciens tokens non utilisés + db.prepare('UPDATE password_reset_tokens SET used = 1 WHERE user_id = ? AND used = 0').run(user.id); + + // Générer un token sécurisé (1h de validité) + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + db.prepare('INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)').run(user.id, token, expiresAt); + + const appUrl = cfg.appUrl?.replace(/\/$/, '') || ''; + const resetUrl = `${appUrl}/reset-password?token=${token}`; + const prenom = (user.display_name || user.email).split(' ')[0]; + + await sendMail({ + to: user.email, + subject: `Réinitialisation de votre mot de passe — ${cfg.appName}`, + html: buildEmailHtml({ + title: 'Réinitialisation du mot de passe', + body: `Bonjour ${prenom},
+Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour en définir un nouveau. Ce lien est valable 1 heure.
+Si vous n'êtes pas à l'origine de cette demande, ignorez simplement cet email.
`, + ctaLabel: 'Réinitialiser mon mot de passe', + ctaUrl: resetUrl, + }), + }); + + res.json({ ok: true }); + } catch (e) { next(e); } +}); + +// ── Réinitialisation du mot de passe ────────────────────────────────────── +router.post('/reset-password', async (req, res, next) => { + try { + const { token, password } = z.object({ + token: z.string().min(1), + password: z.string().min(8), + }).parse(req.body); + + const row = db.prepare( + `SELECT prt.*, u.id AS uid FROM password_reset_tokens prt + JOIN users u ON u.id = prt.user_id + WHERE prt.token = ? AND prt.used = 0` + ).get(token); + + if (!row) throw new HttpError(400, 'Lien invalide ou déjà utilisé.'); + if (new Date(row.expires_at) < new Date()) throw new HttpError(400, 'Ce lien a expiré. Faites une nouvelle demande.'); + + const hash = bcrypt.hashSync(password, 10); + db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(hash, row.uid); + db.prepare('UPDATE password_reset_tokens SET used = 1 WHERE id = ?').run(row.id); + + res.json({ ok: true }); + } catch (e) { next(e); } +}); + + +// ════════════════════════════════════════════════════════════════════════════ +// ── 2FA — Configuration TOTP ───────────────────────────────────────────── +// ════════════════════════════════════════════════════════════════════════════ + +// GET /2fa/setup — génère un secret TOTP + QR code pour l'utilisateur +router.get('/2fa/setup', requireAuth, async (req, res, next) => { + try { + const { authenticator } = await import('otplib'); + const QRCode = (await import('qrcode')).default; + + const cfg = getSmtpConfig(); + const issuer = cfg.appName || 'Crowdlending Tracker'; + const user = db.prepare('SELECT id, email, totp_secret, totp_enabled FROM users WHERE id=?').get(req.user.id); + + // Générer un nouveau secret (ou réutiliser si setup pas encore confirmé) + const secret = (user.totp_enabled ? null : user.totp_secret) || authenticator.generateSecret(); + + if (!user.totp_enabled) { + db.prepare("UPDATE users SET totp_secret=? WHERE id=?").run(secret, req.user.id); + } + + const uri = authenticator.keyuri(user.email, issuer, secret); + const qrCode = await QRCode.toDataURL(uri); + + res.json({ secret, qrCode, issuer, email: user.email, totp_enabled: !!user.totp_enabled }); + } catch (e) { next(e); } +}); + +// POST /2fa/confirm-setup — vérifie le code TOTP et active le 2FA +router.post('/2fa/confirm-setup', requireAuth, async (req, res, next) => { + try { + const { code } = z.object({ code: z.string().length(6) }).parse(req.body); + const { authenticator } = await import('otplib'); + + const user = db.prepare('SELECT totp_secret, totp_enabled FROM users WHERE id=?').get(req.user.id); + if (!user.totp_secret) throw new HttpError(400, 'Lancez d\'abord la configuration 2FA.'); + if (user.totp_enabled) throw new HttpError(400, 'Le 2FA est déjà activé.'); + + authenticator.options = { window: 1 }; + const valid = authenticator.verify({ token: code, secret: user.totp_secret }); + if (!valid) throw new HttpError(400, 'Code invalide. Réessayez.'); + + db.prepare("UPDATE users SET totp_enabled=1 WHERE id=?").run(req.user.id); + res.json({ ok: true }); + } catch (e) { next(e); } +}); + +// POST /2fa/disable — désactive le 2FA (mot de passe requis) +router.post('/2fa/disable', requireAuth, async (req, res, next) => { + try { + const { password } = z.object({ password: z.string().min(1) }).parse(req.body); + + const user = db.prepare('SELECT password_hash, totp_enabled FROM users WHERE id=?').get(req.user.id); + if (!user.totp_enabled) throw new HttpError(400, 'Le 2FA n\'est pas activé.'); + + const ok = bcrypt.compareSync(password, user.password_hash); + if (!ok) throw new HttpError(401, 'Mot de passe incorrect.'); + + db.prepare("UPDATE users SET totp_enabled=0, totp_secret=NULL WHERE id=?").run(req.user.id); + // Supprimer tous les appareils de confiance + db.prepare('DELETE FROM two_fa_trusted_devices WHERE user_id=?').run(req.user.id); + + res.json({ ok: true }); + } catch (e) { next(e); } +}); + +// ════════════════════════════════════════════════════════════════════════════ +// ── 2FA — Vérification lors de la connexion ────────────────────────────── +// ════════════════════════════════════════════════════════════════════════════ + +// POST /2fa/send-email-code — envoie un OTP par email (flow 2FA) +router.post('/2fa/send-email-code', async (req, res, next) => { + try { + const { sessionToken } = z.object({ sessionToken: z.string().min(1) }).parse(req.body); + + const sess = db.prepare( + "SELECT * FROM two_fa_sessions WHERE token=? AND used=0 AND expires_at > datetime('now')" + ).get(sessionToken); + if (!sess) throw new HttpError(401, 'Session expirée. Reconnectez-vous.'); + + const user = db.prepare('SELECT id, email, display_name FROM users WHERE id=?').get(sess.user_id); + + // Invalider les anciens codes + db.prepare('UPDATE two_fa_email_codes SET used=1 WHERE user_id=? AND used=0').run(user.id); + + const code = String(Math.floor(100000 + Math.random() * 900000)); + const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + db.prepare('INSERT INTO two_fa_email_codes (user_id, code, expires_at) VALUES (?,?,?)').run(user.id, code, expiresAt); + + const cfg = getSmtpConfig(); + if (!cfg.enabled) throw new HttpError(503, "SMTP désactivé — impossible d'envoyer le code."); + + const prenom = (user.display_name || user.email).split(' ')[0]; + await sendMail({ + to: user.email, + subject: `${code} — Votre code de connexion`, + html: buildEmailHtml({ + title: 'Code de vérification', + body: `Bonjour ${prenom},
+Voici votre code de vérification à usage unique :
+${code}
+Ce code est valable 5 minutes. Ne le communiquez à personne.
`, + }), + }); + + res.json({ ok: true }); + } catch (e) { next(e); } +}); + +// POST /2fa/verify — vérifie le code (TOTP ou email) et émet le JWT final +router.post('/2fa/verify', async (req, res, next) => { + try { + const { sessionToken, code, method, trustDevice } = z.object({ + sessionToken: z.string().min(1), + code: z.string().min(6).max(6), + method: z.enum(['totp', 'email']), + trustDevice: z.boolean().optional(), + }).parse(req.body); + + const sess = db.prepare( + "SELECT * FROM two_fa_sessions WHERE token=? AND used=0 AND expires_at > datetime('now')" + ).get(sessionToken); + if (!sess) throw new HttpError(401, 'Session expirée. Reconnectez-vous.'); + + const user = db.prepare('SELECT id, email, display_name, role, totp_secret, totp_enabled FROM users WHERE id=?').get(sess.user_id); + + if (method === 'totp') { + const { authenticator } = await import('otplib'); + authenticator.options = { window: 1 }; + const valid = authenticator.verify({ token: code, secret: user.totp_secret }); + if (!valid) throw new HttpError(400, 'Code invalide.'); + } else { + // Email OTP + const row = db.prepare( + "SELECT id FROM two_fa_email_codes WHERE user_id=? AND code=? AND used=0 AND expires_at > datetime('now')" + ).get(user.id, code); + if (!row) throw new HttpError(400, 'Code invalide ou expiré.'); + db.prepare('UPDATE two_fa_email_codes SET used=1 WHERE id=?').run(row.id); + } + + // Invalider la session 2FA + db.prepare('UPDATE two_fa_sessions SET used=1 WHERE id=?').run(sess.id); + + // Appareil de confiance (30 jours) + let deviceToken = null; + if (trustDevice) { + deviceToken = crypto.randomBytes(32).toString('hex'); + const devExpires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); + db.prepare('INSERT INTO two_fa_trusted_devices (user_id, token, expires_at) VALUES (?,?,?)').run(user.id, deviceToken, devExpires); + } + + const token = signToken({ sub: user.id, email: user.email }); + res.json({ + token, + user: { id: user.id, email: user.email, displayName: user.display_name, role: user.role, email_verified: 1, totp_enabled: 1 }, + ...(deviceToken ? { deviceToken } : {}), }); } catch (e) { next(e); } }); diff --git a/backend/src/server.js b/backend/src/server.js index 7e2875f..4fa3e0c 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -41,6 +41,8 @@ import refSecteursRouter from './routes/ref-secteurs.js'; import categoriesInvRouter from './routes/categories-inv.js'; import secteursInvRouter from './routes/secteurs-inv.js'; import associationsInvRouter from './routes/associations-inv.js'; +import db from './db/index.js'; +import { getSmtpConfig } from './utils/mailer.js'; const app = express(); const PORT = process.env.PORT || 4000; @@ -69,6 +71,20 @@ const authLimiter = rateLimit({ app.get('/api/health', (_, res) => res.json({ ok: true, ts: new Date().toISOString() })); +// Informations publiques de la plateforme (utilisées sur la page de login) +app.get('/api/app-info', (_, res) => { + try { + const cfg = getSmtpConfig(); + const icon = db.prepare(`SELECT filename FROM app_icons WHERE name = 'logo-app' LIMIT 1`).get(); + res.json({ + appName: cfg.appName || 'Crowdlending Tracker', + iconUrl: icon ? `/api/icons-files/${icon.filename}` : null, + }); + } catch { + res.json({ appName: 'Crowdlending Tracker', iconUrl: null }); + } +}); + app.use('/api/auth', authLimiter, authRouter); // All routes below require authentication diff --git a/backend/src/utils/mailer.js b/backend/src/utils/mailer.js index 6e0a5bc..a741f84 100644 --- a/backend/src/utils/mailer.js +++ b/backend/src/utils/mailer.js @@ -25,7 +25,7 @@ export function getSmtpConfig() { username: row?.username || process.env.SMTP_USERNAME || '', password: row?.password || process.env.SMTP_PASSWORD || '', allowUnauth: !!(row?.allow_unauth), - appName: row?.app_name || process.env.APP_NAME || 'Crowdlending', + appName: row?.app_name || process.env.APP_NAME || 'Crowdlending Tracker', appUrl: row?.app_url || process.env.APP_URL || '', }; } diff --git a/frontend/public/login-bg.jpg b/frontend/public/login-bg.jpg new file mode 100644 index 0000000..67fb441 Binary files /dev/null and b/frontend/public/login-bg.jpg differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 81a726b..a783421 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,6 +2,9 @@ import { Routes, Route, Navigate } from 'react-router-dom'; import { useAuth } from './context/AuthContext.jsx'; import Login from './pages/Login.jsx'; import Register from './pages/Register.jsx'; +import ForgotPassword from './pages/ForgotPassword.jsx'; +import ResetPassword from './pages/ResetPassword.jsx'; +import VerifyEmail from './pages/VerifyEmail.jsx'; import Layout from './components/Layout.jsx'; import Dashboard from './pages/Dashboard.jsx'; import DepotsRetraits from './pages/DepotsRetraits.jsx'; @@ -36,8 +39,11 @@ function AdminOnly({ children }) { export default function App() { return (+ Si un compte correspond à {email}, vous recevrez + un lien de réinitialisation valable 1 heure. +
+ + Retour à la connexion + ++ Saisissez votre email pour recevoir un lien de réinitialisation. +
++ Vous vous souvenez ?{' '} + + Se connecter + +
+ > + )} + +Connectez-vous à votre compte
++ Pas encore de compte ?{' '} + Créer un compte +
+ > + )} + + {/* ══ ÉTAPE 2 : Choix de méthode 2FA ══════════════════════════ */} + {step === 'method' && ( + <> +Choisissez votre méthode de vérification
++ {method === 'totp' + ? 'Entrez le code à 6 chiffres affiché dans votre application d\'authentification.' + : <>Code envoyé à {email}.> + } +
+✓ Email de vérification renvoyé.
; + return ( ++ Email non confirmé.{' '} + +
+ ); +} + +/* ── Changement d'email ───────────────────────────────────────── */ +function EmailChangeForm({ onDone }) { + const { updateUser } = useAuth(); + const [newEmail, setNewEmail] = useState(''); + const [pwd, setPwd] = useState(''); + const [msg, setMsg] = useState(null); + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(false); + + const submit = async (e) => { + e.preventDefault(); + setErr(null); setMsg(null); setBusy(true); + try { + const r = await updateUser({ email: newEmail, currentPassword: pwd }); + if (r?.requiresVerification) { + setMsg(`Un email de vérification a été envoyé à ${newEmail}. Validez-le pour activer cette adresse.`); + } else { + setMsg('Email mis à jour.'); setTimeout(onDone, 1500); + } + } catch (e) { setErr(e.message); } + finally { setBusy(false); } + }; + + return ( + + ); +} + /* ── Mon profil + Préférences ────────────────────────────────── */ function AccountForm() { const { user, updateUser } = useAuth(); @@ -92,9 +170,10 @@ function AccountForm() { const initial = parseName(user?.display_name); const [prenom, setPrenom] = useState(initial.prenom); const [nom, setNom] = useState(initial.nom); - const [infoMsg, setInfoMsg] = useState(null); - const [infoErr, setInfoErr] = useState(null); - const [loading, setLoading] = useState(false); + const [infoMsg, setInfoMsg] = useState(null); + const [infoErr, setInfoErr] = useState(null); + const [loading, setLoading] = useState(false); + const [changingEmail, setChangingEmail] = useState(false); const save = async () => { setInfoErr(null); setInfoMsg(null); setLoading(true); @@ -138,16 +217,23 @@ function AccountForm() {+ Protégez votre compte avec une vérification supplémentaire à chaque connexion. +
+ + {err &&+ Scannez ce QR code avec votre application d'authentification (Google Authenticator, Authy, etc.), puis entrez le code à 6 chiffres pour confirmer. +
++ Vous ne pouvez pas scanner ? Entrez manuellement cette clé dans votre application : +
+
+ {setupData.secret}
+
+ + Émetteur : {setupData.issuer} · Compte : {setupData.email} +
++ Un email de vérification a été envoyé à +
++ {verifyEmail} +
++ Cliquez sur le lien dans l'email pour activer votre compte. Vérifiez aussi vos spams. +
+ + Retour à la connexion + ++ Rejoignez {appInfo.appName} +
++ Déjà inscrit ?{' '} + + Se connecter + +
+ + >)} {/* fin verifyEmail ternaire */} ++ Lien de réinitialisation invalide ou manquant. +
+ + Faire une nouvelle demande + ++ Redirection vers la connexion dans quelques secondes… +
+ + Se connecter + ++ Choisissez un mot de passe d'au moins 8 caractères. +
++ + Retour à la connexion + +
+ > + )} + +Vérification en cours…
+ )} + + {status === 'ok' && ( + <> ++ Votre adresse email a bien été confirmée. Vous pouvez maintenant vous connecter. +
+ + Se connecter + + > + )} + + {status === 'error' && ( + <> ++ {errMsg} +
+ + Retour à la connexion + + > + )} + +