Compare commits

..

16 Commits

Author SHA1 Message Date
Julian Gassner
2df79f75cb Merge 34194e65d2 into 3a01b2c84f 2025-02-05 16:49:04 +00:00
Julian Gassner
34194e65d2 Update messages 2025-02-05 16:48:57 +00:00
Julian Gassner
5d2a76adfe Merge branch 'feature/add-multi-factor-authentication' of https://github.com/badkeyy/nginx-proxy-manager into feature/add-multi-factor-authentication 2025-02-05 07:10:35 +00:00
Julian Gassner
33906bfcdc Remove package-lock 2025-02-05 07:10:34 +00:00
Julian Gassner
6e51d819fa Merge branch 'develop' into feature/add-multi-factor-authentication 2025-02-05 08:08:51 +01:00
Julian Gassner
8aa173a732 Finish MFA implementation 2025-02-05 07:05:15 +00:00
Julian Gassner
35938db24b Added MFA to model and internal user 2025-01-15 14:33:11 +00:00
Julian Gassner
69f7920675 Remove console.logs 2025-01-09 23:04:13 +00:00
Julian Gassner
45fc63875c Merge branch 'feature/show-active-host-in-cert-list' of https://github.com/badkeyy/nginx-proxy-manager into feature/show-active-host-in-cert-list 2025-01-09 22:49:21 +00:00
Julian Gassner
f3fee7d886 Merge 2025-01-09 22:48:18 +00:00
Julian Gassner
50f7bfc726 Add dead_hosts and redirection_hosts 2025-01-09 22:47:08 +00:00
Julian Gassner
8fb9d9fec6 Removed whitespace 2025-01-09 18:32:45 +01:00
Julian Gassner
cea9a17218 Retrigger build 2025-01-09 18:07:50 +01:00
Julian Gassner
6bbe7d4cc4 Fix alignment 2025-01-08 17:48:09 +01:00
Julian Gassner
c6ab315165 Add status indicator to certificates and show active domain names 2025-01-08 05:43:10 +01:00
Julian Gassner
2c1595756d Add hosts to cert list 2024-12-18 18:10:20 +01:00
24 changed files with 1678 additions and 1957 deletions

View File

@@ -1 +1 @@
2.12.3 2.12.2

View File

@@ -1,7 +1,7 @@
<p align="center"> <p align="center">
<img src="https://nginxproxymanager.com/github.png"> <img src="https://nginxproxymanager.com/github.png">
<br><br> <br><br>
<img src="https://img.shields.io/badge/version-2.12.3-green.svg?style=for-the-badge"> <img src="https://img.shields.io/badge/version-2.12.2-green.svg?style=for-the-badge">
<a href="https://hub.docker.com/repository/docker/jc21/nginx-proxy-manager"> <a href="https://hub.docker.com/repository/docker/jc21/nginx-proxy-manager">
<img src="https://img.shields.io/docker/stars/jc21/nginx-proxy-manager.svg?style=for-the-badge"> <img src="https://img.shields.io/docker/stars/jc21/nginx-proxy-manager.svg?style=for-the-badge">
</a> </a>

View File

