Add possibility to remove mfa

This commit is contained in:
Julian Gassner 2025-02-06 16:47:56 +00:00
parent 6228a54ecf
commit 0bfd2f901d
9 changed files with 168 additions and 57 deletions

View File

@ -1,5 +1,5 @@
const authModel = require('../models/auth'); const authModel = require('../models/auth');
const error = require('../lib/error'); const error = require('../lib/error');
const speakeasy = require('speakeasy'); const speakeasy = require('speakeasy');
module.exports = { module.exports = {
@ -13,10 +13,10 @@ module.exports = {
throw new error.AuthError('MFA is not enabled for this user.'); throw new error.AuthError('MFA is not enabled for this user.');
} }
const verified = speakeasy.totp.verify({ const verified = speakeasy.totp.verify({
secret: auth.mfa_secret, secret: auth.mfa_secret,
encoding: 'base32', encoding: 'base32',
token: token, token: token,
window: 2 window: 2
}); });
if (!verified) { if (!verified) {
throw new error.AuthError('Invalid MFA token.'); throw new error.AuthError('Invalid MFA token.');
@ -58,10 +58,10 @@ module.exports = {
throw new error.AuthError('MFA is not set up for this user.'); throw new error.AuthError('MFA is not set up for this user.');
} }
const verified = speakeasy.totp.verify({ const verified = speakeasy.totp.verify({
secret: auth.mfa_secret, secret: auth.mfa_secret,
encoding: 'base32', encoding: 'base32',
token: token, token: token,
window: 2 window: 2
}); });
if (!verified) { if (!verified) {
throw new error.AuthError('Invalid MFA token.'); throw new error.AuthError('Invalid MFA token.');
@ -73,4 +73,25 @@ module.exports = {
.then(() => 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 });
});
});
},
}; };

View File

@ -1,37 +1,18 @@
const express = require('express'); const express = require('express');
const jwtdecode = require('../lib/express/jwt-decode'); const jwtdecode = require('../lib/express/jwt-decode');
const apiValidator = require('../lib/validator/api'); const apiValidator = require('../lib/validator/api');
const internalToken = require('../internal/token'); const schema = require('../schema');
const schema = require('../schema'); const internalMfa = require('../internal/mfa');
const internalMfa = require('../internal/mfa'); const qrcode = require('qrcode');
const qrcode = require('qrcode'); const speakeasy = require('speakeasy');
const speakeasy = require('speakeasy'); const userModel = require('../models/user');
const userModel = require('../models/user');
let router = express.Router({ let router = express.Router({
caseSensitive: true, caseSensitive: true,
strict: true, strict: true,
mergeParams: true mergeParams: true
}); });
router
.route('/')
.options((_, res) => {
res.sendStatus(204);
})
.get(async (req, res, next) => {
internalToken.getFreshToken(res.locals.access, {
expiry: (typeof req.query.expiry !== 'undefined' ? req.query.expiry : null),
scope: (typeof req.query.scope !== 'undefined' ? req.query.scope : null)
})
.then((data) => {
res.status(200)
.send(data);
})
.catch(next);
});
router router
.route('/create') .route('/create')
.post(jwtdecode(), (req, res, next) => { .post(jwtdecode(), (req, res, next) => {
@ -54,7 +35,7 @@ router
.then(({ secret, user }) => { .then(({ secret, user }) => {
const otpAuthUrl = speakeasy.otpauthURL({ const otpAuthUrl = speakeasy.otpauthURL({
secret: secret.ascii, secret: secret.ascii,
label: user.email, label: user.email,
issuer: 'Nginx Proxy Manager' issuer: 'Nginx Proxy Manager'
}); });
qrcode.toDataURL(otpAuthUrl, (err, dataUrl) => { qrcode.toDataURL(otpAuthUrl, (err, dataUrl) => {
@ -71,12 +52,13 @@ router
router router
.route('/enable') .route('/enable')
.post(jwtdecode(), (req, res, next) => { .post(jwtdecode(), (req, res, next) => {
apiValidator(schema.getValidationSchema('/mfa', 'post'), req.body).then((params) => { apiValidator(schema.getValidationSchema('/mfa/enable', 'post'), req.body).then((params) => {
internalMfa.enableMfaForUser(res.locals.access.token.getUserId(), params.token) internalMfa.enableMfaForUser(res.locals.access.token.getUserId(), params.token)
.then(() => res.status(200).send({ success: true })) .then(() => res.status(200).send({ success: true }))
.catch(next); .catch(next);
} }
);}); ).catch(next);
});
router router
.route('/check') .route('/check')
@ -86,4 +68,14 @@ router
.catch(next); .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; module.exports = router;

View File

@ -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"
}
}
}
}
}
}
}
}

View File

@ -1,14 +1,15 @@
{ {
"operationId": "enableMfa", "operationId": "enableMfa",
"summary": "Enable multi-factor authentication for a user", "summary": "Enable multi-factor authentication for a user",
"tags": ["MFA"], "tags": [
"MFA"
],
"requestBody": { "requestBody": {
"description": "MFA Token Payload", "description": "MFA Token Payload",
"required": true, "required": true,
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"token": { "token": {
@ -16,7 +17,9 @@
"minLength": 1 "minLength": 1
} }
}, },
"required": ["token"] "required": [
"token"
]
} }
} }
} }
@ -38,4 +41,4 @@
} }
} }
} }
} }

