add 2MFA
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
"geoip-lite": "latest",
|
||||
"jose": "^6.1.3",
|
||||
"msgpackr": "1.11.8",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^9.0.1",
|
||||
"web-push": "^3.6.7",
|
||||
"winston": "^3.11.0",
|
||||
@@ -197,6 +198,8 @@
|
||||
|
||||
"animejs": ["animejs@3.2.2", "", {}, "sha512-Ao95qWLpDPXXM+WrmwcKbl6uNlC5tjnowlaRYtuVDHHoygjtIPfDUoK9NthrlZsQSKjZXlmji2TrBUAVbiH0LQ=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"ansi-to-html": ["ansi-to-html@0.7.2", "", { "dependencies": { "entities": "^2.2.0" }, "bin": { "ansi-to-html": "bin/ansi-to-html" } }, "sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g=="],
|
||||
@@ -235,6 +238,8 @@
|
||||
|
||||
"camel-case": ["camel-case@4.1.2", "", { "dependencies": { "pascal-case": "^3.1.2", "tslib": "^2.0.3" } }, "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw=="],
|
||||
|
||||
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001780", "", {}, "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
@@ -243,6 +248,8 @@
|
||||
|
||||
"clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="],
|
||||
|
||||
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
|
||||
|
||||
"codemirror": ["codemirror@5.65.16", "", {}, "sha512-br21LjYmSlVL0vFCPWPfhzUCT34FM/pAdK7rRIZwa0rrtrIdotvP4Oh4GUHsu2E3IrQMCfRkL/fN3ytMNxVQvg=="],
|
||||
|
||||
"color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="],
|
||||
@@ -257,10 +264,14 @@
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
|
||||
|
||||
"delay": ["delay@5.0.0", "", {}, "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
|
||||
|
||||
"dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="],
|
||||
|
||||
"dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="],
|
||||
@@ -269,6 +280,8 @@
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.321", "", {}, "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
|
||||
@@ -289,6 +302,8 @@
|
||||
|
||||
"fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="],
|
||||
|
||||
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
|
||||
"flag-icons": ["flag-icons@7.5.0", "", {}, "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg=="],
|
||||
|
||||
"fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="],
|
||||
@@ -297,6 +312,8 @@
|
||||
|
||||
"geoip-lite": ["geoip-lite@2.0.1", "", { "dependencies": { "chalk": "4.1 - 4.1.2", "iconv-lite": "0.4.13 - 0.6.3", "ip-address": "5.8.9 - 5.9.4", "lazy": "1.0.11", "yauzl": "^3.2.1" } }, "sha512-cR9E28nu1a6dsvzB1tANhdmCyXWV1L4AiSCT9alHLIUl06599EGu33mqY99ieU0twQob0kfcDQ/sAUBvHb7swA=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
@@ -321,6 +338,8 @@
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
@@ -371,6 +390,8 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
|
||||
|
||||
"logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="],
|
||||
@@ -411,24 +432,40 @@
|
||||
|
||||
"one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="],
|
||||
|
||||
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||
|
||||
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||
|
||||
"param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="],
|
||||
|
||||
"pascal-case": ["pascal-case@3.1.2", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
|
||||
|
||||
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
|
||||
"relateurl": ["relateurl@0.2.7", "", {}, "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
|
||||
|
||||
"rpc-websockets": ["rpc-websockets@9.3.7", "", { "dependencies": { "@swc/helpers": "^0.5.11", "@types/uuid": "^10.0.0", "@types/ws": "^8.2.2", "buffer": "^6.0.3", "eventemitter3": "^5.0.1", "uuid": "^11.0.0", "ws": "^8.5.0" }, "optionalDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^6.0.0" } }, "sha512-dQal1U0yKH2umW0DgqSecP4G1jNxyPUGY60uUMB8bLoXabC2aWT3Cag9hOhZXsH/52QJEcggxNNWhF+Fp48ykw=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
@@ -437,6 +474,8 @@
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
@@ -451,8 +490,12 @@
|
||||
|
||||
"stream-json": ["stream-json@1.9.1", "", { "dependencies": { "stream-chain": "^2.2.5" } }, "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"superstruct": ["superstruct@2.0.2", "", {}, "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
@@ -493,12 +536,22 @@
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
|
||||
|
||||
"winston": ["winston@3.19.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA=="],
|
||||
|
||||
"winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
|
||||
"ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
|
||||
|
||||
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
||||
|
||||
"yargs": ["yargs@15.4.1", "", { "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" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
||||
|
||||
"yauzl": ["yauzl@3.2.1", "", { "dependencies": { "buffer-crc32": "~0.2.3", "pend": "~1.2.0" } }, "sha512-k1isifdbpNSFEHFJ1ZY4YDewv0IH9FR61lDetaRMD3j2ae3bIXGV+7c+LHCqtQGofSd8PIyV4X6+dHMAnSr60A=="],
|
||||
|
||||
"@solana/errors/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"geoip-lite": "latest",
|
||||
"jose": "^6.1.3",
|
||||
"msgpackr": "1.11.8",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^9.0.1",
|
||||
"web-push": "^3.6.7",
|
||||
"winston": "^3.11.0"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const form = document.getElementById("login-form");
|
||||
const user = document.getElementById("user");
|
||||
const pass = document.getElementById("pass");
|
||||
const mfaGroup = document.getElementById("mfa-group");
|
||||
const mfaCode = document.getElementById("mfa-code");
|
||||
const errEl = document.getElementById("error");
|
||||
|
||||
(async () => {
|
||||
@@ -218,9 +220,26 @@ form?.addEventListener("submit", async (e) => {
|
||||
const res = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user: user.value, pass: pass.value }),
|
||||
body: JSON.stringify({
|
||||
user: user.value,
|
||||
pass: pass.value,
|
||||
mfaCode: mfaCode?.value || "",
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.status === 202) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data.mfaRequired) {
|
||||
if (mfaGroup) mfaGroup.style.display = "";
|
||||
if (mfaCode) {
|
||||
mfaCode.required = true;
|
||||
mfaCode.focus();
|
||||
}
|
||||
errEl.textContent = "Enter your authenticator code to continue.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Invalid credentials");
|
||||
|
||||
@@ -19,6 +19,19 @@ const currentPasswordInput = document.getElementById("current-password");
|
||||
const newPasswordInput = document.getElementById("new-password");
|
||||
const confirmPasswordInput = document.getElementById("confirm-password");
|
||||
const passwordPolicyHint = document.getElementById("password-policy-hint");
|
||||
const mfaStatusText = document.getElementById("mfa-status-text");
|
||||
const mfaStartBtn = document.getElementById("mfa-start-btn");
|
||||
const mfaSetupPanel = document.getElementById("mfa-setup-panel");
|
||||
const mfaQrCode = document.getElementById("mfa-qr-code");
|
||||
const mfaSecretText = document.getElementById("mfa-secret-text");
|
||||
const mfaEnableCodeInput = document.getElementById("mfa-enable-code");
|
||||
const mfaEnableBtn = document.getElementById("mfa-enable-btn");
|
||||
const mfaOtpauthLink = document.getElementById("mfa-otpauth-link");
|
||||
const mfaDisablePanel = document.getElementById("mfa-disable-panel");
|
||||
const mfaDisablePasswordInput = document.getElementById("mfa-disable-password");
|
||||
const mfaDisableCodeInput = document.getElementById("mfa-disable-code");
|
||||
const mfaDisableBtn = document.getElementById("mfa-disable-btn");
|
||||
const mfaMessage = document.getElementById("mfa-message");
|
||||
|
||||
const prefsForm = document.getElementById("prefs-form");
|
||||
const prefNotificationsInput = document.getElementById("pref-notifications");
|
||||
@@ -45,6 +58,8 @@ const securityRequireUppercaseInput = document.getElementById("security-require-
|
||||
const securityRequireLowercaseInput = document.getElementById("security-require-lowercase");
|
||||
const securityRequireNumberInput = document.getElementById("security-require-number");
|
||||
const securityRequireSymbolInput = document.getElementById("security-require-symbol");
|
||||
const securityMfaAdminsInput = document.getElementById("security-mfa-admins");
|
||||
const securityMfaNonAdminsInput = document.getElementById("security-mfa-non-admins");
|
||||
|
||||
const tlsForm = document.getElementById("tls-form");
|
||||
const tlsPermissionNote = document.getElementById("tls-permission-note");
|
||||
@@ -184,6 +199,8 @@ function setSecurityFormDisabled(disabled) {
|
||||
securityRequireLowercaseInput,
|
||||
securityRequireNumberInput,
|
||||
securityRequireSymbolInput,
|
||||
securityMfaAdminsInput,
|
||||
securityMfaNonAdminsInput,
|
||||
securitySaveBtn,
|
||||
];
|
||||
|
||||
@@ -231,6 +248,8 @@ function applySecurityForm() {
|
||||
securityRequireLowercaseInput.checked = Boolean(securityConfig.passwordRequireLowercase);
|
||||
securityRequireNumberInput.checked = Boolean(securityConfig.passwordRequireNumber);
|
||||
securityRequireSymbolInput.checked = Boolean(securityConfig.passwordRequireSymbol);
|
||||
if (securityMfaAdminsInput) securityMfaAdminsInput.checked = Boolean(securityConfig.mfaRequiredForAdmins);
|
||||
if (securityMfaNonAdminsInput) securityMfaNonAdminsInput.checked = Boolean(securityConfig.mfaRequiredForNonAdmins);
|
||||
updatePasswordPolicyUi();
|
||||
}
|
||||
|
||||
@@ -246,6 +265,8 @@ async function loadSecurityPolicy() {
|
||||
passwordRequireLowercase: false,
|
||||
passwordRequireNumber: false,
|
||||
passwordRequireSymbol: false,
|
||||
mfaRequiredForAdmins: false,
|
||||
mfaRequiredForNonAdmins: false,
|
||||
};
|
||||
updatePasswordPolicyUi();
|
||||
return;
|
||||
@@ -393,6 +414,107 @@ async function updatePassword(event) {
|
||||
showMessage("Password updated successfully.");
|
||||
}
|
||||
|
||||
function showMfaMessage(text, type = "ok") {
|
||||
if (!mfaMessage) return;
|
||||
mfaMessage.textContent = text;
|
||||
mfaMessage.className = `text-sm rounded-lg px-3 py-2 border ${
|
||||
type === "error"
|
||||
? "border-rose-800 bg-rose-900/20 text-rose-200"
|
||||
: "border-emerald-800 bg-emerald-900/20 text-emerald-200"
|
||||
}`;
|
||||
mfaMessage.classList.remove("hidden");
|
||||
setTimeout(() => mfaMessage.classList.add("hidden"), 5000);
|
||||
}
|
||||
|
||||
async function loadMfaStatus() {
|
||||
if (!mfaStatusText) return;
|
||||
try {
|
||||
const res = await fetch("/api/mfa/status", { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
mfaStatusText.textContent = "Failed to load MFA status.";
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const requiredText = data.required ? " Required by policy." : "";
|
||||
mfaStatusText.textContent = data.enabled
|
||||
? `MFA is enabled.${requiredText}`
|
||||
: `MFA is not enabled.${requiredText}`;
|
||||
if (mfaStartBtn) mfaStartBtn.classList.toggle("hidden", !!data.enabled);
|
||||
if (mfaDisablePanel) mfaDisablePanel.classList.toggle("hidden", !data.enabled);
|
||||
if (mfaDisableBtn) mfaDisableBtn.disabled = !!data.required;
|
||||
if (mfaDisablePanel) {
|
||||
mfaDisablePanel.classList.toggle("opacity-60", !!data.required);
|
||||
}
|
||||
} catch {
|
||||
mfaStatusText.textContent = "Failed to load MFA status.";
|
||||
}
|
||||
}
|
||||
|
||||
async function startMfaSetup() {
|
||||
try {
|
||||
const res = await fetch("/api/mfa/setup", { method: "POST", credentials: "include" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
showMfaMessage(data.error || "Failed to start MFA setup.", "error");
|
||||
return;
|
||||
}
|
||||
if (mfaQrCode) mfaQrCode.innerHTML = data.qrSvg || "";
|
||||
if (mfaSecretText) mfaSecretText.textContent = data.secret || "";
|
||||
if (mfaOtpauthLink) mfaOtpauthLink.href = data.otpauthUrl || "#";
|
||||
if (mfaSetupPanel) mfaSetupPanel.classList.remove("hidden");
|
||||
if (mfaEnableCodeInput) mfaEnableCodeInput.focus();
|
||||
} catch {
|
||||
showMfaMessage("Network error while starting MFA setup.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function enableMfa() {
|
||||
const code = String(mfaEnableCodeInput?.value || "").trim();
|
||||
try {
|
||||
const res = await fetch("/api/mfa/enable", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
showMfaMessage(data.error || "Failed to enable MFA.", "error");
|
||||
return;
|
||||
}
|
||||
if (mfaSetupPanel) mfaSetupPanel.classList.add("hidden");
|
||||
if (mfaEnableCodeInput) mfaEnableCodeInput.value = "";
|
||||
showMfaMessage("MFA enabled.");
|
||||
await loadMfaStatus();
|
||||
} catch {
|
||||
showMfaMessage("Network error while enabling MFA.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function disableMfa() {
|
||||
const currentPassword = String(mfaDisablePasswordInput?.value || "");
|
||||
const code = String(mfaDisableCodeInput?.value || "").trim();
|
||||
try {
|
||||
const res = await fetch("/api/mfa/disable", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ currentPassword, code }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
showMfaMessage(data.error || "Failed to disable MFA.", "error");
|
||||
return;
|
||||
}
|
||||
if (mfaDisablePasswordInput) mfaDisablePasswordInput.value = "";
|
||||
if (mfaDisableCodeInput) mfaDisableCodeInput.value = "";
|
||||
showMfaMessage("MFA disabled.");
|
||||
await loadMfaStatus();
|
||||
} catch {
|
||||
showMfaMessage("Network error while disabling MFA.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function savePrefs(event) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -452,6 +574,8 @@ async function saveSecurityPolicy(event) {
|
||||
passwordRequireLowercase: securityRequireLowercaseInput.checked,
|
||||
passwordRequireNumber: securityRequireNumberInput.checked,
|
||||
passwordRequireSymbol: securityRequireSymbolInput.checked,
|
||||
mfaRequiredForAdmins: !!securityMfaAdminsInput?.checked,
|
||||
mfaRequiredForNonAdmins: !!securityMfaNonAdminsInput?.checked,
|
||||
};
|
||||
|
||||
const res = await fetch("/api/settings/security", {
|
||||
@@ -1837,6 +1961,7 @@ async function init() {
|
||||
await loadCurrentUser();
|
||||
applyPermissionVisibility();
|
||||
loadPrefs();
|
||||
await loadMfaStatus();
|
||||
|
||||
if (userHas("clients:build")) {
|
||||
if (buildsShowAllWrap && !isAdmin(currentUser?.role)) {
|
||||
@@ -1863,6 +1988,9 @@ async function init() {
|
||||
}
|
||||
|
||||
passwordForm.addEventListener("submit", updatePassword);
|
||||
if (mfaStartBtn) mfaStartBtn.addEventListener("click", startMfaSetup);
|
||||
if (mfaEnableBtn) mfaEnableBtn.addEventListener("click", enableMfa);
|
||||
if (mfaDisableBtn) mfaDisableBtn.addEventListener("click", disableMfa);
|
||||
prefsForm.addEventListener("submit", savePrefs);
|
||||
|
||||
const sidebarBtn = document.getElementById("pref-nav-sidebar");
|
||||
|
||||
@@ -57,6 +57,22 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mfa-group" class="form-group" style="display: none">
|
||||
<label class="label">Authenticator Code</label>
|
||||
<div class="input-row input-animated">
|
||||
<i class="fa-solid fa-shield-halved"></i
|
||||
><input
|
||||
id="mfa-code"
|
||||
name="mfa-code"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
maxlength="6"
|
||||
pattern="[0-9]{6}"
|
||||
placeholder="6-digit code"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="button primary full login-btn">
|
||||
<span class="btn-text">Sign in</span>
|
||||
<i class="fa-solid fa-arrow-right btn-icon"></i>
|
||||
|
||||
@@ -169,6 +169,76 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="mfa-settings" class="space-y-3 mt-2 pt-4 border-t border-slate-800">
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-slate-500 pb-1">Multi-Factor Authentication</p>
|
||||
<p id="mfa-status-text" class="text-sm text-slate-400">Loading MFA status...</p>
|
||||
</div>
|
||||
<button
|
||||
id="mfa-start-btn"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-700 hover:bg-emerald-600 transition-colors text-white text-sm font-medium"
|
||||
>
|
||||
<i class="fa-solid fa-shield-halved"></i>Set Up MFA
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="mfa-setup-panel" class="hidden rounded-lg border border-slate-700 bg-slate-900 px-4 py-4 space-y-4">
|
||||
<p class="text-sm text-slate-300 pb-1">Add this manual key to your authenticator app, then enter the 6-digit code.</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-[20rem_minmax(0,1fr)] gap-5 items-start">
|
||||
<div class="w-80 max-w-full">
|
||||
<div id="mfa-qr-code" class="aspect-square w-full rounded-lg bg-white p-1 border border-slate-700 overflow-hidden"></div>
|
||||
</div>
|
||||
<div class="min-w-0 space-y-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-slate-500">Manual Key</p>
|
||||
<div class="rounded bg-slate-950 border border-slate-800 px-3 py-2 font-mono text-sm text-sky-300 break-all" id="mfa-secret-text"></div>
|
||||
<input
|
||||
id="mfa-enable-code"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="6-digit code"
|
||||
class="w-full px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
/>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button id="mfa-enable-btn" type="button" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-700 hover:bg-emerald-600 transition-colors text-white text-sm font-medium">
|
||||
<i class="fa-solid fa-check"></i>Enable MFA
|
||||
</button>
|
||||
<a id="mfa-otpauth-link" href="#" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 text-sm transition-colors">
|
||||
<i class="fa-solid fa-mobile-screen"></i>Open App Link
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mfa-disable-panel" class="hidden rounded-lg border border-slate-700 bg-slate-900 px-4 py-4 space-y-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<input
|
||||
id="mfa-disable-password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="Current password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-rose-500"
|
||||
/>
|
||||
<input
|
||||
id="mfa-disable-code"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="6-digit code"
|
||||
class="w-full px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-rose-500"
|
||||
/>
|
||||
</div>
|
||||
<button id="mfa-disable-btn" type="button" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-rose-800/70 hover:bg-rose-700 transition-colors text-rose-100 border border-rose-700 text-sm font-medium">
|
||||
<i class="fa-solid fa-lock-open"></i>Disable MFA
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p id="mfa-message" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
|
||||
</div>
|
||||
|
||||
<p id="settings-message" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
|
||||
</section>
|
||||
|
||||
@@ -574,6 +644,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-slate-500 mb-2">MFA Enforcement</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
|
||||
<label class="flex items-start gap-2 rounded-lg border border-slate-700/60 bg-slate-900/40 p-3">
|
||||
<input id="security-mfa-admins" type="checkbox" class="mt-1 h-4 w-4 accent-indigo-500" />
|
||||
<span>
|
||||
<span class="block text-slate-200">Require MFA for admins</span>
|
||||
<span class="block text-xs text-slate-500">Your admin account must have MFA enabled first.</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-start gap-2 rounded-lg border border-slate-700/60 bg-slate-900/40 p-3">
|
||||
<input id="security-mfa-non-admins" type="checkbox" class="mt-1 h-4 w-4 accent-indigo-500" />
|
||||
<span>
|
||||
<span class="block text-slate-200">Require MFA for non-admins</span>
|
||||
<span class="block text-xs text-slate-500">Applies to operator and viewer accounts.</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
id="security-save-btn"
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { extractTokenFromCookie, extractTokenFromHeader } from "./auth";
|
||||
import { generateTotpCode } from "./mfa";
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
enableUserMfa,
|
||||
getUserById,
|
||||
setUserMfaSecret,
|
||||
} from "./users";
|
||||
import { handleAuthRoutes } from "./server/routes/auth-routes";
|
||||
|
||||
const PASSWORD = "Aa1!AuthMfaTest_2026";
|
||||
const mockServer = {
|
||||
requestIP: () => ({ address: "127.0.0.1" }),
|
||||
};
|
||||
|
||||
describe("auth token extraction", () => {
|
||||
test("extractTokenFromHeader returns bearer token", () => {
|
||||
@@ -16,3 +30,49 @@ describe("auth token extraction", () => {
|
||||
expect(extractTokenFromCookie("foo=bar")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth MFA login", () => {
|
||||
test("MFA-enabled user gets a challenge and can login with TOTP", async () => {
|
||||
const username = `mfa_user_${Date.now().toString(36)}`;
|
||||
const created = await createUser(username, PASSWORD, "operator", "test");
|
||||
expect(created.success).toBe(true);
|
||||
|
||||
try {
|
||||
const secret = "JBSWY3DPEHPK3PXP";
|
||||
expect(setUserMfaSecret(created.userId!, secret).success).toBe(true);
|
||||
expect(enableUserMfa(created.userId!).success).toBe(true);
|
||||
|
||||
const url = new URL("https://localhost/api/login");
|
||||
const challenge = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ user: username, pass: PASSWORD }),
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
expect(challenge?.status).toBe(202);
|
||||
expect((await challenge!.json()).mfaRequired).toBe(true);
|
||||
|
||||
const login = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
user: username,
|
||||
pass: PASSWORD,
|
||||
mfaCode: generateTotpCode(secret),
|
||||
}),
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
expect(login?.status).toBe(200);
|
||||
const body = (await login!.json()) as any;
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.token).toBeTruthy();
|
||||
expect(getUserById(created.userId!)).not.toBeNull();
|
||||
} finally {
|
||||
deleteUser(created.userId!);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface Config {
|
||||
passwordRequireLowercase: boolean;
|
||||
passwordRequireNumber: boolean;
|
||||
passwordRequireSymbol: boolean;
|
||||
mfaRequiredForAdmins: boolean;
|
||||
mfaRequiredForNonAdmins: boolean;
|
||||
};
|
||||
enrollment: {
|
||||
requireApproval: boolean;
|
||||
@@ -140,6 +142,8 @@ const DEFAULT_CONFIG: Config = {
|
||||
passwordRequireLowercase: false,
|
||||
passwordRequireNumber: false,
|
||||
passwordRequireSymbol: false,
|
||||
mfaRequiredForAdmins: false,
|
||||
mfaRequiredForNonAdmins: false,
|
||||
},
|
||||
enrollment: {
|
||||
requireApproval: true,
|
||||
@@ -506,6 +510,14 @@ export function loadConfig(): Config {
|
||||
String(process.env.OVERLORD_PASSWORD_REQUIRE_SYMBOL || "").toLowerCase() === "true" ||
|
||||
fileConfig.security?.passwordRequireSymbol ||
|
||||
DEFAULT_CONFIG.security.passwordRequireSymbol,
|
||||
mfaRequiredForAdmins:
|
||||
String(process.env.OVERLORD_MFA_REQUIRED_FOR_ADMINS || "").toLowerCase() === "true" ||
|
||||
fileConfig.security?.mfaRequiredForAdmins ||
|
||||
DEFAULT_CONFIG.security.mfaRequiredForAdmins,
|
||||
mfaRequiredForNonAdmins:
|
||||
String(process.env.OVERLORD_MFA_REQUIRED_FOR_NON_ADMINS || "").toLowerCase() === "true" ||
|
||||
fileConfig.security?.mfaRequiredForNonAdmins ||
|
||||
DEFAULT_CONFIG.security.mfaRequiredForNonAdmins,
|
||||
},
|
||||
enrollment: {
|
||||
requireApproval:
|
||||
@@ -675,6 +687,8 @@ export async function updateSecurityConfig(
|
||||
next.passwordRequireLowercase = Boolean(next.passwordRequireLowercase);
|
||||
next.passwordRequireNumber = Boolean(next.passwordRequireNumber);
|
||||
next.passwordRequireSymbol = Boolean(next.passwordRequireSymbol);
|
||||
next.mfaRequiredForAdmins = Boolean(next.mfaRequiredForAdmins);
|
||||
next.mfaRequiredForNonAdmins = Boolean(next.mfaRequiredForNonAdmins);
|
||||
|
||||
configCache = {
|
||||
...current,
|
||||
|
||||
@@ -216,6 +216,14 @@ export function initializeUserSchema(): void {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "013_users_mfa",
|
||||
run: () => {
|
||||
addColumnIfMissing("users", `mfa_secret TEXT DEFAULT NULL`);
|
||||
addColumnIfMissing("users", `mfa_enabled INTEGER NOT NULL DEFAULT 0`);
|
||||
addColumnIfMissing("users", `mfa_enabled_at INTEGER DEFAULT NULL`);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
runMigrations(userMigrations);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { buildTotpUri, generateMfaSecret, generateTotpCode, verifyTotpCode } from "./mfa";
|
||||
|
||||
describe("mfa totp", () => {
|
||||
test("matches RFC 6238 test vector with 8 digits", () => {
|
||||
const secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
|
||||
expect(generateTotpCode(secret, 59_000, 8)).toBe("94287082");
|
||||
});
|
||||
|
||||
test("verifies current and adjacent window codes", () => {
|
||||
const secret = generateMfaSecret();
|
||||
const now = Date.now();
|
||||
const code = generateTotpCode(secret, now);
|
||||
|
||||
expect(verifyTotpCode(secret, code, now)).toBe(true);
|
||||
expect(verifyTotpCode(secret, "000000", now)).toBe(false);
|
||||
});
|
||||
|
||||
test("builds an authenticator URI", () => {
|
||||
const uri = buildTotpUri({
|
||||
issuer: "Overlord",
|
||||
accountName: "admin",
|
||||
secret: "JBSWY3DPEHPK3PXP",
|
||||
});
|
||||
|
||||
expect(uri).toContain("otpauth://totp/");
|
||||
expect(uri).toContain("secret=JBSWY3DPEHPK3PXP");
|
||||
expect(uri).toContain("issuer=Overlord");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
|
||||
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
const DEFAULT_PERIOD_SECONDS = 30;
|
||||
const DEFAULT_DIGITS = 6;
|
||||
const DEFAULT_WINDOW = 1;
|
||||
|
||||
export function generateMfaSecret(length = 20): string {
|
||||
const bytes = new Uint8Array(Math.max(10, Math.min(64, length)));
|
||||
crypto.getRandomValues(bytes);
|
||||
return base32Encode(Buffer.from(bytes));
|
||||
}
|
||||
|
||||
export function buildTotpUri(params: {
|
||||
issuer: string;
|
||||
accountName: string;
|
||||
secret: string;
|
||||
}): string {
|
||||
const issuer = params.issuer || "Overlord";
|
||||
const label = `${issuer}:${params.accountName}`;
|
||||
const search = new URLSearchParams({
|
||||
secret: params.secret,
|
||||
issuer,
|
||||
algorithm: "SHA1",
|
||||
digits: String(DEFAULT_DIGITS),
|
||||
period: String(DEFAULT_PERIOD_SECONDS),
|
||||
});
|
||||
return `otpauth://totp/${encodeURIComponent(label)}?${search.toString()}`;
|
||||
}
|
||||
|
||||
export function generateTotpCode(
|
||||
secret: string,
|
||||
timestamp = Date.now(),
|
||||
digits = DEFAULT_DIGITS,
|
||||
periodSeconds = DEFAULT_PERIOD_SECONDS,
|
||||
): string {
|
||||
const key = base32Decode(secret);
|
||||
const counter = Math.floor(timestamp / 1000 / periodSeconds);
|
||||
const counterBuffer = Buffer.alloc(8);
|
||||
counterBuffer.writeBigUInt64BE(BigInt(counter), 0);
|
||||
|
||||
const digest = createHmac("sha1", key).update(counterBuffer).digest();
|
||||
const offset = digest[digest.length - 1] & 0x0f;
|
||||
const binary =
|
||||
((digest[offset] & 0x7f) << 24) |
|
||||
((digest[offset + 1] & 0xff) << 16) |
|
||||
((digest[offset + 2] & 0xff) << 8) |
|
||||
(digest[offset + 3] & 0xff);
|
||||
|
||||
return String(binary % 10 ** digits).padStart(digits, "0");
|
||||
}
|
||||
|
||||
export function verifyTotpCode(
|
||||
secret: string,
|
||||
code: string,
|
||||
timestamp = Date.now(),
|
||||
window = DEFAULT_WINDOW,
|
||||
): boolean {
|
||||
const normalized = String(code || "").replace(/\s+/g, "");
|
||||
if (!/^\d{6}$/.test(normalized)) return false;
|
||||
|
||||
for (let offset = -window; offset <= window; offset++) {
|
||||
const candidate = generateTotpCode(
|
||||
secret,
|
||||
timestamp + offset * DEFAULT_PERIOD_SECONDS * 1000,
|
||||
);
|
||||
if (safeEqual(candidate, normalized)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function base32Encode(buffer: Buffer): string {
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let output = "";
|
||||
|
||||
for (const byte of buffer) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
|
||||
if (bits > 0) {
|
||||
output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function base32Decode(input: string): Buffer {
|
||||
const clean = input.toUpperCase().replace(/=+$/g, "").replace(/\s+/g, "");
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const bytes: number[] = [];
|
||||
|
||||
for (const char of clean) {
|
||||
const index = BASE32_ALPHABET.indexOf(char);
|
||||
if (index === -1) {
|
||||
throw new Error("Invalid MFA secret");
|
||||
}
|
||||
value = (value << 5) | index;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bytes.push((value >>> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function safeEqual(left: string, right: string): boolean {
|
||||
const leftBuffer = Buffer.from(left);
|
||||
const rightBuffer = Buffer.from(right);
|
||||
if (leftBuffer.length !== rightBuffer.length) return false;
|
||||
return timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createQrSvg } from "./qr";
|
||||
|
||||
describe("qr svg", () => {
|
||||
test("creates an inline SVG QR for otpauth payloads", () => {
|
||||
const svg = createQrSvg(
|
||||
"otpauth://totp/Overlord:admin?secret=JBSWY3DPEHPK3PXP&issuer=Overlord&algorithm=SHA1&digits=6&period=30",
|
||||
);
|
||||
|
||||
expect(svg).toContain("<svg");
|
||||
expect(svg).toContain("aria-label=\"MFA setup QR code\"");
|
||||
expect(svg).toContain("<rect");
|
||||
});
|
||||
|
||||
test("supports larger payloads through the QR library", () => {
|
||||
const svg = createQrSvg("x".repeat(200));
|
||||
expect(svg).toContain("<svg");
|
||||
expect(svg).toContain("viewBox=");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import QRCode from "qrcode";
|
||||
|
||||
export function createQrSvg(text: string, scale = 8, margin = 2): string {
|
||||
const qr = QRCode.create(text, {
|
||||
errorCorrectionLevel: "M",
|
||||
margin,
|
||||
});
|
||||
const size = qr.modules.size;
|
||||
const total = (size + margin * 2) * scale;
|
||||
const rects: string[] = [];
|
||||
|
||||
for (let row = 0; row < size; row++) {
|
||||
for (let col = 0; col < size; col++) {
|
||||
if (!qr.modules.get(col, row)) continue;
|
||||
rects.push(
|
||||
`<rect x="${(col + margin) * scale}" y="${(row + margin) * scale}" width="${scale}" height="${scale}"/>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${total} ${total}" width="100%" height="100%" shape-rendering="crispEdges" role="img" aria-label="MFA setup QR code"><rect width="100%" height="100%" fill="#fff"/><g fill="#000">${rects.join("")}</g></svg>`;
|
||||
}
|
||||
@@ -49,6 +49,8 @@ describe("security settings", () => {
|
||||
loginWindowMinutes: 0,
|
||||
loginLockoutMinutes: 0,
|
||||
passwordMinLength: 2,
|
||||
mfaRequiredForAdmins: true,
|
||||
mfaRequiredForNonAdmins: true,
|
||||
});
|
||||
|
||||
expect(updated.sessionTtlHours).toBe(24 * 30);
|
||||
@@ -56,5 +58,7 @@ describe("security settings", () => {
|
||||
expect(updated.loginWindowMinutes).toBe(1);
|
||||
expect(updated.loginLockoutMinutes).toBe(1);
|
||||
expect(updated.passwordMinLength).toBe(6);
|
||||
expect(updated.mfaRequiredForAdmins).toBe(true);
|
||||
expect(updated.mfaRequiredForNonAdmins).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
revokeToken,
|
||||
} from "../../auth";
|
||||
import { AuditAction, logAudit } from "../../auditLog";
|
||||
import { buildTotpUri, generateMfaSecret, verifyTotpCode } from "../../mfa";
|
||||
import { createQrSvg } from "../../qr";
|
||||
import {
|
||||
listUserSessions,
|
||||
revokeSessionById,
|
||||
@@ -23,7 +25,19 @@ import {
|
||||
recordFailedAttempt,
|
||||
recordSuccessfulAttempt,
|
||||
} from "../../rateLimit";
|
||||
import { getUserById, canUserAccessClient, canUserAccessFeature, getUserFeaturePermissions, type FeatureName, ALL_FEATURES } from "../../users";
|
||||
import {
|
||||
getUserById,
|
||||
canUserAccessClient,
|
||||
canUserAccessFeature,
|
||||
getUserFeaturePermissions,
|
||||
type FeatureName,
|
||||
ALL_FEATURES,
|
||||
disableUserMfa,
|
||||
enableUserMfa,
|
||||
getUserMfaStatus,
|
||||
isMfaRequiredForUser,
|
||||
setUserMfaSecret,
|
||||
} from "../../users";
|
||||
import { getUserPermissions, requirePermission } from "../../rbac";
|
||||
import { makeAuthCookie, makeAuthCookieClear } from "./auth-cookie";
|
||||
|
||||
@@ -69,10 +83,44 @@ export async function handleAuthRoutes(
|
||||
const body = await req.json();
|
||||
const username = body?.user || "";
|
||||
const password = body?.pass || "";
|
||||
const mfaCode = typeof body?.mfaCode === "string" ? body.mfaCode : "";
|
||||
|
||||
const user = await authenticateUser(username, password);
|
||||
|
||||
if (user) {
|
||||
const mfaEnabled = Boolean(user.mfa_enabled && user.mfa_secret);
|
||||
if (mfaEnabled) {
|
||||
if (!mfaCode) {
|
||||
return Response.json({ ok: false, mfaRequired: true }, { status: 202 });
|
||||
}
|
||||
if (!verifyTotpCode(user.mfa_secret!, mfaCode)) {
|
||||
recordFailedAttempt(ip);
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip,
|
||||
action: AuditAction.LOGIN_FAILED,
|
||||
success: false,
|
||||
errorMessage: "Invalid MFA code",
|
||||
});
|
||||
return Response.json({ ok: false, error: "Invalid MFA code", mfaRequired: true }, { status: 401 });
|
||||
}
|
||||
} else if (isMfaRequiredForUser(user)) {
|
||||
recordFailedAttempt(ip);
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip,
|
||||
action: AuditAction.LOGIN_FAILED,
|
||||
success: false,
|
||||
errorMessage: "MFA required but not enrolled",
|
||||
});
|
||||
return Response.json(
|
||||
{ ok: false, error: "MFA is required for this account, but it is not enrolled yet." },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const userAgent = req.headers.get("User-Agent") || null;
|
||||
const token = await generateToken(user, { ip, userAgent: userAgent || undefined });
|
||||
const sessionTtlSeconds = getSessionTtlSeconds();
|
||||
@@ -140,6 +188,102 @@ export async function handleAuthRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/mfa/status") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return Response.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
const dbUser = getUserById(user.userId);
|
||||
if (!dbUser) {
|
||||
return Response.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
const status = getUserMfaStatus(user.userId);
|
||||
return Response.json({
|
||||
enabled: Boolean(status?.enabled),
|
||||
enabledAt: status?.enabledAt || null,
|
||||
required: isMfaRequiredForUser(dbUser),
|
||||
policy: {
|
||||
admins: isMfaRequiredForUser({ role: "admin" }),
|
||||
nonAdmins: isMfaRequiredForUser({ role: "operator" }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/mfa/setup") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return Response.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
const status = getUserMfaStatus(user.userId);
|
||||
if (status?.enabled) {
|
||||
return Response.json({ error: "MFA is already enabled" }, { status: 400 });
|
||||
}
|
||||
const secret = generateMfaSecret();
|
||||
const result = setUserMfaSecret(user.userId, secret);
|
||||
if (!result.success) {
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
const otpauthUrl = buildTotpUri({
|
||||
issuer: "Overlord",
|
||||
accountName: user.username,
|
||||
secret,
|
||||
});
|
||||
return Response.json({
|
||||
secret,
|
||||
otpauthUrl,
|
||||
qrSvg: createQrSvg(otpauthUrl),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/mfa/enable") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return Response.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const code = typeof body?.code === "string" ? body.code : "";
|
||||
const status = getUserMfaStatus(user.userId);
|
||||
if (!status?.secret) {
|
||||
return Response.json({ error: "Start MFA setup first" }, { status: 400 });
|
||||
}
|
||||
if (!verifyTotpCode(status.secret, code)) {
|
||||
return Response.json({ error: "Invalid MFA code" }, { status: 400 });
|
||||
}
|
||||
const result = enableUserMfa(user.userId);
|
||||
if (!result.success) {
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
return Response.json({ ok: true, enabled: true });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/mfa/disable") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return Response.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
const dbUser = getUserById(user.userId);
|
||||
if (!dbUser) {
|
||||
return Response.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
if (isMfaRequiredForUser(dbUser)) {
|
||||
return Response.json({ error: "MFA is required by policy for this account" }, { status: 400 });
|
||||
}
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const currentPassword = typeof body?.currentPassword === "string" ? body.currentPassword : "";
|
||||
const code = typeof body?.code === "string" ? body.code : "";
|
||||
if (!currentPassword || !(await Bun.password.verify(currentPassword, dbUser.password_hash))) {
|
||||
return Response.json({ error: "Current password is incorrect" }, { status: 400 });
|
||||
}
|
||||
if (dbUser.mfa_enabled && dbUser.mfa_secret && !verifyTotpCode(dbUser.mfa_secret, code)) {
|
||||
return Response.json({ error: "Invalid MFA code" }, { status: 400 });
|
||||
}
|
||||
const result = disableUserMfa(user.userId);
|
||||
if (!result.success) {
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
return Response.json({ ok: true, enabled: false });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/logout") {
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
const user = await getUserFromRequest(req);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getThumbnailStats } from "../../thumbnails";
|
||||
import { getClientCount, getOnlineClients } from "../../clientManager";
|
||||
import { metrics } from "../../metrics";
|
||||
import { requirePermission } from "../../rbac";
|
||||
import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess, canUserAccessClient } from "../../users";
|
||||
import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess, canUserAccessClient, getUserById } from "../../users";
|
||||
import { runCertbotSetup } from "../certbot-setup";
|
||||
import {
|
||||
getActiveProxies,
|
||||
@@ -131,6 +131,16 @@ export async function handleMiscRoutes(
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (Boolean(body?.mfaRequiredForAdmins)) {
|
||||
const dbUser = getUserById(user.userId);
|
||||
if (!dbUser?.mfa_enabled) {
|
||||
return Response.json(
|
||||
{ error: "Enable MFA on your own admin account before requiring it for admins." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await updateSecurityConfig({
|
||||
sessionTtlHours: Number(body?.sessionTtlHours),
|
||||
loginMaxAttempts: Number(body?.loginMaxAttempts),
|
||||
@@ -141,6 +151,8 @@ export async function handleMiscRoutes(
|
||||
passwordRequireLowercase: Boolean(body?.passwordRequireLowercase),
|
||||
passwordRequireNumber: Boolean(body?.passwordRequireNumber),
|
||||
passwordRequireSymbol: Boolean(body?.passwordRequireSymbol),
|
||||
mfaRequiredForAdmins: Boolean(body?.mfaRequiredForAdmins),
|
||||
mfaRequiredForNonAdmins: Boolean(body?.mfaRequiredForNonAdmins),
|
||||
});
|
||||
|
||||
logAudit({
|
||||
|
||||
@@ -22,6 +22,9 @@ export interface User {
|
||||
can_build: number;
|
||||
can_upload_files: number;
|
||||
telegram_chat_id: string | null;
|
||||
mfa_secret: string | null;
|
||||
mfa_enabled: number;
|
||||
mfa_enabled_at: number | null;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
@@ -36,6 +39,8 @@ export interface UserInfo {
|
||||
can_build: number;
|
||||
can_upload_files: number;
|
||||
telegram_chat_id: string | null;
|
||||
mfa_enabled: number;
|
||||
mfa_enabled_at: number | null;
|
||||
}
|
||||
|
||||
export interface UserClientAccessRule {
|
||||
@@ -125,12 +130,75 @@ export function getUserByUsername(username: string): User | null {
|
||||
export function listUsers(): UserInfo[] {
|
||||
const users = db
|
||||
.prepare(
|
||||
"SELECT id, username, role, created_at, last_login, created_by, client_scope, plugin_scope, can_build, can_upload_files, telegram_chat_id FROM users ORDER BY created_at DESC",
|
||||
"SELECT id, username, role, created_at, last_login, created_by, client_scope, plugin_scope, can_build, can_upload_files, telegram_chat_id, mfa_enabled, mfa_enabled_at FROM users ORDER BY created_at DESC",
|
||||
)
|
||||
.all() as UserInfo[];
|
||||
return users;
|
||||
}
|
||||
|
||||
export function isMfaRequiredForUser(user: Pick<User, "role">): boolean {
|
||||
const security = getConfig().security;
|
||||
return user.role === "admin"
|
||||
? Boolean(security.mfaRequiredForAdmins)
|
||||
: Boolean(security.mfaRequiredForNonAdmins);
|
||||
}
|
||||
|
||||
export function getUserMfaStatus(userId: number): {
|
||||
enabled: boolean;
|
||||
enabledAt: number | null;
|
||||
secret: string | null;
|
||||
} | null {
|
||||
const row = db
|
||||
.prepare("SELECT mfa_enabled, mfa_enabled_at, mfa_secret FROM users WHERE id = ?")
|
||||
.get(userId) as
|
||||
| { mfa_enabled: number; mfa_enabled_at: number | null; mfa_secret: string | null }
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
return {
|
||||
enabled: Boolean(row.mfa_enabled),
|
||||
enabledAt: row.mfa_enabled_at || null,
|
||||
secret: row.mfa_secret || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function setUserMfaSecret(userId: number, secret: string): { success: boolean; error?: string } {
|
||||
try {
|
||||
db.prepare("UPDATE users SET mfa_secret = ?, mfa_enabled = 0, mfa_enabled_at = NULL WHERE id = ?").run(
|
||||
secret,
|
||||
userId,
|
||||
);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
logger.error("[users] setUserMfaSecret error:", err);
|
||||
return { success: false, error: err.message || "Failed to update MFA secret" };
|
||||
}
|
||||
}
|
||||
|
||||
export function enableUserMfa(userId: number): { success: boolean; error?: string } {
|
||||
try {
|
||||
db.prepare("UPDATE users SET mfa_enabled = 1, mfa_enabled_at = ? WHERE id = ?").run(
|
||||
Date.now(),
|
||||
userId,
|
||||
);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
logger.error("[users] enableUserMfa error:", err);
|
||||
return { success: false, error: err.message || "Failed to enable MFA" };
|
||||
}
|
||||
}
|
||||
|
||||
export function disableUserMfa(userId: number): { success: boolean; error?: string } {
|
||||
try {
|
||||
db.prepare("UPDATE users SET mfa_secret = NULL, mfa_enabled = 0, mfa_enabled_at = NULL WHERE id = ?").run(
|
||||
userId,
|
||||
);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
logger.error("[users] disableUserMfa error:", err);
|
||||
return { success: false, error: err.message || "Failed to disable MFA" };
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserClientAccessScope(userId: number): ClientAccessScope {
|
||||
return getUserAccessCacheEntry(userId).scope;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user