@@ -508,13 +508,8 @@ const internalAccessList = {
if (typeof item.password !== 'undefined' && item.password.length) { if (typeof item.password !== 'undefined' && item.password.length) {
logger.info('Adding: ' + item.username); logger.info('Adding: ' + item.username);
utils.execFile('openssl', ['passwd', '-apr1', item.password]) utils.execFile('/usr/bin/htpasswd', ['-b', htpasswd_file, item.username, item.password])
.then((res) => { .then((/*result*/) => {
try {
fs.appendFileSync(htpasswd_file, item.username + ':' + res + '\n', {encoding: 'utf8'});
} catch (err) {
reject(err);
}
next(); next();
}) })
.catch((err) => { .catch((err) => {

76
backend/internal/mfa.js Normal file
View File

@@ -0,0 +1,76 @@
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);
});
},
};

View File

@@ -4,6 +4,7 @@ const userModel = require('../models/user');
const authModel = require('../models/auth'); const authModel = require('../models/auth');
const helpers = require('../lib/helpers'); const helpers = require('../lib/helpers');
const TokenModel = require('../models/token'); const TokenModel = require('../models/token');
const mfa = require('../internal/mfa'); // <-- added MFA import
const ERROR_MESSAGE_INVALID_AUTH = 'Invalid email or password'; const ERROR_MESSAGE_INVALID_AUTH = 'Invalid email or password';
@@ -21,6 +22,8 @@ module.exports = {
getTokenFromEmail: (data, issuer) => { getTokenFromEmail: (data, issuer) => {
let Token = new TokenModel(); let Token = new TokenModel();
console.log(data);
data.scope = data.scope || 'user'; data.scope = data.scope || 'user';
data.expiry = data.expiry || '1d'; data.expiry = data.expiry || '1d';
@@ -41,34 +44,66 @@ module.exports = {
.then((auth) => { .then((auth) => {
if (auth) { if (auth) {
return auth.verifyPassword(data.secret) return auth.verifyPassword(data.secret)
.then((valid) => { .then(async (valid) => {
if (valid) { if (valid) {
if (data.scope !== 'user' && _.indexOf(user.roles, data.scope) === -1) { 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); throw new error.AuthError('Invalid scope: ' + data.scope);
} }
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);
}
// Create a moment of the expiry expression return Token.create({
let expiry = helpers.parseDatePeriod(data.expiry); iss: issuer || 'api',
if (expiry === null) { attrs: {
throw new error.AuthError('Invalid expiry time: ' + data.expiry); 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({ return Token.create({
iss: issuer || 'api', iss: issuer || 'api',
attrs: { attrs: {
id: user.id id: user.id
}, },
scope: [data.scope], scope: [data.scope],
expiresIn: data.expiry expiresIn: data.expiry
}) })
.then((signed) => { .then((signed) => {
return { return {
token: signed.token, token: signed.token,
expires: expiry.toISOString() expires: expiry.toISOString()
}; };
});
}
}); });
} else { } else {
throw new error.AuthError(ERROR_MESSAGE_INVALID_AUTH); throw new error.AuthError(ERROR_MESSAGE_INVALID_AUTH);

View File

@@ -507,7 +507,8 @@ const internalUser = {
.then((user) => { .then((user) => {
return internalToken.getTokenFromUser(user); return internalToken.getTokenFromUser(user);
}); });
} },
}; };
module.exports = internalUser; module.exports = internalUser;

View File

@@ -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');
});
};

View File

@@ -23,8 +23,10 @@
"node-rsa": "^1.0.8", "node-rsa": "^1.0.8",
"objection": "3.0.1", "objection": "3.0.1",
"path": "^0.12.7", "path": "^0.12.7",
"qrcode": "^1.5.4",
"pg": "^8.13.1", "pg": "^8.13.1",
"signale": "1.4.0", "signale": "1.4.0",
"speakeasy": "^2.0.0",
"sqlite3": "5.1.6", "sqlite3": "5.1.6",
"temp-write": "^4.0.0" "temp-write": "^4.0.0"
}, },

View File

@@ -27,6 +27,7 @@ router.get('/', (req, res/*, next*/) => {
router.use('/schema', require('./schema')); router.use('/schema', require('./schema'));
router.use('/tokens', require('./tokens')); router.use('/tokens', require('./tokens'));
router.use('/mfa', require('./mfa'));
router.use('/users', require('./users')); router.use('/users', require('./users'));
router.use('/audit-log', require('./audit-log')); router.use('/audit-log', require('./audit-log'));
router.use('/reports', require('./reports')); router.use('/reports', require('./reports'));

89
backend/routes/mfa.js Normal file
View File

@@ -0,0 +1,89 @@
const express = require('express');
const jwtdecode = require('../lib/express/jwt-decode');
const apiValidator = require('../lib/validator/api');
const internalToken = require('../internal/token');
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('/')
.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
.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', 'post'), req.body).then((params) => {
internalMfa.enableMfaForUser(res.locals.access.token.getUserId(), params.token)
.then(() => res.status(200).send({ success: true }))
.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);
});
module.exports = router;

View File

@@ -0,0 +1,41 @@
{
"operationId": "enableMfa",
"summary": "Enable multi-factor authentication for a user",
"tags": ["MFA"],
"requestBody": {
"description": "MFA Token Payload",
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"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"
}
}
}
}
}
}
}
}

View File

@@ -22,6 +22,10 @@
"secret": { "secret": {
"minLength": 1, "minLength": 1,
"type": "string" "type": "string"
},
"mfa_token": {
"minLength": 1,
"type": "string"
} }
}, },
"required": ["identity", "secret"], "required": ["identity", "secret"],

View File