View File

@ -15,9 +15,14 @@
"$ref": "./paths/get.json" "$ref": "./paths/get.json"
} }
}, },
"/mfa": { "/mfa/enable": {
"post": { "post": {
"$ref": "./paths/mfa/post.json" "$ref": "./paths/mfa/enable/post.json"
}
},
"/mfa/delete": {
"delete": {
"$ref": "./paths/mfa/delete/delete.json"
} }
}, },
"/audit-log": { "/audit-log": {

View File

@ -211,6 +211,9 @@ module.exports = {
}, },
check: function () { check: function () {
return fetch('get', 'mfa/check'); return fetch('get', 'mfa/check');
},
delete: function (secret) {
return fetch('delete', 'mfa/delete', {secret: secret});
} }
}, },

View File

@ -27,12 +27,22 @@
</div> </div>
<div class="col-sm-12 col-md-12"> <div class="col-sm-12 col-md-12">
<label class="form-label mfa-label" style="display: none;"><%- i18n('mfa', 'mfa') %></label> <label class="form-label mfa-label"><%- i18n('mfa', 'mfa') %></label>
<button type="button" class="btn btn-info add-mfa"><%- i18n('mfa', 'add-mfa') %></button> <button type="button" class="btn btn-info mfa-add"><%- i18n('mfa', 'mfa-add') %></button>
<button type="button" class="btn btn-danger mfa-remove" style="display: none;"><%- i18n('mfa', 'mfa-remove') %></button>
<div class="mfa-remove-confirm-container" style="display: none;">
<div class="form-group">
<label class="form-label"><%- i18n('mfa', 'confirm-password') %></label>
<input name="mfa_password" type="password" class="form-control mfa-remove-password-field" placeholder="<%- i18n('mfa', 'enter-password') %>">
<div class="invalid-feedback mfa-error"></div>
</div>
<button type="button" class="btn btn-danger mfa-remove-confirm"><%- i18n('mfa', 'confirm-remove-mfa') %></button>
</div>
<p class="qr-instructions" style="display: none;"><%- i18n('mfa', 'mfa-setup-instruction') %></p> <p class="qr-instructions" style="display: none;"><%- i18n('mfa', 'mfa-setup-instruction') %></p>
<div class="mfa-validation-container" style="display: none;"> <div class="mfa-validation-container" style="display: none;">
<label class="form-label"><%- i18n('mfa', 'mfa-token') %> <span class="form-required">*</span></label> <label class="form-label"><%- i18n('mfa', 'mfa-token') %> <span class="form-required">*</span></label>
<input name="mfa_validation" type="text" class="form-control" placeholder="000000" value=""> <input name="mfa_validation" type="text" class="form-control" placeholder="000000" value="">
<div class="invalid-feedback mfa-error"></div>
</div> </div>
</div> </div>

View File

