feat: 2FA/TOTP, config templates (v0.11.4 Beta)

- 2FA: TOTP setup wizard in Settings, QR code enrollment, login second-step, password-confirmed disable
- Config templates: reusable DNS/NTP/syslog config sets pushed to selected devices via RouterOS API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-05-16 15:44:23 -07:00
co-authored by Claude Sonnet 4.6
parent 49ab1b002e
commit a1ca4d0a44
12 changed files with 1036 additions and 78 deletions
+216 -15
View File
@@ -1,12 +1,12 @@
{
"name": "mikrotik-manager-backend",
"version": "0.10.6-beta",
"version": "0.11.3-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mikrotik-manager-backend",
"version": "0.10.6-beta",
"version": "0.11.3-beta",
"dependencies": {
"@influxdata/influxdb-client": "^1.33.2",
"bcryptjs": "^2.4.3",
@@ -21,7 +21,9 @@
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"nodemailer": "^8.0.5",
"otpauth": "^9.5.1",
"pg": "^8.11.3",
"qrcode": "^1.5.4",
"socket.io": "^4.6.1",
"ssh2": "^1.14.0",
"zod": "^3.22.4"
@@ -38,6 +40,7 @@
"@types/node": "^20.10.5",
"@types/nodemailer": "^6.4.14",
"@types/pg": "^8.10.9",
"@types/qrcode": "^1.5.6",
"@types/ssh2": "^1.11.17",
"@types/supertest": "^7.2.0",
"eslint": "^9.39.4",
@@ -1905,6 +1908,16 @@
"pg-types": "^2.2.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
@@ -2461,7 +2474,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -2471,7 +2483,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -2940,7 +2951,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -3124,7 +3134,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -3137,7 +3146,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
@@ -3346,6 +3354,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/dedent": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -3467,6 +3484,12 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"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": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
@@ -3532,7 +3555,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/encodeurl": {
@@ -4280,7 +4302,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -4762,7 +4783,6 @@
"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==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6280,6 +6300,30 @@
"node": ">= 0.8.0"
}
},
"node_modules/otpauth": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.1.tgz",
"integrity": "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.2.0"
},
"funding": {
"url": "https://github.com/hectorm/otpauth?sponsor=1"
}
},
"node_modules/otpauth/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/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -6316,7 +6360,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -6367,7 +6410,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6594,6 +6636,15 @@
"node": ">=8"
}
},
"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/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -6732,6 +6783,141 @@
],
"license": "MIT"
},
"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/qrcode/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/qrcode/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/qrcode/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/qrcode/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/qrcode/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/qrcode/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/qrcode/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/qrcode/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/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
@@ -6826,12 +7012,17 @@
"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==",
"dev": true,
"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/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -6990,6 +7181,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",
@@ -7348,7 +7545,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -7363,7 +7559,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -8037,6 +8232,12 @@
"node": ">= 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/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
"version": "0.11.3-beta",
"version": "0.11.4-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
@@ -26,7 +26,9 @@
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"nodemailer": "^8.0.5",
"otpauth": "^9.5.1",
"pg": "^8.11.3",
"qrcode": "^1.5.4",
"socket.io": "^4.6.1",
"ssh2": "^1.14.0",
"zod": "^3.22.4"
@@ -43,6 +45,7 @@
"@types/node": "^20.10.5",
"@types/nodemailer": "^6.4.14",
"@types/pg": "^8.10.9",
"@types/qrcode": "^1.5.6",
"@types/ssh2": "^1.11.17",
"@types/supertest": "^7.2.0",
"eslint": "^9.39.4",
+15
View File
@@ -347,6 +347,21 @@ CREATE TABLE IF NOT EXISTS device_availability (
);
CREATE INDEX IF NOT EXISTS idx_device_availability_device ON device_availability(device_id, went_offline_at DESC);
-- Configuration templates (reusable config sets pushed to devices or groups)
CREATE TABLE IF NOT EXISTS config_templates (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
applies_to_type VARCHAR(20),
template_json JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- TOTP two-factor authentication
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(64);
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT false;
-- Wireless security profiles (WPA/WPA2/WPA3 config)
CREATE TABLE IF NOT EXISTS wireless_security_profiles (
id SERIAL PRIMARY KEY,
+2
View File
@@ -43,6 +43,7 @@ import searchRoutes from './routes/search';
import switchesRoutes from './routes/switches';
import routersRoutes from './routes/routers';
import alertsRoutes from './routes/alerts';
import configTemplatesRoutes from './routes/configTemplates';
import wirelessRoutes from './routes/wireless';
import networkServicesRoutes from './routes/networkServices';
import credentialPresetsRoutes from './routes/credentialPresets';
@@ -215,6 +216,7 @@ app.use('/api/credential-presets', credentialPresetsRoutes);
app.use('/api/audit-log', auditLogRoutes);
app.use('/api/tags', tagsRoutes);
app.use('/api/maintenance-windows', maintenanceWindowsRoutes);
app.use('/api/config-templates', configTemplatesRoutes);
// ─── Error Handler ────────────────────────────────────────────────────────────
app.use(errorHandler);
+108 -1
View File
@@ -1,9 +1,14 @@
import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import * as OTPAuth from 'otpauth';
import * as qrcode from 'qrcode';
import jwt from 'jsonwebtoken';
import { query, queryOne } from '../config/database';
import { signToken, requireAuth } from '../middleware/auth';
import { loginRateLimit, rateLimitRedis } from '../middleware/rateLimitRedis';
const JWT_SECRET = process.env.JWT_SECRET || 'changeme';
const router = Router();
const DEFAULT_JWT_SECRET = 'changeme';
@@ -47,16 +52,118 @@ router.post('/login', loginRateLimit(), async (req: Request, res: Response) => {
username: string;
password_hash: string;
role: string;
}>(`SELECT id, username, password_hash, role FROM users WHERE username = $1`, [username]);
totp_enabled: boolean;
}>(`SELECT id, username, password_hash, role, totp_enabled FROM users WHERE username = $1`, [username]);
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
if (user.totp_enabled) {
const totpToken = jwt.sign({ userId: user.id, partial: true }, JWT_SECRET, { expiresIn: '5m' });
return res.json({ requires_totp: true, totp_token: totpToken });
}
const token = signToken({ userId: user.id, username: user.username, role: user.role });
return res.json({ token, user: { id: user.id, username: user.username, role: user.role } });
});
// Exchange partial TOTP token + code for a full session token
router.post('/totp/verify', async (req: Request, res: Response) => {
const { totp_token, code } = req.body as { totp_token?: string; code?: string };
if (!totp_token || !code) {
return res.status(400).json({ error: 'totp_token and code are required' });
}
let payload: { userId: number; partial: boolean };
try {
payload = jwt.verify(totp_token, JWT_SECRET) as typeof payload;
} catch {
return res.status(401).json({ error: 'Invalid or expired TOTP token' });
}
if (!payload.partial) {
return res.status(401).json({ error: 'Invalid token type' });
}
const user = await queryOne<{ id: number; username: string; role: string; totp_secret: string | null; totp_enabled: boolean }>(
`SELECT id, username, role, totp_secret, totp_enabled FROM users WHERE id = $1`,
[payload.userId]
);
if (!user || !user.totp_enabled || !user.totp_secret) {
return res.status(401).json({ error: 'TOTP not configured' });
}
const totp = new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(user.totp_secret), digits: 6, period: 30 });
const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 });
if (delta === null) {
return res.status(401).json({ error: 'Invalid TOTP code' });
}
const token = signToken({ userId: user.id, username: user.username, role: user.role });
return res.json({ token, user: { id: user.id, username: user.username, role: user.role } });
});
// Generate a new TOTP secret + QR code for the current user (does not enable yet)
router.post('/totp/setup', requireAuth, async (req: Request, res: Response) => {
const secret = new OTPAuth.Secret({ size: 20 });
const totp = new OTPAuth.TOTP({
issuer: 'MikroTik Manager',
label: req.user!.username,
secret,
digits: 6,
period: 30,
});
const uri = totp.toString();
const qrDataUrl = await qrcode.toDataURL(uri);
// Store the pending secret temporarily on the user row (not yet enabled)
await query(`UPDATE users SET totp_secret = $1 WHERE id = $2`, [secret.base32, req.user!.userId]);
return res.json({ secret: secret.base32, uri, qr: qrDataUrl });
});
// Confirm the TOTP setup by validating a code — enables TOTP
router.post('/totp/confirm', requireAuth, async (req: Request, res: Response) => {
const { code } = req.body as { code?: string };
if (!code) return res.status(400).json({ error: 'code is required' });
const user = await queryOne<{ totp_secret: string | null }>(
`SELECT totp_secret FROM users WHERE id = $1`,
[req.user!.userId]
);
if (!user?.totp_secret) return res.status(400).json({ error: 'Run /totp/setup first' });
const totp = new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(user.totp_secret), digits: 6, period: 30 });
const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 });
if (delta === null) return res.status(401).json({ error: 'Invalid code' });
await query(`UPDATE users SET totp_enabled = true WHERE id = $1`, [req.user!.userId]);
return res.json({ ok: true });
});
// Disable TOTP — requires password confirmation
router.post('/totp/disable', requireAuth, async (req: Request, res: Response) => {
const { password } = req.body as { password?: string };
if (!password) return res.status(400).json({ error: 'password is required' });
const user = await queryOne<{ password_hash: string }>(
`SELECT password_hash FROM users WHERE id = $1`,
[req.user!.userId]
);
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
return res.status(401).json({ error: 'Incorrect password' });
}
await query(`UPDATE users SET totp_enabled = false, totp_secret = NULL WHERE id = $1`, [req.user!.userId]);
return res.json({ ok: true });
});
// Return current TOTP status for the authenticated user
router.get('/totp/status', requireAuth, async (req: Request, res: Response) => {
const user = await queryOne<{ totp_enabled: boolean }>(
`SELECT totp_enabled FROM users WHERE id = $1`,
[req.user!.userId]
);
return res.json({ totp_enabled: user?.totp_enabled ?? false });
});
router.get('/me', requireAuth, (req: Request, res: Response) => {
res.json({ user: req.user });
});
+152
View File
@@ -0,0 +1,152 @@
import { Router, Request, Response } from 'express';
import { query, queryOne } from '../config/database';
import { requireAuth, requireAdmin, requireWrite } from '../middleware/auth';
import { RouterOSClient } from '../services/mikrotik/RouterOSClient';
import { decrypt } from '../utils/crypto';
const router = Router();
router.use(requireAuth);
interface ConfigTemplate {
id: number;
name: string;
description: string | null;
applies_to_type: string | null;
template_json: {
dns_servers?: string[];
ntp_servers?: string[];
syslog_host?: string;
};
created_at: string;
updated_at: string;
}
// GET /api/config-templates
router.get('/', async (_req: Request, res: Response) => {
const rows = await query<ConfigTemplate>(`SELECT * FROM config_templates ORDER BY name`);
res.json(rows);
});
// GET /api/config-templates/:id
router.get('/:id', async (req: Request, res: Response) => {
const row = await queryOne<ConfigTemplate>(`SELECT * FROM config_templates WHERE id = $1`, [req.params.id]);
if (!row) { res.status(404).json({ error: 'Not found' }); return; }
res.json(row);
});
// POST /api/config-templates (admin)
router.post('/', requireAdmin, async (req: Request, res: Response) => {
const { name, description, applies_to_type, template_json } = req.body as Partial<ConfigTemplate>;
if (!name?.trim()) { res.status(400).json({ error: 'name is required' }); return; }
const rows = await query<ConfigTemplate>(
`INSERT INTO config_templates (name, description, applies_to_type, template_json)
VALUES ($1, $2, $3, $4) RETURNING *`,
[name.trim(), description ?? null, applies_to_type ?? null, JSON.stringify(template_json ?? {})]
);
res.status(201).json(rows[0]);
});
// PUT /api/config-templates/:id (admin)
router.put('/:id', requireAdmin, async (req: Request, res: Response) => {
const { name, description, applies_to_type, template_json } = req.body as Partial<ConfigTemplate>;
const rows = await query<ConfigTemplate>(
`UPDATE config_templates SET
name = COALESCE($1, name),
description = COALESCE($2, description),
applies_to_type = COALESCE($3, applies_to_type),
template_json = COALESCE($4, template_json),
updated_at = NOW()
WHERE id = $5 RETURNING *`,
[name, description, applies_to_type, template_json ? JSON.stringify(template_json) : null, req.params.id]
);
if (!rows[0]) { res.status(404).json({ error: 'Not found' }); return; }
res.json(rows[0]);
});
// DELETE /api/config-templates/:id (admin)
router.delete('/:id', requireAdmin, async (req: Request, res: Response) => {
await query(`DELETE FROM config_templates WHERE id = $1`, [req.params.id]);
res.json({ ok: true });
});
// POST /api/config-templates/:id/apply — push template to one or more devices (operator+)
router.post('/:id/apply', requireWrite, async (req: Request, res: Response) => {
const template = await queryOne<ConfigTemplate>(
`SELECT * FROM config_templates WHERE id = $1`,
[req.params.id]
);
if (!template) { res.status(404).json({ error: 'Template not found' }); return; }
const { device_ids } = req.body as { device_ids?: number[] };
if (!Array.isArray(device_ids) || device_ids.length === 0) {
res.status(400).json({ error: 'device_ids array required' });
return;
}
const deviceRows = await query<{
id: number; name: string; ip_address: string; api_port: number;
api_username: string; api_password_encrypted: string;
}>(
`SELECT id, name, ip_address, api_port, api_username, api_password_encrypted
FROM devices WHERE id = ANY($1::int[])`,
[device_ids]
);
const tpl = template.template_json;
const results: { device_id: number; device_name: string; ok: boolean; error?: string }[] = [];
await Promise.allSettled(
deviceRows.map(async (dev) => {
const client = new RouterOSClient(
dev.ip_address,
dev.api_port,
dev.api_username,
decrypt(dev.api_password_encrypted),
15_000
);
try {
await client.connect();
if (tpl.dns_servers && tpl.dns_servers.length > 0) {
await client.execute('/ip/dns/set', { servers: tpl.dns_servers.join(',') });
}
if (tpl.ntp_servers && tpl.ntp_servers.length > 0) {
await client.execute('/system/ntp/client/set', {
enabled: 'yes',
servers: tpl.ntp_servers.join(','),
}).catch(() =>
// RouterOS 6 uses different NTP path
client.execute('/system/ntp/client/set', {
enabled: 'yes',
'primary-ntp': tpl.ntp_servers![0] ?? '',
'secondary-ntp': tpl.ntp_servers![1] ?? '',
})
);
}
if (tpl.syslog_host) {
// Find existing remote logging action and update it
const actions = await client.execute('/system/logging/action/print');
const remoteAction = (actions as Record<string, string>[]).find((a) => a.target === 'remote');
if (remoteAction) {
await client.execute('/system/logging/action/set', {
'.id': remoteAction['.id'],
'remote': tpl.syslog_host,
});
}
}
results.push({ device_id: dev.id, device_name: dev.name, ok: true });
} catch (err) {
results.push({ device_id: dev.id, device_name: dev.name, ok: false, error: (err as Error).message });
} finally {
client.disconnect();
}
})
);
res.json({ results });
});
export default router;