@@ -14,7 +14,7 @@
"application/json": { "application/json": {
"schema": { "schema": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": true,
"required": ["name", "nickname", "email"], "required": ["name", "nickname", "email"],
"properties": { "properties": {
"name": { "name": {

View File

@@ -15,6 +15,11 @@
"$ref": "./paths/get.json" "$ref": "./paths/get.json"
} }
}, },
"/mfa": {
"post": {
"$ref": "./paths/mfa/post.json"
}
},
"/audit-log": { "/audit-log": {
"get": { "get": {
"$ref": "./paths/audit-log/get.json" "$ref": "./paths/audit-log/get.json"

File diff suppressed because it is too large Load Diff

View File

@@ -202,7 +202,46 @@ module.exports = {
return fetch('get', ''); 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');
}
},
Tokens: { 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 * @param {String} identity

View File

@@ -4,438 +4,444 @@ const Tokens = require('./tokens');
module.exports = { module.exports = {
/** /**
* @param {String} route * @param {String} route
* @param {Object} [options] * @param {Object} [options]
* @returns {Boolean} * @returns {Boolean}
*/ */
navigate: function (route, options) { navigate: function (route, options) {
options = options || {}; options = options || {};
Backbone.history.navigate(route.toString(), options); Backbone.history.navigate(route.toString(), options);
return true; return true;
}, },
/** /**
* Login * Login
*/ */
showLogin: function () { showLogin: function () {
window.location = '/login'; window.location = '/login';
}, },
/** /**
* Users * Users
*/ */
showUsers: function () { showUsers: function () {
const controller = this; let controller = this;
if (Cache.User.isAdmin()) { if (Cache.User.isAdmin()) {
require(['./main', './users/main'], (App, View) => { require(['./main', './users/main'], (App, View) => {
controller.navigate('/users'); controller.navigate('/users');
App.UI.showAppContent(new View()); App.UI.showAppContent(new View());
}); });
} else { } else {
this.showDashboard(); this.showDashboard();
} }
}, },
/** /**
* User Form * User Form
* *
* @param [model] * @param [model]
*/ */
showUserForm: function (model) { showUserForm: function (model) {
if (Cache.User.isAdmin()) { if (Cache.User.isAdmin()) {
require(['./main', './user/form'], function (App, View) { require(['./main', './user/form'], function (App, View) {
App.UI.showModalDialog(new View({model: model})); App.UI.showModalDialog(new View({model: model}));
}); });
} }
}, },
/** /**
* User Permissions Form * User Permissions Form
* *
* @param model * @param model
*/ */
showUserPermissions: function (model) { showUserPermissions: function (model) {
if (Cache.User.isAdmin()) { if (Cache.User.isAdmin()) {
require(['./main', './user/permissions'], function (App, View) { require(['./main', './user/permissions'], function (App, View) {
App.UI.showModalDialog(new View({model: model})); App.UI.showModalDialog(new View({model: model}));
}); });
} }
}, },
/** /**
* User Password Form * User Password Form
* *
* @param model * @param model
*/ */
showUserPasswordForm: function (model) { showUserPasswordForm: function (model) {
if (Cache.User.isAdmin() || model.get('id') === Cache.User.get('id')) { if (Cache.User.isAdmin() || model.get('id') === Cache.User.get('id')) {
require(['./main', './user/password'], function (App, View) { require(['./main', './user/password'], function (App, View) {
App.UI.showModalDialog(new View({model: model})); App.UI.showModalDialog(new View({model: model}));
}); });
} }
}, },
/** /**
* User Delete Confirm * User Delete Confirm
* *
* @param model * @param model
*/ */
showUserDeleteConfirm: function (model) { showUserDeleteConfirm: function (model) {
if (Cache.User.isAdmin() && model.get('id') !== Cache.User.get('id')) { if (Cache.User.isAdmin() && model.get('id') !== Cache.User.get('id')) {
require(['./main', './user/delete'], function (App, View) { require(['./main', './user/delete'], function (App, View) {
App.UI.showModalDialog(new View({model: model})); App.UI.showModalDialog(new View({model: model}));
}); });
} }
}, },
/** /**
* Dashboard * Dashboard
*/ */
showDashboard: function () { showDashboard: function () {
const controller = this; let controller = this;
require(['./main', './dashboard/main'], (App, View) => {
controller.navigate('/');
App.UI.showAppContent(new View());
});
},
/** require(['./main', './dashboard/main'], (App, View) => {
* Nginx Proxy Hosts controller.navigate('/');
*/ App.UI.showAppContent(new View());
showNginxProxy: function () { });
if (Cache.User.isAdmin() || Cache.User.canView('proxy_hosts')) { },
const controller = this;
require(['./main', './nginx/proxy/main'], (App, View) => { /**
controller.navigate('/nginx/proxy'); * Nginx Proxy Hosts
App.UI.showAppContent(new View()); */
}); showNginxProxy: function () {
} if (Cache.User.isAdmin() || Cache.User.canView('proxy_hosts')) {
}, let controller = this;
/** require(['./main', './nginx/proxy/main'], (App, View) => {
* Nginx Proxy Host Form controller.navigate('/nginx/proxy');
* App.UI.showAppContent(new View());
* @param [model] });
*/ }
showNginxProxyForm: function (model) { },
if (Cache.User.isAdmin() || Cache.User.canManage('proxy_hosts')) {
require(['./main', './nginx/proxy/form'], function (App, View) {
App.UI.showModalDialog(new View({model: model}));
});
}
},
/** /**
* Proxy Host Delete Confirm * Nginx Proxy Host Form
* *
* @param model * @param [model]
*/ */
showNginxProxyDeleteConfirm: function (model) { showNginxProxyForm: function (model) {
if (Cache.User.isAdmin() || Cache.User.canManage('proxy_hosts')) { if (Cache.User.isAdmin() || Cache.User.canManage('proxy_hosts')) {
require(['./main', './nginx/proxy/delete'], function (App, View) { require(['./main', './nginx/proxy/form'], function (App, View) {
App.UI.showModalDialog(new View({model: model})); App.UI.showModalDialog(new View({model: model}));
}); });
} }
}, },
/** /**
* Nginx Redirection Hosts * Proxy Host Delete Confirm
*/ *
showNginxRedirection: function () { * @param model
if (Cache.User.isAdmin() || Cache.User.canView('redirection_hosts')) { */
const controller = this; showNginxProxyDeleteConfirm: function (model) {
require(['./main', './nginx/redirection/main'], (App, View) => { if (Cache.User.isAdmin() || Cache.User.canManage('proxy_hosts')) {
controller.navigate('/nginx/redirection'); require(['./main', './nginx/proxy/delete'], function (App, View) {
App.UI.showAppContent(new View()); App.UI.showModalDialog(new View({model: model}));
}); });
} }
}, },
/** /**
* Nginx Redirection Host Form * Nginx Redirection Hosts
* */
* @param [model] showNginxRedirection: function () {
*/ if (Cache.User.isAdmin() || Cache.User.canView('redirection_hosts')) {
showNginxRedirectionForm: function (model) { let controller = this;
if (Cache.User.isAdmin() || Cache.User.canManage('redirection_hosts')) {
require(['./main', './nginx/redirection/form'], function (App, View) {
App.UI.showModalDialog(new View({model: model}));
});
}
},
/** require(['./main', './nginx/redirection/main'], (App, View) => {
* Proxy Redirection Delete Confirm controller.navigate('/nginx/redirection');
* App.UI.showAppContent(new View());
* @param model });
*/ }
showNginxRedirectionDeleteConfirm: function (model) { },
if (Cache.User.isAdmin() || Cache.User.canManage('redirection_hosts')) {
require(['./main', './nginx/redirection/delete'], function (App, View) {
App.UI.showModalDialog(new View({model: model}));
});
}
},
/** /**
* Nginx Stream Hosts * Nginx Redirection Host Form
*/ *
showNginxStream: function () { * @param [model]
if (Cache.User.isAdmin() || Cache.User.canView('streams')) { */
const controller = this; showNginxRedirectionForm: function (model) {
require(['./main', './nginx/stream/main'], (App, View) => { if (Cache.User.isAdmin() || Cache.User.canManage('redirection_hosts')) {
controller.navigate('/nginx/stream'); require(['./main', './nginx/redirection/form'], function (App, View) {
App.UI.showAppContent(new View()); App.UI.showModalDialog(new View({model: model}));
}); });
} }
}, },
/** /**
* Stream Form * Proxy Redirection Delete Confirm
* *
* @param [model] * @param model
*/ */
showNginxStreamForm: function (model) { showNginxRedirectionDeleteConfirm: function (model) {
if (Cache.User.isAdmin() || Cache.User.canManage('streams')) { if (Cache.User.isAdmin() || Cache.User.canManage('redirection_hosts')) {
require(['./main', './nginx/stream/form'], function (App, View) { require(['./main', './nginx/redirection/delete'], function (App, View) {
App.UI.showModalDialog(new View({model: model})); App.UI.showModalDialog(new View({model: model}));
}); });
} }
}, },
/** /**
* Stream Delete Confirm * Nginx Stream Hosts
* */
* @param model showNginxStream: function () {
*/ if (Cache.User.isAdmin() || Cache.User.canView('streams')) {
showNginxStreamDeleteConfirm: function (model) { let controller = this;
if (Cache.User.isAdmin() || Cache.User.canManage('streams')) {
require(['./main', './nginx/stream/delete'], function (App, View) {
App.UI.showModalDialog(new View({model: model}));
});
}
},
/** require(['./main', './nginx/stream/main'], (App, View) => {
* Nginx Dead Hosts controller.navigate('/nginx/stream');
*/ App.UI.showAppContent(new View());
showNginxDead: function () { });
if (Cache.User.isAdmin() || Cache.User.canView('dead_hosts')) { }
const controller = this; },
require(['./main', './nginx/dead/main'], (App, View) => {
controller.navigate('/nginx/404');
App.UI.showAppContent(new View());
});
}
},
/** /**
* Dead Host Form * Stream Form
* *
* @param [model] * @param [model]
*/ */
showNginxDeadForm: function (model) { showNginxStreamForm: function (model) {
if (Cache.User.isAdmin() || Cache.User.canManage('dead_hosts')) { if (Cache.User.isAdmin() || Cache.User.canManage('streams')) {
require(['./main', './nginx/dead/form'], function (App, View) { require(['./main', './nginx/stream/form'], function (App, View) {
App.UI.showModalDialog(new View({model: model})); App.UI.showModalDialog(new View({model: model}));
}); });
} }
}, },
/** /**
* Dead Host Delete Confirm * Stream Delete Confirm
* *
* @param model * @param model
*/ */
showNginxDeadDeleteConfirm: function (model) { showNginxStreamDeleteConfirm: function (model) {
if (Cache.User.isAdmin() || Cache.User.canManage('dead_hosts')) { if (Cache.User.isAdmin() || Cache.User.canManage('streams')) {
require(['./main', './nginx/dead/delete'], function (App, View) { require(['./main', './nginx/stream/delete'], function (App, View) {
App.UI.showModalDialog(new View({model: model})); App.UI.showModalDialog(new View({model: model}));
}); });
} }
}, },
/** /**
* Help Dialog * Nginx Dead Hosts
* */
* @param {String} title showNginxDead: function () {
* @param {String} content if (Cache.User.isAdmin() || Cache.User.canView('dead_hosts')) {
*/ let controller = this;
showHelp: function (title, content) {
require(['./main', './help/main'], function (App, View) {
App.UI.showModalDialog(new View({title: title, content: content}));
});
},
/** require(['./main', './nginx/dead/main'], (App, View) => {
* Nginx Access controller.navigate('/nginx/404');
*/ App.UI.showAppContent(new View());
showNginxAccess: function () { });
if (Cache.User.isAdmin() || Cache.User.canView('access_lists')) { }
const controller = this; },
require(['./main', './nginx/access/main'], (App, View) => {
controller.navigate('/nginx/access');
App.UI.showAppContent(new View());
});
}
},
/** /**
* Nginx Access List Form * Dead Host Form
* *
* @param [model] * @param [model]
*/ */
showNginxAccessListForm: function (model) { showNginxDeadForm: function (model) {
if (Cache.User.isAdmin() || Cache.User.canManage('access_lists')) { if (Cache.User.isAdmin() || Cache.User.canManage('dead_hosts')) {
require(['./main', './nginx/access/form'], function (App, View) { require(['./main', './nginx/dead/form'], function (App, View) {
App.UI.showModalDialog(new View({model: model})); App.UI.showModalDialog(new View({model: model}));
}); });
} }
}, },
/** /**
* Access List Delete Confirm * Dead Host Delete Confirm
* *
* @param model * @param model
*/ */
showNginxAccessListDeleteConfirm: function (model) { showNginxDeadDeleteConfirm: function (model) {
if (Cache.User.isAdmin() || Cache.User.canManage('access_lists')) { if (Cache.User.isAdmin() || Cache.User.canManage('dead_hosts')) {
require(['./main', './nginx/access/delete'], function (App, View) { require(['./main', './nginx/dead/delete'], function (App, View) {
App.UI.showModalDialog(new View({model: model})); App.UI.showModalDialog(new View({model: model}));
}); });
} }
}, },
/** /**
* Nginx Certificates * Help Dialog
*/ *
showNginxCertificates: function () { * @param {String} title
if (Cache.User.isAdmin() || Cache.User.canView('certificates')) { * @param {String} content
const controller = this; */
require(['./main', './nginx/certificates/main'], (App, View) => { showHelp: function (title, content) {
controller.navigate('/nginx/certificates'); require(['./main', './help/main'], function (App, View) {
App.UI.showAppContent(new View()); App.UI.showModalDialog(new View({title: title, content: content}));
}); });
} },
},
/** /**
* Nginx Certificate Form * Nginx Access
* */
* @param [model] showNginxAccess: function () {
*/ if (Cache.User.isAdmin() || Cache.User.canView('access_lists')) {
showNginxCertificateForm: function (model) { let controller = this;
if (Cache.User.isAdmin() || Cache.User.canManage('certificates')) {
require(['./main', './nginx/certificates/form'], function (App, View) {
App.UI.showModalDialog(new View({model: model}));
});
}
},
/** require(['./main', './nginx/access/main'], (App, View) => {
* Certificate Renew controller.navigate('/nginx/access');
* App.UI.showAppContent(new View());
* @param model });
*/ }
showNginxCertificateRenew: function (model) { },
if (Cache.User.isAdmin() || Cache.User.canManage('certificates')) {
require(['./main', './nginx/certificates/renew'], function (App, View) {
App.UI.showModalDialog(new View({model: model}));
});
}
},
/** /**
* Certificate Delete Confirm * Nginx Access List Form
* *
* @param model * @param [model]
*/ */
showNginxCertificateDeleteConfirm: function (model) { showNginxAccessListForm: function (model) {
if (Cache.User.isAdmin() || Cache.User.canManage('certificates')) { if (Cache.User.isAdmin() || Cache.User.canManage('access_lists')) {
require(['./main', './nginx/certificates/delete'], function (App, View) { require(['./main', './nginx/access/form'], function (App, View) {
App.UI.showModalDialog(new View({model: model})); App.UI.showModalDialog(new View({model: model}));
}); });
} }
}, },
/** /**
* Certificate Test Reachability * Access List Delete Confirm
* *
* @param model * @param model
*/ */
showNginxCertificateTestReachability: function (model) { showNginxAccessListDeleteConfirm: function (model) {
if (Cache.User.isAdmin() || Cache.User.canManage('certificates')) { if (Cache.User.isAdmin() || Cache.User.canManage('access_lists')) {
require(['./main', './nginx/certificates/test'], function (App, View) { require(['./main', './nginx/access/delete'], function (App, View) {
App.UI.showModalDialog(new View({model: model})); App.UI.showModalDialog(new View({model: model}));
}); });
} }
}, },
/** /**
* Audit Log * Nginx Certificates
*/ */
showAuditLog: function () { showNginxCertificates: function () {
const controller = this; if (Cache.User.isAdmin() || Cache.User.canView('certificates')) {
if (Cache.User.isAdmin()) { let controller = this;
require(['./main', './audit-log/main'], (App, View) => {
controller.navigate('/audit-log');
App.UI.showAppContent(new View());
});
} else {
this.showDashboard();
}
},
/** require(['./main', './nginx/certificates/main'], (App, View) => {
* Audit Log Metadata controller.navigate('/nginx/certificates');
* App.UI.showAppContent(new View());
* @param model });
*/ }
showAuditMeta: function (model) { },
if (Cache.User.isAdmin()) {
require(['./main', './audit-log/meta'], function (App, View) {
App.UI.showModalDialog(new View({model: model}));
});
}
},
/** /**
* Settings * Nginx Certificate Form
*/ *
showSettings: function () { * @param [model]
const controller = this; */
if (Cache.User.isAdmin()) { showNginxCertificateForm: function (model) {
require(['./main', './settings/main'], (App, View) => { if (Cache.User.isAdmin() || Cache.User.canManage('certificates')) {
controller.navigate('/settings'); require(['./main', './nginx/certificates/form'], function (App, View) {
App.UI.showAppContent(new View()); App.UI.showModalDialog(new View({model: model}));
}); });
} else { }
this.showDashboard(); },
}
},
/** /**
* Settings Item Form * Certificate Renew
* *
* @param model * @param model
*/ */
showSettingForm: function (model) { showNginxCertificateRenew: function (model) {
if (Cache.User.isAdmin()) { if (Cache.User.isAdmin() || Cache.User.canManage('certificates')) {
if (model.get('id') === 'default-site') { require(['./main', './nginx/certificates/renew'], function (App, View) {
require(['./main', './settings/default-site/main'], function (App, View) { App.UI.showModalDialog(new View({model: model}));
App.UI.showModalDialog(new View({model: model})); });
}); }
} },
}
},
/** /**
* Logout * Certificate Delete Confirm
*/ *
logout: function () { * @param model
Tokens.dropTopToken(); */
this.showLogin(); showNginxCertificateDeleteConfirm: function (model) {
} if (Cache.User.isAdmin() || Cache.User.canManage('certificates')) {
require(['./main', './nginx/certificates/delete'], function (App, View) {
App.UI.showModalDialog(new View({model: model}));
});
}
},
/**
* Certificate Test Reachability
*
* @param model
*/
showNginxCertificateTestReachability: function (model) {
if (Cache.User.isAdmin() || Cache.User.canManage('certificates')) {
require(['./main', './nginx/certificates/test'], function (App, View) {
App.UI.showModalDialog(new View({model: model}));
});
}
},
/**
* Audit Log
*/
showAuditLog: function () {
let controller = this;
if (Cache.User.isAdmin()) {
require(['./main', './audit-log/main'], (App, View) => {
controller.navigate('/audit-log');
App.UI.showAppContent(new View());
});
} else {
this.showDashboard();
}
},
/**
* Audit Log Metadata
*
* @param model
*/
showAuditMeta: function (model) {
if (Cache.User.isAdmin()) {
require(['./main', './audit-log/meta'], function (App, View) {
App.UI.showModalDialog(new View({model: model}));
});
}
},
/**
* Settings
*/
showSettings: function () {
let controller = this;
if (Cache.User.isAdmin()) {
require(['./main', './settings/main'], (App, View) => {
controller.navigate('/settings');
App.UI.showAppContent(new View());
});
} else {
this.showDashboard();
}
},
/**
* Settings Item Form
*
* @param model
*/
showSettingForm: function (model) {
if (Cache.User.isAdmin()) {
if (model.get('id') === 'default-site') {
require(['./main', './settings/default-site/main'], function (App, View) {
App.UI.showModalDialog(new View({model: model}));
});
}
}
},
/**
* Logout
*/
logout: function () {
Tokens.dropTopToken();
this.showLogin();
}
}; };

View File

@@ -6,85 +6,87 @@ const Helpers = require('../../lib/helpers');
const template = require('./main.ejs'); const template = require('./main.ejs');
module.exports = Mn.View.extend({ module.exports = Mn.View.extend({
template: template, template: template,
id: 'dashboard', id: 'dashboard',
columns: 0, columns: 0,
stats: {}, stats: {},
ui: { ui: {
links: 'a' links: 'a'
}, },
events: { events: {
'click @ui.links': function (e) { 'click @ui.links': function (e) {
e.preventDefault(); e.preventDefault();
Controller.navigate($(e.currentTarget).attr('href'), true); Controller.navigate($(e.currentTarget).attr('href'), true);
} }
}, },
templateContext: function () { templateContext: function () {
const view = this; let view = this;
return { return {
getUserName: function () { getUserName: function () {
return Cache.User.get('nickname') || Cache.User.get('name'); return Cache.User.get('nickname') || Cache.User.get('name');
}, },
getHostStat: function (type) { getHostStat: function (type) {
if (view.stats && typeof view.stats.hosts !== 'undefined' && typeof view.stats.hosts[type] !== 'undefined') { if (view.stats && typeof view.stats.hosts !== 'undefined' && typeof view.stats.hosts[type] !== 'undefined') {
return Helpers.niceNumber(view.stats.hosts[type]); return Helpers.niceNumber(view.stats.hosts[type]);
} }
return '-'; return '-';
}, },
canShow: function (perm) { canShow: function (perm) {
return Cache.User.isAdmin() || Cache.User.canView(perm); return Cache.User.isAdmin() || Cache.User.canView(perm);
}, },
columns: view.columns columns: view.columns
}; };
}, },
onRender: function () { onRender: function () {
const view = this; let view = this;
if (typeof view.stats.hosts === 'undefined') {
Api.Reports.getHostStats()
.then(response => {
if (!view.isDestroyed()) {
view.stats.hosts = response;
view.render();
}
})
.catch(err => {
console.log(err);
});
}
},
/** if (typeof view.stats.hosts === 'undefined') {
* @param {Object} [model] Api.Reports.getHostStats()
*/ .then(response => {
preRender: function (model) { if (!view.isDestroyed()) {
this.columns = 0; view.stats.hosts = response;
view.render();
}
})
.catch(err => {
console.log(err);
});
}
},
// calculate the available columns based on permissions for the objects /**
// and store as a variable * @param {Object} [model]
const perms = ['proxy_hosts', 'redirection_hosts', 'streams', 'dead_hosts']; */
preRender: function (model) {
this.columns = 0;
perms.map(perm => { // calculate the available columns based on permissions for the objects
this.columns += Cache.User.isAdmin() || Cache.User.canView(perm) ? 1 : 0; // and store as a variable
}); //let view = this;
let perms = ['proxy_hosts', 'redirection_hosts', 'streams', 'dead_hosts'];
// Prevent double rendering on initial calls perms.map(perm => {
if (typeof model !== 'undefined') { this.columns += Cache.User.isAdmin() || Cache.User.canView(perm) ? 1 : 0;
this.render(); });
}
},
initialize: function () { // Prevent double rendering on initial calls
this.preRender(); if (typeof model !== 'undefined') {
this.listenTo(Cache.User, 'change', this.preRender); this.render();
} }
},
initialize: function () {
this.preRender();
this.listenTo(Cache.User, 'change', this.preRender);
}
}); });