@ -15,10 +15,14 @@ module.exports = Mn.View.extend({
cancel: 'button.cancel', cancel: 'button.cancel',
save: 'button.save', save: 'button.save',
error: '.secret-error', error: '.secret-error',
addMfa: '.add-mfa', mfaError: '.mfa-error',
mfaLabel: '.mfa-label', // added binding addMfa: '.mfa-add',
mfaValidation: '.mfa-validation-container', // added binding mfaValidation: '.mfa-validation-container',
qrInstructions: '.qr-instructions' // added binding for instructions qrInstructions: '.qr-instructions',
removeMfa: '.mfa-remove',
removeMfaConfirmContainer: '.mfa-remove-confirm-container',
removeMfaConfirm: '.mfa-remove-confirm',
removeMfaPassword: '.mfa-remove-password-field'
}, },
events: { events: {
@ -75,7 +79,6 @@ module.exports = Mn.View.extend({
return App.Api.Mfa.enable(mfaToken) return App.Api.Mfa.enable(mfaToken)
.then(() => result); .then(() => result);
} }
console.log(result);
return result; return result;
}) })
.then(result => { .then(result => {
@ -106,6 +109,31 @@ module.exports = Mn.View.extend({
.catch(err => { .catch(err => {
view.ui.error.text(err.message).show(); view.ui.error.text(err.message).show();
}); });
},
'click @ui.removeMfa': function (e) {
// Show confirmation section with a password field and confirm button
this.ui.removeMfa.hide();
this.ui.removeMfaConfirmContainer.show();
},
'click @ui.removeMfaConfirm': function (e) {
let view = this;
let password = view.ui.removeMfaPassword.val();
if (!password) {
view.ui.error.text('Password required to remove MFA').show();
return;
}
App.Api.Mfa.delete(password)
.then(() => {
view.ui.addMfa.show();
view.ui.qrInstructions.hide();
view.ui.mfaValidation.hide();
view.ui.removeMfaConfirmContainer.hide();
view.ui.removeMfa.hide();
view.ui.mfaValidation.find('input[name="mfa_validation"]').removeAttr('required');
})
.catch(err => {
view.ui.mfaError.text(err.message).show();
});
} }
}, },
@ -143,16 +171,17 @@ module.exports = Mn.View.extend({
.then(response => { .then(response => {
if (response.active) { if (response.active) {
view.ui.addMfa.hide(); view.ui.addMfa.hide();
view.ui.mfaLabel.hide();
view.ui.qrInstructions.hide(); view.ui.qrInstructions.hide();
view.ui.mfaValidation.hide(); view.ui.mfaValidation.hide();
// Remove required attribute if MFA is active & field is hidden view.ui.removeMfa.show();
view.ui.removeMfaConfirmContainer.hide();
view.ui.mfaValidation.find('input[name="mfa_validation"]').removeAttr('required'); view.ui.mfaValidation.find('input[name="mfa_validation"]').removeAttr('required');
} else { } else {
view.ui.addMfa.show(); view.ui.addMfa.show();
view.ui.mfaLabel.show();
view.ui.qrInstructions.hide(); view.ui.qrInstructions.hide();
view.ui.mfaValidation.hide(); view.ui.mfaValidation.hide();
view.ui.removeMfa.hide();
view.ui.removeMfaConfirmContainer.hide();
view.ui.mfaValidation.find('input[name="mfa_validation"]').removeAttr('required'); view.ui.mfaValidation.find('input[name="mfa_validation"]').removeAttr('required');
} }
}) })

View File

@ -39,9 +39,13 @@
}, },
"mfa": { "mfa": {
"mfa": "Multi Factor Authentication", "mfa": "Multi Factor Authentication",
"add-mfa": "Generate secret", "mfa-add": "Add Multi Factor Authentication",
"mfa-remove": "Remove Multi Factor Authentication",
"mfa-setup-instruction": "Scan this QR code in your authenticator app to set up MFA and then enter the current MFA code in the input field.", "mfa-setup-instruction": "Scan this QR code in your authenticator app to set up MFA and then enter the current MFA code in the input field.",
"mfa-token": "Multi factor authentication token" "mfa-token": "Multi factor authentication token",
"confirm-password": "Please enter your password to confirm",
"enter-password": "Enter Password",
"confirm-remove-mfa": "Confirm Multi Factor Authentication removal"
}, },
"login": { "login": {
"title": "Login to your account", "title": "Login to your account",