diff --git a/backend/internal/mfa.js b/backend/internal/mfa.js new file mode 100644 index 000000000..79f11bf75 --- /dev/null +++ b/backend/internal/mfa.js @@ -0,0 +1,97 @@ +const authModel = require('../models/auth'); +const error = require('../lib/error'); +const speakeasy = require('speakeasy'); + +module.exports = { + validateMfaTokenForUser: (userId, token) => { + return authModel + .query() + .where('user_id', userId) + .first() + .then((auth) => { + if (!auth || !auth.mfa_enabled) { + throw new error.AuthError('MFA is not enabled for this user.'); + } + const verified = speakeasy.totp.verify({ + secret: auth.mfa_secret, + encoding: 'base32', + token: token, + window: 2 + }); + if (!verified) { + throw new error.AuthError('Invalid MFA token.'); + } + return true; + }); + }, + isMfaEnabledForUser: (userId) => { + return authModel + .query() + .where('user_id', userId) + .first() + .then((auth) => { + console.log(auth); + if (!auth) { + throw new error.AuthError('User not found.'); + } + return auth.mfa_enabled === true; + }); + }, + createMfaSecretForUser: (userId) => { + const secret = speakeasy.generateSecret({ length: 20 }); + console.log(secret); + return authModel + .query() + .where('user_id', userId) + .update({ + mfa_secret: secret.base32 + }) + .then(() => secret); + }, + enableMfaForUser: (userId, token) => { + return authModel + .query() + .where('user_id', userId) + .first() + .then((auth) => { + if (!auth || !auth.mfa_secret) { + throw new error.AuthError('MFA is not set up for this user.'); + } + const verified = speakeasy.totp.verify({ + secret: auth.mfa_secret, + encoding: 'base32', + token: token, + window: 2 + }); + if (!verified) { + throw new error.AuthError('Invalid MFA token.'); + } + return authModel + .query() + .where('user_id', userId) + .update({ mfa_enabled: true }) + .then(() => true); + }); + }, + disableMfaForUser: (data, userId) => { + return authModel + .query() + .where('user_id', userId) + .first() + .then((auth) => { + if (!auth) { + throw new error.AuthError('User not found.'); + } + return auth.verifyPassword(data.secret) + .then((valid) => { + if (!valid) { + throw new error.AuthError('Invalid password.'); + } + return authModel + .query() + .where('user_id', userId) + .update({ mfa_enabled: false, mfa_secret: null }); + }); + }); + }, +}; diff --git a/backend/internal/token.js b/backend/internal/token.js index 0e6dec5e3..04cf9dd81 100644 --- a/backend/internal/token.js +++ b/backend/internal/token.js @@ -4,6 +4,7 @@ const userModel = require('../models/user'); const authModel = require('../models/auth'); const helpers = require('../lib/helpers'); const TokenModel = require('../models/token'); +const mfa = require('../internal/mfa'); // <-- added MFA import const ERROR_MESSAGE_INVALID_AUTH = 'Invalid email or password'; @@ -21,6 +22,8 @@ module.exports = { getTokenFromEmail: (data, issuer) => { let Token = new TokenModel(); + console.log(data); + data.scope = data.scope || 'user'; data.expiry = data.expiry || '1d'; @@ -41,34 +44,66 @@ module.exports = { .then((auth) => { if (auth) { return auth.verifyPassword(data.secret) - .then((valid) => { + .then(async (valid) => { if (valid) { - if (data.scope !== 'user' && _.indexOf(user.roles, data.scope) === -1) { - // The scope requested doesn't exist as a role against the user, - // you shall not pass. throw new error.AuthError('Invalid scope: ' + data.scope); } - - // Create a moment of the expiry expression - let expiry = helpers.parseDatePeriod(data.expiry); - if (expiry === null) { - throw new error.AuthError('Invalid expiry time: ' + data.expiry); - } - - return Token.create({ - iss: issuer || 'api', - attrs: { - id: user.id - }, - scope: [data.scope], - expiresIn: data.expiry - }) - .then((signed) => { - return { - token: signed.token, - expires: expiry.toISOString() - }; + return await mfa.isMfaEnabledForUser(user.id) + .then((mfaEnabled) => { + if (mfaEnabled) { + if (!data.mfa_token) { + throw new error.AuthError('MFA token required'); + } + console.log(data.mfa_token); + return mfa.validateMfaTokenForUser(user.id, data.mfa_token) + .then((mfaValid) => { + if (!mfaValid) { + throw new error.AuthError('Invalid MFA token'); + } + // Create a moment of the expiry expression + let expiry = helpers.parseDatePeriod(data.expiry); + if (expiry === null) { + throw new error.AuthError('Invalid expiry time: ' + data.expiry); + } + + return Token.create({ + iss: issuer || 'api', + attrs: { + id: user.id + }, + scope: [data.scope], + expiresIn: data.expiry + }) + .then((signed) => { + return { + token: signed.token, + expires: expiry.toISOString() + }; + }); + }); + } else { + // Create a moment of the expiry expression + let expiry = helpers.parseDatePeriod(data.expiry); + if (expiry === null) { + throw new error.AuthError('Invalid expiry time: ' + data.expiry); + } + + return Token.create({ + iss: issuer || 'api', + attrs: { + id: user.id + }, + scope: [data.scope], + expiresIn: data.expiry + }) + .then((signed) => { + return { + token: signed.token, + expires: expiry.toISOString() + }; + }); + } }); } else { throw new error.AuthError(ERROR_MESSAGE_INVALID_AUTH); diff --git a/backend/internal/user.js b/backend/internal/user.js index 742ab65d3..3a28a8ce1 100644 --- a/backend/internal/user.js +++ b/backend/internal/user.js @@ -507,7 +507,8 @@ const internalUser = { .then((user) => { return internalToken.getTokenFromUser(user); }); - } + }, + }; module.exports = internalUser; diff --git a/backend/migrations/20250115041439_mfa_integeration.js b/backend/migrations/20250115041439_mfa_integeration.js new file mode 100644 index 000000000..ed90495e5 --- /dev/null +++ b/backend/migrations/20250115041439_mfa_integeration.js @@ -0,0 +1,45 @@ +const migrate_name = 'identifier_for_migrate'; +const logger = require('../logger').migrate; + +/** + * Migrate + * + * @see http://knexjs.org/#Schema + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +exports.up = function (knex/*, Promise*/) { + + logger.info('[' + migrate_name + '] Migrating Up...'); + + return knex.schema.alterTable('auth', (table) => { + table.string('mfa_secret'); + table.boolean('mfa_enabled').defaultTo(false); + }) + .then(() => { + logger.info('[' + migrate_name + '] User Table altered'); + logger.info('[' + migrate_name + '] Migrating Up Complete'); + }); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +exports.down = function (knex/*, Promise*/) { + logger.info('[' + migrate_name + '] Migrating Down...'); + + return knex.schema.alterTable('auth', (table) => { + table.dropColumn('mfa_key'); + table.dropColumn('mfa_enabled'); + }) + .then(() => { + logger.info('[' + migrate_name + '] User Table altered'); + logger.info('[' + migrate_name + '] Migrating Down Complete'); + }); +}; diff --git a/backend/package.json b/backend/package.json index 30984a332..1b835844d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,8 +23,10 @@ "node-rsa": "^1.0.8", "objection": "3.0.1", "path": "^0.12.7", + "qrcode": "^1.5.4", "pg": "^8.13.1", "signale": "1.4.0", + "speakeasy": "^2.0.0", "sqlite3": "5.1.6", "temp-write": "^4.0.0" }, diff --git a/backend/routes/main.js b/backend/routes/main.js index b97096d0e..09e1bf760 100644 --- a/backend/routes/main.js +++ b/backend/routes/main.js @@ -27,6 +27,7 @@ router.get('/', (req, res/*, next*/) => { router.use('/schema', require('./schema')); router.use('/tokens', require('./tokens')); +router.use('/mfa', require('./mfa')); router.use('/users', require('./users')); router.use('/audit-log', require('./audit-log')); router.use('/reports', require('./reports')); diff --git a/backend/routes/mfa.js b/backend/routes/mfa.js new file mode 100644 index 000000000..100cda1eb --- /dev/null +++ b/backend/routes/mfa.js @@ -0,0 +1,81 @@ +const express = require('express'); +const jwtdecode = require('../lib/express/jwt-decode'); +const apiValidator = require('../lib/validator/api'); +const schema = require('../schema'); +const internalMfa = require('../internal/mfa'); +const qrcode = require('qrcode'); +const speakeasy = require('speakeasy'); +const userModel = require('../models/user'); + +let router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +router + .route('/create') + .post(jwtdecode(), (req, res, next) => { + if (!res.locals.access) { + return next(new Error('Invalid token')); + } + const userId = res.locals.access.token.getUserId(); + internalMfa.createMfaSecretForUser(userId) + .then((secret) => { + return userModel.query() + .where('id', '=', userId) + .first() + .then((user) => { + if (!user) { + return next(new Error('User not found')); + } + return { secret, user }; + }); + }) + .then(({ secret, user }) => { + const otpAuthUrl = speakeasy.otpauthURL({ + secret: secret.ascii, + label: user.email, + issuer: 'Nginx Proxy Manager' + }); + qrcode.toDataURL(otpAuthUrl, (err, dataUrl) => { + if (err) { + console.error('Error generating QR code:', err); + return next(err); + } + res.status(200).send({ qrCode: dataUrl }); + }); + }) + .catch(next); + }); + +router + .route('/enable') + .post(jwtdecode(), (req, res, next) => { + apiValidator(schema.getValidationSchema('/mfa/enable', 'post'), req.body).then((params) => { + internalMfa.enableMfaForUser(res.locals.access.token.getUserId(), params.token) + .then(() => res.status(200).send({ success: true })) + .catch(next); + } + ).catch(next); + }); + +router + .route('/check') + .get(jwtdecode(), (req, res, next) => { + internalMfa.isMfaEnabledForUser(res.locals.access.token.getUserId()) + .then((active) => res.status(200).send({ active })) + .catch(next); + }); + +router + .route('/delete') + .delete(jwtdecode(), (req, res, next) => { + apiValidator(schema.getValidationSchema('/mfa/delete', 'delete'), req.body).then((params) => { + internalMfa.disableMfaForUser(params, res.locals.access.token.getUserId()) + .then(() => res.status(200).send({ success: true })) + .catch(next); + }).catch(next); + }); + +module.exports = router; diff --git a/backend/schema/paths/mfa/delete/delete.json b/backend/schema/paths/mfa/delete/delete.json new file mode 100644 index 000000000..9b9b14153 --- /dev/null +++ b/backend/schema/paths/mfa/delete/delete.json @@ -0,0 +1,44 @@ +{ + "operationId": "disableMfa", + "summary": "Disable multi-factor authentication for a user", + "tags": [ + "MFA" + ], + "requestBody": { + "description": "Payload to disable MFA", + "required": true, + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "secret": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "secret" + ] + } + } + } + }, + "responses": { + "200": { + "description": "MFA disabled successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/backend/schema/paths/mfa/enable/post.json b/backend/schema/paths/mfa/enable/post.json new file mode 100644 index 000000000..33228781e --- /dev/null +++ b/backend/schema/paths/mfa/enable/post.json @@ -0,0 +1,44 @@ +{ + "operationId": "enableMfa", + "summary": "Enable multi-factor authentication for a user", + "tags": [ + "MFA" + ], + "requestBody": { + "description": "MFA Token Payload", + "required": true, + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "token": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "token" + ] + } + } + } + }, + "responses": { + "200": { + "description": "MFA enabled successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/backend/schema/paths/tokens/post.json b/backend/schema/paths/tokens/post.json index 99703ff0d..35287f725 100644 --- a/backend/schema/paths/tokens/post.json +++ b/backend/schema/paths/tokens/post.json @@ -22,6 +22,10 @@ "secret": { "minLength": 1, "type": "string" + }, + "mfa_token": { + "minLength": 1, + "type": "string" } }, "required": ["identity", "secret"], diff --git a/backend/schema/paths/users/post.json b/backend/schema/paths/users/post.json index c0213fe05..15aeb9f4d 100644 --- a/backend/schema/paths/users/post.json +++ b/backend/schema/paths/users/post.json @@ -14,7 +14,7 @@ "application/json": { "schema": { "type": "object", - "additionalProperties": false, + "additionalProperties": true, "required": ["name", "nickname", "email"], "properties": { "name": { diff --git a/backend/schema/swagger.json b/backend/schema/swagger.json index 5a0142bff..b7f2b9a3d 100644 --- a/backend/schema/swagger.json +++ b/backend/schema/swagger.json @@ -15,6 +15,16 @@ "$ref": "./paths/get.json" } }, + "/mfa/enable": { + "post": { + "$ref": "./paths/mfa/enable/post.json" + } + }, + "/mfa/delete": { + "delete": { + "$ref": "./paths/mfa/delete/delete.json" + } + }, "/audit-log": { "get": { "$ref": "./paths/audit-log/get.json" diff --git a/backend/yarn.lock b/backend/yarn.lock index cea8210bc..55723d375 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -2735,67 +2735,11 @@ path@^0.12.7: process "^0.11.1" util "^0.10.3" -pg-cloudflare@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz#e6d5833015b170e23ae819e8c5d7eaedb472ca98" - integrity sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q== - pg-connection-string@2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.5.0.tgz#538cadd0f7e603fc09a12590f3b8a452c2c0cf34" integrity sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ== -pg-connection-string@^2.7.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.7.0.tgz#f1d3489e427c62ece022dba98d5262efcb168b37" - integrity sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA== - -pg-int8@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" - integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== - -pg-pool@^3.7.0: - version "3.7.0" - resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.7.0.tgz#d4d3c7ad640f8c6a2245adc369bafde4ebb8cbec" - integrity sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g== - -pg-protocol@^1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.7.0.tgz#ec037c87c20515372692edac8b63cf4405448a93" - integrity sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ== - -pg-types@^2.1.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3" - integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA== - dependencies: - pg-int8 "1.0.1" - postgres-array "~2.0.0" - postgres-bytea "~1.0.0" - postgres-date "~1.0.4" - postgres-interval "^1.1.0" - -pg@^8.13.1: - version "8.13.1" - resolved "https://registry.yarnpkg.com/pg/-/pg-8.13.1.tgz#6498d8b0a87ff76c2df7a32160309d3168c0c080" - integrity sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ== - dependencies: - pg-connection-string "^2.7.0" - pg-pool "^3.7.0" - pg-protocol "^1.7.0" - pg-types "^2.1.0" - pgpass "1.x" - optionalDependencies: - pg-cloudflare "^1.1.1" - -pgpass@1.x: - version "1.0.5" - resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.5.tgz#9b873e4a564bb10fa7a7dbd55312728d422a223d" - integrity sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug== - dependencies: - split2 "^4.1.0" - picomatch@^2.0.4, picomatch@^2.2.1: version "2.2.2" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" @@ -2814,28 +2758,6 @@ pkg-conf@^2.1.0: find-up "^2.0.0" load-json-file "^4.0.0" -postgres-array@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" - integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== - -postgres-bytea@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.0.tgz#027b533c0aa890e26d172d47cf9ccecc521acd35" - integrity sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w== - -postgres-date@~1.0.4: - version "1.0.7" - resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8" - integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q== - -postgres-interval@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695" - integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ== - dependencies: - xtend "^4.0.0" - prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -3272,11 +3194,6 @@ socks@^2.6.2: ip "^2.0.0" smart-buffer "^4.2.0" -split2@^4.1.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" - integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== - sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" @@ -3748,11 +3665,6 @@ xdg-basedir@^4.0.0: resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== -xtend@^4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" - integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== - y18n@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.1.tgz#8db2b83c31c5d75099bb890b23f3094891e247d4" diff --git a/frontend/js/app/api.js b/frontend/js/app/api.js index 6e33a6dca..12497a6c7 100644 --- a/frontend/js/app/api.js +++ b/frontend/js/app/api.js @@ -202,7 +202,49 @@ module.exports = { return fetch('get', ''); }, + Mfa: { + create: function () { + return fetch('post', 'mfa/create'); + }, + enable: function (token) { + return fetch('post', 'mfa/enable', {token: token}); + }, + check: function () { + return fetch('get', 'mfa/check'); + }, + delete: function (secret) { + return fetch('delete', 'mfa/delete', {secret: secret}); + } + }, + Tokens: { + + /** + * + * @param {String} identity + * @param {String} secret + * @param {String} token + * @param {Boolean} wipe + * @returns {Promise} + */ + + loginWithMFA: function (identity, secret, mfaToken, wipe) { + return fetch('post', 'tokens', {identity: identity, secret: secret, mfa_token: mfaToken}) + .then(response => { + if (response.token) { + if (wipe) { + Tokens.clearTokens(); + } + + // Set storage token + Tokens.addToken(response.token); + return response.token; + } else { + Tokens.clearTokens(); + throw(new Error('No token returned')); + } + }); + }, /** * @param {String} identity diff --git a/frontend/js/app/user/form.ejs b/frontend/js/app/user/form.ejs index 9ba84438a..1e7bf9ed5 100644 --- a/frontend/js/app/user/form.ejs +++ b/frontend/js/app/user/form.ejs @@ -25,6 +25,27 @@
+ +