From fbee337a3e233cfd907567a1e7533425ebd1fb53 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Sat, 18 Jul 2026 03:12:40 +0200 Subject: [PATCH 01/10] Add TOTP two-factor authentication with recovery codes --- config/migrate.js | 3 + package-lock.json | 402 +++++++++++++++++++++++++++++++++++++++++- package.json | 2 + public/js/app.js | 434 ++++++++++++++++++++++++++++++++++++++++++++++ routes/auth.js | 15 ++ routes/totp.js | 361 ++++++++++++++++++++++++++++++++++++++ server.js | 4 +- 7 files changed, 1218 insertions(+), 3 deletions(-) create mode 100644 routes/totp.js diff --git a/config/migrate.js b/config/migrate.js index 58ad11e..8189eb7 100644 --- a/config/migrate.js +++ b/config/migrate.js @@ -27,6 +27,9 @@ const tables = { { name: 'email_change_expires', def: 'DATETIME DEFAULT NULL' }, { name: 'user_agent', def: 'VARCHAR(512) DEFAULT NULL' }, { name: 'onboarding_done', def: 'TINYINT(1) NOT NULL DEFAULT 0' }, + { name: 'totp_secret', def: 'VARCHAR(255) DEFAULT NULL' }, + { name: 'totp_enabled', def: 'TINYINT(1) NOT NULL DEFAULT 0' }, + { name: 'recovery_codes', def: 'TEXT DEFAULT NULL' }, { name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' }, ], }, diff --git a/package-lock.json b/package-lock.json index d19dc06..a3ae033 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zerohost-dashboard", - "version": "1.0.4", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zerohost-dashboard", - "version": "1.0.4", + "version": "1.1.1", "dependencies": { "@simplewebauthn/server": "^13.3.2", "argon2": "^0.41.1", @@ -18,6 +18,8 @@ "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", "mariadb": "^3.4.0", + "otplib": "^13.4.1", + "qrcode": "^1.5.4", "resend": "^6.17.1" } }, @@ -33,6 +35,74 @@ "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", "license": "MIT" }, + "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/@peculiar/asn1-android": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz", @@ -210,6 +280,15 @@ "node": ">=10" } }, + "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/@simplewebauthn/server": { "version": "13.3.2", "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz", @@ -263,6 +342,30 @@ "node": ">= 0.6" } }, + "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/argon2": { "version": "0.41.1", "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.41.1.tgz", @@ -366,6 +469,44 @@ "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/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/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/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -441,6 +582,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/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -469,6 +619,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "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", @@ -510,6 +666,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", @@ -649,6 +811,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", @@ -676,6 +851,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", @@ -808,6 +992,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/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -857,6 +1050,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", @@ -1077,6 +1282,56 @@ "node": ">= 0.8" } }, + "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", @@ -1086,12 +1341,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/postal-mime": { "version": "2.7.4", "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", @@ -1129,6 +1402,23 @@ "node": ">=16.0.0" } }, + "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", @@ -1174,6 +1464,21 @@ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "license": "Apache-2.0" }, + "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/resend": { "version": "6.17.1", "resolved": "https://registry.npmjs.org/resend/-/resend-6.17.1.tgz", @@ -1278,6 +1583,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", @@ -1375,6 +1686,32 @@ "node": ">= 0.8" } }, + "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/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1453,6 +1790,67 @@ "engines": { "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/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/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" + } } } } diff --git a/package.json b/package.json index 8409027..0c8ef1f 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", "mariadb": "^3.4.0", + "otplib": "^13.4.1", + "qrcode": "^1.5.4", "resend": "^6.17.1" } } diff --git a/public/js/app.js b/public/js/app.js index 201e86e..f57bf14 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -20,6 +20,7 @@ const state = { accountTab: 'info', sidebarServersOpen: false, sidebarServersLoading: false, + totpTempToken: null, }; function setRgpdConsent(preferences) { @@ -660,6 +661,51 @@ function renderLoginPage() { + + @@ -698,6 +744,25 @@ function renderLoginPage() { navigateTo('signup'); }); + $('#login-totp-form-inner').addEventListener('submit', handleTotpVerify); + $('#login-totp-recovery-btn').addEventListener('click', () => { + $('#login-totp-initial').style.display = 'none'; + $('#login-totp-recovery-form').style.display = 'block'; + }); + $('#login-totp-back-btn').addEventListener('click', () => { + $('#login-totp-recovery-form').style.display = 'none'; + $('#login-totp-initial').style.display = 'block'; + }); + $('#login-totp-recovery-inner').addEventListener('submit', handleTotpRecovery); + $('#login-totp-cancel-btn').addEventListener('click', () => { + state.totpTempToken = null; + $('#login-totp-form').style.display = 'none'; + $('#login-choices').style.display = 'block'; + const dc = $('#discord-login-card'); + if (dc) dc.style.display = ''; + initIcons(); + }); + initIcons(); setTimeout(initIcons, 100); } @@ -907,6 +972,19 @@ async function handleLogin(e) { capToken, }), }); + + if (data.needsTotp) { + state.totpTempToken = data.tempToken; + $('#login-email-form').style.display = 'none'; + $('#login-totp-form').style.display = 'block'; + $('#login-totp-code').value = ''; + $('#login-totp-code').focus(); + btn.disabled = false; + btn.innerHTML = 'Sign In'; + initIcons(); + return; + } + state.token = data.token; state.user = data.user; localStorage.setItem('zh_token', data.token); @@ -925,6 +1003,71 @@ async function handleLogin(e) { } } +async function handleTotpVerify(e) { + e.preventDefault(); + const form = $('#login-totp-form-inner'); + hideError(form); + const btn = $('#login-totp-btn'); + btn.disabled = true; + btn.innerHTML = ' Verifying...'; + + try { + const data = await api('/auth/totp/verify', { + method: 'POST', + body: JSON.stringify({ + code: $('#login-totp-code').value.trim(), + tempToken: state.totpTempToken, + }), + }); + state.totpTempToken = null; + state.token = data.token; + state.user = data.user; + localStorage.setItem('zh_token', data.token); + localStorage.setItem('zh_user', JSON.stringify(data.user)); + history.replaceState({ page: 'overview' }, '', '/'); + renderDashboard(); + } catch (err) { + showError(form, err.message); + $('#login-totp-code').value = ''; + $('#login-totp-code').focus(); + } finally { + btn.disabled = false; + btn.innerHTML = 'Verify'; + } +} + +async function handleTotpRecovery(e) { + e.preventDefault(); + const form = $('#login-totp-recovery-inner'); + hideError(form); + const btn = $('#login-recovery-btn'); + btn.disabled = true; + btn.innerHTML = ' Verifying...'; + + try { + const data = await api('/auth/totp/recovery', { + method: 'POST', + body: JSON.stringify({ + code: $('#login-recovery-code').value.trim(), + tempToken: state.totpTempToken, + }), + }); + state.totpTempToken = null; + state.token = data.token; + state.user = data.user; + localStorage.setItem('zh_token', data.token); + localStorage.setItem('zh_user', JSON.stringify(data.user)); + history.replaceState({ page: 'overview' }, '', '/'); + renderDashboard(); + } catch (err) { + showError(form, err.message); + $('#login-recovery-code').value = ''; + } finally { + btn.disabled = false; + btn.innerHTML = 'Verify Recovery Code'; + } +} + async function completePasskeyLogin(credential) { if (await checkVpn()) { await showVpnBlockModal(); @@ -2275,6 +2418,11 @@ let activityIcons = { passkey_registered: '', email_verified: '', passkey_deleted: '', + totp_enabled: '', + totp_disabled: '', + login_totp: '', + recovery_code_used: '', + recovery_codes_regenerated: '', }; function formatRelativeTime(dateStr) { @@ -2309,6 +2457,11 @@ function getActionLabel(action) { passkey_registered: 'Passkey registered', email_verified: 'Email verified', passkey_deleted: 'Passkey deleted', + totp_enabled: 'Two-factor authentication enabled', + totp_disabled: 'Two-factor authentication disabled', + login_totp: 'Signed in with 2FA code', + recovery_code_used: 'Used recovery code to sign in', + recovery_codes_regenerated: 'Recovery codes regenerated', }; return labels[action] || action; } @@ -3247,11 +3400,22 @@ function renderAccountSecurityTab() {
+ +
+

+ + Two-Factor Authentication +

+
+

Loading...

+
+
`; $('#change-password-form').addEventListener('submit', handleChangePassword); $('#register-passkey-btn').addEventListener('click', handleRegisterPasskey); loadPasskeys(); + loadTotpStatus(); } function renderAccountLogsTab() { @@ -3514,6 +3678,276 @@ async function handleDeletePasskey(id) { }); } +async function loadTotpStatus() { + const section = $('#totp-section-content'); + if (!section) return; + try { + const data = await api('/auth/totp/status'); + if (data.enabled) { + renderTotpEnabled(section); + } else { + renderTotpDisabled(section); + } + } catch (err) { + section.innerHTML = `

Failed to load: ${escapeHtml(err.message)}

`; + } +} + +function renderTotpDisabled(section) { + section.innerHTML = html` +

+ Add an extra layer of security to your account by enabling two-factor authentication. + You'll need to enter a verification code from your authenticator app when signing in. +

+ +
+ `; + $('#enable-totp-btn').addEventListener('click', handleTotpSetup); + initIcons(); +} + +async function renderTotpEnabled(section) { + let remaining = 0; + try { + const rcData = await api('/auth/totp/recovery-codes'); + remaining = rcData.remaining; + } catch {} + + section.innerHTML = html` +
+ + Two-factor authentication is enabled +
+
+
+ Recovery codes remaining: + ${remaining} +
+

+ Use a recovery code if you lose access to your authenticator app. +

+
+
+ + +
+ `; + $('#regenerate-codes-btn').addEventListener('click', handleRegenerateRecoveryCodes); + $('#disable-totp-btn').addEventListener('click', handleDisableTotp); + initIcons(); +} + +async function handleTotpSetup() { + const btn = $('#enable-totp-btn'); + const area = $('#totp-setup-area'); + if (!btn || !area) return; + btn.disabled = true; + btn.innerHTML = ''; + + try { + const data = await api('/auth/totp/setup', { method: 'POST' }); + area.innerHTML = html` +
+

+ Scan this QR code with your authenticator app (e.g. Google Authenticator, Authy, 2FAS). +

+
+ TOTP QR Code +
+

+ Or manually enter this key: ${data.secret} +

+
+
+ + +
+ + +
+
+
+ `; + btn.style.display = 'none'; + $('#totp-enable-form').addEventListener('submit', handleTotpEnable); + $('#totp-setup-cancel-btn').addEventListener('click', () => { + area.innerHTML = ''; + btn.disabled = false; + btn.innerHTML = ' Enable Two-Factor Authentication'; + btn.style.display = ''; + initIcons(); + }); + initIcons(); + } catch (err) { + btn.disabled = false; + btn.innerHTML = ' Enable Two-Factor Authentication'; + area.innerHTML = `

${escapeHtml(err.message)}

`; + } +} + +async function handleTotpEnable(e) { + e.preventDefault(); + const btn = $('#totp-enable-btn'); + const status = $('#totp-enable-status'); + btn.disabled = true; + btn.innerHTML = ''; + status.textContent = ''; + + try { + const data = await api('/auth/totp/enable', { + method: 'POST', + body: JSON.stringify({ code: $('#totp-verify-code').value.trim() }), + }); + + showRecoveryCodesModal(data.recoveryCodes); + loadTotpStatus(); + } catch (err) { + status.textContent = err.message; + status.style.color = 'var(--accent-red)'; + $('#totp-verify-code').value = ''; + $('#totp-verify-code').focus(); + } finally { + btn.disabled = false; + btn.innerHTML = 'Verify and Enable'; + } +} + +function showRecoveryCodesModal(codes) { + const overlay = $('#modal-overlay'); + const content = $('#modal-content'); + content.innerHTML = html` + +
+ Save these codes in a secure place. You will not be able to view them again. +
+

+ Each code can be used once to sign in if you lose access to your authenticator app. +

+
+ ${codes.map(c => `
${c}
`).join('')} +
+ + `; + overlay.classList.add('open'); + + $('#recovery-codes-ok-btn').addEventListener('click', () => { + overlay.classList.remove('open'); + }); +} + +async function handleDisableTotp() { + const overlay = $('#modal-overlay'); + const content = $('#modal-content'); + content.innerHTML = html` + +

+ Enter your password to disable two-factor authentication. +

+
+ + +
+ + + `; + overlay.classList.add('open'); + + const confirmBtn = $('#confirm-disable-totp-btn'); + confirmBtn.addEventListener('click', async () => { + const password = $('#modal-disable-totp-pw').value; + if (!password) { + $('#modal-disable-totp-status').textContent = 'Password is required'; + return; + } + confirmBtn.disabled = true; + confirmBtn.innerHTML = ''; + try { + await api('/auth/totp/disable', { + method: 'POST', + body: JSON.stringify({ password }), + }); + overlay.classList.remove('open'); + showToast('Two-factor authentication disabled', 'info'); + loadTotpStatus(); + } catch (err) { + $('#modal-disable-totp-status').textContent = err.message; + } finally { + confirmBtn.disabled = false; + confirmBtn.innerHTML = 'Disable 2FA'; + } + }); +} + +async function handleRegenerateRecoveryCodes() { + const overlay = $('#modal-overlay'); + const content = $('#modal-content'); + content.innerHTML = html` + +

+ Your existing recovery codes will be invalidated and new ones will be generated. +

+
+ + `; + overlay.classList.add('open'); + + $('#confirm-regenerate-codes-btn').addEventListener('click', async () => { + const btn = $('#confirm-regenerate-codes-btn'); + btn.disabled = true; + btn.innerHTML = ''; + try { + const data = await api('/auth/totp/recovery-codes/regenerate', { method: 'POST' }); + $('#regenerate-codes-actions').style.display = 'none'; + $('#regenerate-codes-result').innerHTML = html` +
+ Save these codes in a secure place. You will not be able to view them again. +
+
+ ${data.recoveryCodes.map(c => `
${c}
`).join('')} +
+ + `; + $('#recovery-codes-done-btn').addEventListener('click', () => { + overlay.classList.remove('open'); + loadTotpStatus(); + }); + initIcons(); + } catch (err) { + $('#regenerate-codes-result').innerHTML = `

${escapeHtml(err.message)}

`; + } + }); +} + function handleModifyApiKey() { const overlay = $('#modal-overlay'); const content = $('#modal-content'); diff --git a/routes/auth.js b/routes/auth.js index dc01d71..fee97ae 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -492,6 +492,21 @@ router.post('/login', async (req, res) => { return res.status(403).json({ error: 'Please verify your email before signing in. Check your inbox for the verification link.' }); } + if (user.totp_enabled) { + const tempToken = jwt.sign( + { userId: user.id, totpTemp: true }, + process.env.JWT_SECRET, + { expiresIn: '5m', algorithm: 'HS256' } + ); + + recordLoginAttempt(ip, true); + + return res.json({ + needsTotp: true, + tempToken, + }); + } + const token = generateToken({ userId: user.id, email: user.email, diff --git a/routes/totp.js b/routes/totp.js new file mode 100644 index 0000000..cadcd92 --- /dev/null +++ b/routes/totp.js @@ -0,0 +1,361 @@ +import { Router } from 'express'; +import { TOTP, NobleCryptoPlugin, ScureBase32Plugin, generateSecret, verify, generateURI } from 'otplib'; +import QRCode from 'qrcode'; +import jwt from 'jsonwebtoken'; +import { createHash, randomBytes } from 'crypto'; +import argon2 from 'argon2'; + +import { query } from '../config/db.js'; +import { authenticateToken, generateToken } from '../middleware/auth.js'; +import { logActivity } from '../services/activity.js'; + +const totp = new TOTP({ + crypto: new NobleCryptoPlugin(), + base32: new ScureBase32Plugin(), +}); + +const router = Router(); +const JWT_SECRET = process.env.JWT_SECRET; + +function generateRecoveryCodes(count = 8) { + const codes = []; + for (let i = 0; i < count; i++) { + const bytes = randomBytes(4).toString('hex').toUpperCase(); + const code = bytes.match(/.{4}/g).join('-'); + codes.push(code); + } + return codes; +} + +function hashRecoveryCode(code) { + return createHash('sha256').update(code).digest('hex'); +} + +function gravatarHash(email) { + return createHash('md5').update(email.trim().toLowerCase()).digest('hex'); +} + +router.get('/totp/status', authenticateToken, async (req, res) => { + try { + const users = await query('SELECT totp_enabled FROM users WHERE id = ?', [req.user.userId]); + if (users.length === 0) return res.status(404).json({ error: 'User not found' }); + res.json({ enabled: !!users[0].totp_enabled }); + } catch (err) { + console.error('TOTP status error:', err.message); + res.status(500).json({ error: 'Failed to check status' }); + } +}); + +router.post('/totp/setup', authenticateToken, async (req, res) => { + try { + const userId = req.user.userId; + const users = await query('SELECT id, email, totp_secret, totp_enabled FROM users WHERE id = ?', [userId]); + if (users.length === 0) return res.status(404).json({ error: 'User not found' }); + if (users[0].totp_enabled) { + return res.status(400).json({ error: 'Two-factor authentication is already enabled' }); + } + + const secret = generateSecret(); + const otpauth = generateURI({issuer: 'ZeroHost', label: req.user.email, secret}); + const qrCode = await QRCode.toDataURL(otpauth); + + await query('UPDATE users SET totp_secret = ? WHERE id = ?', [secret, userId]); + + res.json({ secret, qrCode }); + } catch (err) { + console.error('TOTP setup error:', err.message); + res.status(500).json({ error: 'Failed to setup two-factor authentication' }); + } +}); + +router.post('/totp/enable', authenticateToken, async (req, res) => { + try { + const { code } = req.body; + const userId = req.user.userId; + + if (!code) { + return res.status(400).json({ error: 'Verification code is required' }); + } + + const users = await query('SELECT id, totp_secret, totp_enabled FROM users WHERE id = ?', [userId]); + if (users.length === 0) return res.status(404).json({ error: 'User not found' }); + if (users[0].totp_enabled) { + return res.status(400).json({ error: 'Two-factor authentication is already enabled' }); + } + if (!users[0].totp_secret) { + return res.status(400).json({ error: 'Please start the setup first' }); + } + + const verifyResult = await verify({ token: code, secret: users[0].totp_secret }); + if (!verifyResult.valid) { + return res.status(400).json({ error: 'Invalid verification code. Please try again.' }); + } + + const recoveryCodes = generateRecoveryCodes(8); + const hashedCodes = recoveryCodes.map(hashRecoveryCode); + + await query( + 'UPDATE users SET totp_enabled = 1, recovery_codes = ? WHERE id = ?', + [JSON.stringify(hashedCodes), userId] + ); + + await logActivity(userId, 'totp_enabled', 'Enabled two-factor authentication'); + + res.json({ message: 'Two-factor authentication enabled successfully', recoveryCodes }); + } catch (err) { + console.error('TOTP enable error:', err.message); + res.status(500).json({ error: 'Failed to enable two-factor authentication' }); + } +}); + +router.post('/totp/disable', authenticateToken, async (req, res) => { + try { + const { password } = req.body; + const userId = req.user.userId; + + if (!password) { + return res.status(400).json({ error: 'Password is required' }); + } + + const users = await query('SELECT id, password_hash FROM users WHERE id = ?', [userId]); + if (users.length === 0) return res.status(404).json({ error: 'User not found' }); + + const valid = await argon2.verify(users[0].password_hash, password, { type: argon2.argon2id }); + if (!valid) { + return res.status(401).json({ error: 'Password is incorrect' }); + } + + await query( + 'UPDATE users SET totp_secret = NULL, totp_enabled = 0, recovery_codes = NULL WHERE id = ?', + [userId] + ); + + await logActivity(userId, 'totp_disabled', 'Disabled two-factor authentication'); + + res.json({ message: 'Two-factor authentication disabled successfully' }); + } catch (err) { + console.error('TOTP disable error:', err.message); + res.status(500).json({ error: 'Failed to disable two-factor authentication' }); + } +}); + +router.post('/totp/verify', async (req, res) => { + try { + const { code, tempToken } = req.body; + + if (!code || !tempToken) { + return res.status(400).json({ error: 'Code and temporary token are required' }); + } + + let decoded; + try { + decoded = jwt.verify(tempToken, JWT_SECRET); + } catch { + return res.status(401).json({ error: 'Session expired. Please log in again.' }); + } + + if (!decoded.totpTemp) { + return res.status(401).json({ error: 'Invalid session' }); + } + + const userId = decoded.userId; + + const users = await query( + `SELECT id, email, username, totp_secret, totp_enabled, recovery_codes, + is_admin, restricted, token_version, ptero_user_id, + first_name, last_name, email_verified, auth_restricted + FROM users WHERE id = ?`, + [userId] + ); + if (users.length === 0) return res.status(404).json({ error: 'User not found' }); + + const user = users[0]; + if (!user.totp_enabled) { + return res.status(400).json({ error: 'Two-factor authentication is not enabled' }); + } + if (user.auth_restricted) { + return res.status(403).json({ error: 'Your account has been restricted.' }); + } + + const verifyResult = await verify({ token: code, secret: user.totp_secret }); + if (!verifyResult.valid) { + return res.status(401).json({ error: 'Invalid verification code' }); + } + + const token = generateToken({ + userId: user.id, + email: user.email, + username: user.username, + pteroId: user.ptero_user_id, + isAdmin: !!user.is_admin, + restricted: !!user.restricted, + tokenVersion: user.token_version, + }); + + res.cookie('token', token, { + httpOnly: true, + secure: true, + sameSite: 'strict', + maxAge: 2 * 60 * 60 * 1000, + }); + + await query('UPDATE users SET user_agent = ? WHERE id = ?', [ + (req.headers['user-agent'] || 'unknown').toString().slice(0, 512), + user.id + ]).catch(() => {}); + + await logActivity(userId, 'login_totp', 'Signed in with two-factor authentication'); + + res.json({ + token, + user: { + id: user.id, + email: user.email, + username: user.username, + pteroId: user.ptero_user_id, + firstName: user.first_name, + lastName: user.last_name, + isAdmin: !!user.is_admin, + restricted: !!user.restricted, + emailVerified: !!user.email_verified, + gravatarHash: gravatarHash(user.email), + }, + }); + } catch (err) { + console.error('TOTP verify error:', err.message); + res.status(500).json({ error: 'Verification failed' }); + } +}); + +router.post('/totp/recovery', async (req, res) => { + try { + const { code, tempToken } = req.body; + + if (!code || !tempToken) { + return res.status(400).json({ error: 'Recovery code and temporary token are required' }); + } + + let decoded; + try { + decoded = jwt.verify(tempToken, JWT_SECRET); + } catch { + return res.status(401).json({ error: 'Session expired. Please log in again.' }); + } + + if (!decoded.totpTemp) { + return res.status(401).json({ error: 'Invalid session' }); + } + + const userId = decoded.userId; + const users = await query( + `SELECT * FROM users WHERE id = ?`, + [userId] + ); + if (users.length === 0) return res.status(404).json({ error: 'User not found' }); + + const user = users[0]; + if (!user.totp_enabled || !user.recovery_codes) { + return res.status(400).json({ error: 'No recovery codes available' }); + } + + const storedCodes = JSON.parse(user.recovery_codes); + const hashedInput = hashRecoveryCode(code.toUpperCase()); + + const codeIndex = storedCodes.findIndex(h => h === hashedInput); + if (codeIndex === -1) { + return res.status(401).json({ error: 'Invalid recovery code' }); + } + + storedCodes.splice(codeIndex, 1); + await query('UPDATE users SET recovery_codes = ? WHERE id = ?', [JSON.stringify(storedCodes), userId]); + + await logActivity(userId, 'recovery_code_used', 'Used a recovery code to sign in'); + + const jwtToken = generateToken({ + userId: user.id, + email: user.email, + username: user.username, + pteroId: user.ptero_user_id, + isAdmin: !!user.is_admin, + restricted: !!user.restricted, + tokenVersion: user.token_version, + }); + + res.cookie('token', jwtToken, { + httpOnly: true, + secure: true, + sameSite: 'strict', + maxAge: 2 * 60 * 60 * 1000, + }); + + await query('UPDATE users SET user_agent = ? WHERE id = ?', [ + (req.headers['user-agent'] || 'unknown').toString().slice(0, 512), + user.id + ]).catch(() => {}); + + res.json({ + token: jwtToken, + user: { + id: user.id, + email: user.email, + username: user.username, + pteroId: user.ptero_user_id, + firstName: user.first_name, + lastName: user.last_name, + isAdmin: !!user.is_admin, + restricted: !!user.restricted, + emailVerified: !!user.email_verified, + gravatarHash: gravatarHash(user.email), + }, + }); + } catch (err) { + console.error('TOTP recovery error:', err.message); + res.status(500).json({ error: 'Recovery code verification failed' }); + } +}); + +router.get('/totp/recovery-codes', authenticateToken, async (req, res) => { + try { + const users = await query('SELECT totp_enabled, recovery_codes FROM users WHERE id = ?', [req.user.userId]); + if (users.length === 0) return res.status(404).json({ error: 'User not found' }); + + const user = users[0]; + if (!user.totp_enabled) { + return res.json({ enabled: false, remaining: 0, codes: [] }); + } + + const codes = user.recovery_codes ? JSON.parse(user.recovery_codes) : []; + res.json({ + enabled: true, + remaining: codes.length, + }); + } catch (err) { + console.error('TOTP recovery codes error:', err.message); + res.status(500).json({ error: 'Failed to fetch recovery codes' }); + } +}); + +router.post('/totp/recovery-codes/regenerate', authenticateToken, async (req, res) => { + try { + const userId = req.user.userId; + const users = await query('SELECT totp_enabled FROM users WHERE id = ?', [userId]); + if (users.length === 0) return res.status(404).json({ error: 'User not found' }); + if (!users[0].totp_enabled) { + return res.status(400).json({ error: 'Two-factor authentication is not enabled' }); + } + + const newCodes = generateRecoveryCodes(8); + const hashedCodes = newCodes.map(hashRecoveryCode); + + await query('UPDATE users SET recovery_codes = ? WHERE id = ?', [JSON.stringify(hashedCodes), userId]); + + await logActivity(userId, 'recovery_codes_regenerated', 'Regenerated recovery codes'); + + res.json({ recoveryCodes: newCodes }); + } catch (err) { + console.error('TOTP regenerate recovery codes error:', err.message); + res.status(500).json({ error: 'Failed to regenerate recovery codes' }); + } +}); + +export default router; diff --git a/server.js b/server.js index da7b575..e769dad 100644 --- a/server.js +++ b/server.js @@ -28,6 +28,7 @@ import crypto from 'crypto'; import authRoutes from './routes/auth.js'; import passkeyRoutes from './routes/passkeys.js'; +import totpRoutes from './routes/totp.js'; import serverRoutes from './routes/servers.js'; import adminRoutes from './routes/admin.js'; import notificationRoutes from './routes/notifications.js'; @@ -162,7 +163,7 @@ app.use((err, req, res, next) => { }); app.use(cookieParser(process.env.COOKIE_SECRET)); -const csrfExemptPaths = ['/api/auth/login', '/api/auth/register', '/api/auth/passkey/options', '/api/auth/passkey/verify', '/api/auth/passkeys/login/begin', '/api/auth/passkeys/login/complete', '/api/auth/passkeys/register/begin', '/api/auth/passkeys/register/complete']; +const csrfExemptPaths = ['/api/auth/login', '/api/auth/register', '/api/auth/passkey/options', '/api/auth/passkey/verify', '/api/auth/passkeys/login/begin', '/api/auth/passkeys/login/complete', '/api/auth/passkeys/register/begin', '/api/auth/passkeys/register/complete', '/api/auth/totp/verify', '/api/auth/totp/recovery']; app.use((req, res, next) => { if (!req.path.startsWith('/api/')) return next(); if (csrfExemptPaths.includes(req.path)) return next(); @@ -271,6 +272,7 @@ app.get('/api/config', (req, res) => { app.use('/api/auth', authRoutes); app.use('/api/auth', passkeyRoutes); +app.use('/api/auth', totpRoutes); app.use('/api/servers', serverRoutes); app.use('/api/admin', adminRoutes); app.use('/api/notifications', notificationRoutes); From 252a26761db8439cf57c05813c353443571203cd Mon Sep 17 00:00:00 2001 From: ddrayko Date: Sat, 18 Jul 2026 18:44:00 +0200 Subject: [PATCH 02/10] Bump version to v1.2.0 --- package.json | 2 +- public/js/app.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0c8ef1f..12e922c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zerohost-dashboard", - "version": "1.1.1", + "version": "1.2.0", "private": true, "type": "module", "scripts": { diff --git a/public/js/app.js b/public/js/app.js index f57bf14..85ff6fe 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1654,7 +1654,7 @@ async function renderDashboard() {
-
v1.1.1
+
v1.2.0
From ac3fa4b3e91ed845a49a618f7c5f806ee81ff320 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Sat, 18 Jul 2026 18:46:26 +0200 Subject: [PATCH 03/10] Move 2FA setup into a modal popup instead of inline in card --- public/js/app.js | 68 ++++++++++++++++++++++++------------------------ 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index 85ff6fe..974ab9c 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -3703,7 +3703,6 @@ function renderTotpDisabled(section) { Enable Two-Factor Authentication -
`; $('#enable-totp-btn').addEventListener('click', handleTotpSetup); initIcons(); @@ -3748,53 +3747,54 @@ async function renderTotpEnabled(section) { async function handleTotpSetup() { const btn = $('#enable-totp-btn'); - const area = $('#totp-setup-area'); - if (!btn || !area) return; + if (!btn) return; btn.disabled = true; btn.innerHTML = ''; try { const data = await api('/auth/totp/setup', { method: 'POST' }); - area.innerHTML = html` -
-

- Scan this QR code with your authenticator app (e.g. Google Authenticator, Authy, 2FAS). -

-
- TOTP QR Code + + const overlay = $('#modal-overlay'); + const content = $('#modal-content'); + content.innerHTML = html` + +

+ Scan this QR code with your authenticator app (e.g. Google Authenticator, Authy, 2FAS). +

+
+ TOTP QR Code +
+

+ Or manually enter this key: ${data.secret} +

+
+
+ +
-

- Or manually enter this key: ${data.secret} -

- -
- - -
+
+
+
+ `; - btn.style.display = 'none'; - $('#totp-enable-form').addEventListener('submit', handleTotpEnable); - $('#totp-setup-cancel-btn').addEventListener('click', () => { - area.innerHTML = ''; - btn.disabled = false; - btn.innerHTML = ' Enable Two-Factor Authentication'; - btn.style.display = ''; - initIcons(); - }); + overlay.classList.add('open'); + + btn.disabled = false; + btn.innerHTML = ' Enable Two-Factor Authentication'; initIcons(); + + $('#totp-enable-form').addEventListener('submit', handleTotpEnable); } catch (err) { btn.disabled = false; btn.innerHTML = ' Enable Two-Factor Authentication'; - area.innerHTML = `

${escapeHtml(err.message)}

`; + showToast(err.message, 'error'); } } From 19c83520ec71a76f6f1753dc59b7b4280ee7f1c2 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Sat, 18 Jul 2026 18:46:45 +0200 Subject: [PATCH 04/10] Add background colors to TOTP log entries --- public/css/style.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/public/css/style.css b/public/css/style.css index 1b77bf0..f985499 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -3688,6 +3688,9 @@ tbody tr:hover { .activity-icon-passkey_registered { background: rgba(6,182,212,0.15); color: #06b6d4; } .activity-icon-email_verified { background: rgba(5,150,105,0.15); color: #059669; } .activity-icon-passkey_deleted { background: rgba(239,68,68,0.15); color: #ef4444; } +.activity-icon-totp_enabled { background: rgba(5,150,105,0.15); color: #059669; } +.activity-icon-totp_disabled { background: rgba(239,68,68,0.15); color: #ef4444; } +.activity-icon-login_totp { background: rgba(14,165,233,0.15); color: #0ea5e9; } .activity-content { flex: 1; From b5aea8a881855643322b9107f3b49959ffe00b64 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Sat, 18 Jul 2026 18:48:02 +0200 Subject: [PATCH 05/10] Remove recovery codes remaining block from 2FA enabled state --- public/js/app.js | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index 974ab9c..8fc35b3 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -3709,26 +3709,11 @@ function renderTotpDisabled(section) { } async function renderTotpEnabled(section) { - let remaining = 0; - try { - const rcData = await api('/auth/totp/recovery-codes'); - remaining = rcData.remaining; - } catch {} - section.innerHTML = html`
Two-factor authentication is enabled
-
-
- Recovery codes remaining: - ${remaining} -
-

- Use a recovery code if you lose access to your authenticator app. -

-