View File

@@ -25,6 +25,17 @@
<div class="invalid-feedback secret-error"></div> <div class="invalid-feedback secret-error"></div>
</div> </div>
</div> </div>
<div class="col-sm-12 col-md-12">
<label class="form-label mfa-label" style="display: none;"><%- i18n('mfa', 'mfa') %></label>
<button type="button" class="btn btn-info add-mfa"><%- i18n('mfa', 'add-mfa') %></button>
<p class="qr-instructions" style="display: none;"><%- i18n('mfa', 'mfa-setup-instruction') %></p>
<div class="mfa-validation-container" style="display: none;">
<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="">
</div>
</div>
<% if (isAdmin() && !isSelf()) { %> <% if (isAdmin() && !isSelf()) { %>
<div class="col-sm-12 col-md-12"> <div class="col-sm-12 col-md-12">
<div class="form-label"><%- i18n('roles', 'title') %></div> <div class="form-label"><%- i18n('roles', 'title') %></div>

View File

@@ -14,7 +14,11 @@ module.exports = Mn.View.extend({
buttons: '.modal-footer button', buttons: '.modal-footer button',
cancel: 'button.cancel', cancel: 'button.cancel',
save: 'button.save', save: 'button.save',
error: '.secret-error' error: '.secret-error',
addMfa: '.add-mfa',
mfaLabel: '.mfa-label', // added binding
mfaValidation: '.mfa-validation-container', // added binding
qrInstructions: '.qr-instructions' // added binding for instructions
}, },
events: { events: {
@@ -25,6 +29,10 @@ module.exports = Mn.View.extend({
let view = this; let view = this;
let data = this.ui.form.serializeJSON(); let data = this.ui.form.serializeJSON();
// Save "mfa_validation" value and remove it from data
let mfaToken = data.mfa_validation;
delete data.mfa_validation;
let show_password = this.model.get('email') === 'admin@example.com'; let show_password = this.model.get('email') === 'admin@example.com';
// admin@example.com is not allowed // admin@example.com is not allowed
@@ -62,6 +70,15 @@ module.exports = Mn.View.extend({
} }
view.model.set(result); view.model.set(result);
if (mfaToken) {
return App.Api.Mfa.enable(mfaToken)
.then(() => result);
}
console.log(result);
return result;
})
.then(result => {
App.UI.closeModal(function () { App.UI.closeModal(function () {
if (method === App.Api.Users.create) { if (method === App.Api.Users.create) {
// Show permissions dialog immediately // Show permissions dialog immediately
@@ -75,6 +92,20 @@ module.exports = Mn.View.extend({
this.ui.error.text(err.message).show(); this.ui.error.text(err.message).show();
this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); this.ui.buttons.prop('disabled', false).removeClass('btn-disabled');
}); });
},
'click @ui.addMfa': function (e) {
let view = this;
App.Api.Mfa.create()
.then(response => {
view.ui.addMfa.replaceWith(`<img class="qr-code" src="${response.qrCode}" alt="QR Code">`);
view.ui.qrInstructions.show();
view.ui.mfaValidation.show();
// Add required attribute once MFA is activated
view.ui.mfaValidation.find('input[name="mfa_validation"]').attr('required', true);
})
.catch(err => {
view.ui.error.text(err.message).show();
});
} }
}, },
@@ -104,5 +135,29 @@ module.exports = Mn.View.extend({
if (typeof options.model === 'undefined' || !options.model) { if (typeof options.model === 'undefined' || !options.model) {
this.model = new UserModel.Model(); this.model = new UserModel.Model();
} }
},
onRender: function () {
let view = this;
App.Api.Mfa.check()
.then(response => {
if (response.active) {
view.ui.addMfa.hide();
view.ui.mfaLabel.hide();
view.ui.qrInstructions.hide();
view.ui.mfaValidation.hide();
// Remove required attribute if MFA is active & field is hidden
view.ui.mfaValidation.find('input[name="mfa_validation"]').removeAttr('required');
} else {
view.ui.addMfa.show();
view.ui.mfaLabel.show();
view.ui.qrInstructions.hide();
view.ui.mfaValidation.hide();
view.ui.mfaValidation.find('input[name="mfa_validation"]').removeAttr('required');
}
})
.catch(err => {
view.ui.error.text(err.message).show();
});
} }
}); });

View File

@@ -37,8 +37,15 @@
"all": "All", "all": "All",
"any": "Any" "any": "Any"
}, },
"mfa": {
"mfa": "Multi Factor Authentication",
"add-mfa": "Generate secret",
"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"
},
"login": { "login": {
"title": "Login to your account" "title": "Login to your account",
"mfa-required-text": "Please enter your MFA token to continue"
}, },
"main": { "main": {
"app": "Nginx Proxy Manager", "app": "Nginx Proxy Manager",

View File

@@ -1,3 +1,4 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col col-login mx-auto"> <div class="col col-login mx-auto">
@@ -24,6 +25,12 @@
<input name="secret" type="password" class="form-control" placeholder="<%- i18n('str', 'password') %>" required> <input name="secret" type="password" class="form-control" placeholder="<%- i18n('str', 'password') %>" required>
<div class="invalid-feedback secret-error"></div> <div class="invalid-feedback secret-error"></div>
</div> </div>
<div class="form-group mfa-group" style="display: none;">
<p class="mfa-info"><%- i18n('login', 'mfa-required-text') %>:</p>
<label class="form-label"><%- i18n('mfa', 'mfa-token') %></label>
<input name="mfa_token" type="text" class="form-control" placeholder="<%- i18n('mfa', 'mfa-token') %>">
<div class="invalid-feedback mfa-error"></div>
</div>
<div class="form-footer"> <div class="form-footer">
<button type="submit" class="btn btn-teal btn-block"><%- i18n('str', 'sign-in') %></button> <button type="submit" class="btn btn-teal btn-block"><%- i18n('str', 'sign-in') %></button>
</div> </div>
@@ -34,4 +41,4 @@
</form> </form>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -13,7 +13,11 @@ module.exports = Mn.View.extend({
identity: 'input[name="identity"]', identity: 'input[name="identity"]',
secret: 'input[name="secret"]', secret: 'input[name="secret"]',
error: '.secret-error', error: '.secret-error',
button: 'button' error_mfa:'.mfa-error',
button: 'button',
mfaGroup: '.mfa-group', // added MFA group selector
mfaToken: 'input[name="mfa_token"]', // added MFA token input
mfaInfo: '.mfa-info' // added MFA info element
}, },
events: { events: {
@@ -22,14 +26,36 @@ module.exports = Mn.View.extend({
this.ui.button.addClass('btn-loading').prop('disabled', true); this.ui.button.addClass('btn-loading').prop('disabled', true);
this.ui.error.hide(); this.ui.error.hide();
Api.Tokens.login(this.ui.identity.val(), this.ui.secret.val(), true) if(this.ui.mfaToken.val()) {
Api.Tokens.loginWithMFA(this.ui.identity.val(), this.ui.secret.val(), this.ui.mfaToken.val(), true)
.then(() => { .then(() => {
window.location = '/'; window.location = '/';
}) })
.catch(err => { .catch(err => {
this.ui.error.text(err.message).show(); if (err.message === 'Invalid MFA token.') {
this.ui.error_mfa.text(err.message).show();
} else {
this.ui.error.text(err.message).show();
}
this.ui.button.removeClass('btn-loading').prop('disabled', false); this.ui.button.removeClass('btn-loading').prop('disabled', false);
}); });
} else {
Api.Tokens.login(this.ui.identity.val(), this.ui.secret.val(), true)
.then(() => {
window.location = '/';
})
.catch(err => {
if (err.message === 'MFA token required') {
this.ui.mfaGroup.show();
this.ui.mfaInfo.show();
} else {
this.ui.error.text(err.message).show();
}
this.ui.button.removeClass('btn-loading').prop('disabled', false);
});
}
} }
}, },
@@ -40,3 +66,5 @@ module.exports = Mn.View.extend({
} }
} }
}); });

View File

@@ -161,11 +161,11 @@
}, },
"domainoffensive": { "domainoffensive": {
"name": "DomainOffensive (do.de)", "name": "DomainOffensive (do.de)",
"package_name": "certbot-dns-domainoffensive", "package_name": "certbot-dns-do",
"version": "~=2.0.0", "version": "~=0.31.0",
"dependencies": "", "dependencies": "",
"credentials": "dns_do_api_token = YOUR_DO_DE_AUTH_TOKEN", "credentials": "dns_do_api_token = YOUR_DO_DE_AUTH_TOKEN",
"full_plugin_name": "dns-domainoffensive" "full_plugin_name": "dns-do"
}, },
"domeneshop": { "domeneshop": {
"name": "Domeneshop", "name": "Domeneshop",
@@ -534,13 +534,5 @@
"dependencies": "", "dependencies": "",
"credentials": "edgedns_client_secret = as3d1asd5d1a32sdfsdfs2d1asd5=\nedgedns_host = sdflskjdf-dfsdfsdf-sdfsdfsdf.luna.akamaiapis.net\nedgedns_access_token = kjdsi3-34rfsdfsdf-234234fsdfsdf\nedgedns_client_token = dkfjdf-342fsdfsd-23fsdfsdfsdf", "credentials": "edgedns_client_secret = as3d1asd5d1a32sdfsdfs2d1asd5=\nedgedns_host = sdflskjdf-dfsdfsdf-sdfsdfsdf.luna.akamaiapis.net\nedgedns_access_token = kjdsi3-34rfsdfsdf-234234fsdfsdf\nedgedns_client_token = dkfjdf-342fsdfsd-23fsdfsdfsdf",
"full_plugin_name": "edgedns" "full_plugin_name": "edgedns"
}, }
"zoneedit": {
"name": "ZoneEdit",
"package_name": "certbot-dns-zoneedit",
"version": "~=0.3.2",
"dependencies": "--no-deps dnspython",
"credentials": "dns_zoneedit_user = <login-user-id>\ndns_zoneedit_token = <dyn-authentication-token>",
"full_plugin_name": "dns-zoneedit"
}
} }