This commit is contained in:
Julian Gassner
2025-02-05 16:49:04 +00:00
committed by GitHub
24 changed files with 1278 additions and 1481 deletions

View File

@@ -202,7 +202,46 @@ 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');
}
},
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

View File

@@ -33,6 +33,13 @@
<td class="<%- isExpired() ? 'text-danger' : '' %>">
<%- formatDbDate(expires_on, 'Do MMMM YYYY, h:mm a') %>
</td>
<td>
<% if (active_domain_names().length > 0) { %>
<span class="status-icon bg-success"></span> <%- i18n('certificates', 'in-use') %>
<% } else { %>
<span class="status-icon bg-danger"></span> <%- i18n('certificates', 'inactive') %>
<% } %>
</td>
<% if (canManage) { %>
<td class="text-right">
<div class="item-action dropdown">
@@ -48,7 +55,14 @@
<div class="dropdown-divider"></div>
<% } %>
<a href="#" class="delete dropdown-item"><i class="dropdown-icon fe fe-trash-2"></i> <%- i18n('str', 'delete') %></a>
<% if (active_domain_names().length > 0) { %>
<div class="dropdown-divider"></div>
<span class="dropdown-header"><%- i18n('certificates', 'active-domain_names') %></span>
<% active_domain_names().forEach(function(host) { %>
<a href="https://<%- host %>" class="dropdown-item" target="_blank"><%- host %></a>
<% }); %>
<% } %>
</div>
</div>
</td>
<% } %>
<% } %>

View File

@@ -44,14 +44,24 @@ module.exports = Mn.View.extend({
},
},
templateContext: {
canManage: App.Cache.User.canManage('certificates'),
isExpired: function () {
return moment(this.expires_on).isBefore(moment());
},
dns_providers: dns_providers
templateContext: function () {
return {
canManage: App.Cache.User.canManage('certificates'),
isExpired: function () {
return moment(this.expires_on).isBefore(moment());
},
dns_providers: dns_providers,
active_domain_names: function () {
const { proxy_hosts = [], redirect_hosts = [], dead_hosts = [] } = this;
return [...proxy_hosts, ...redirect_hosts, ...dead_hosts].reduce((acc, host) => {
acc.push(...(host.domain_names || []));
return acc;
}, []);
}
};
},
initialize: function () {
this.listenTo(this.model, 'change', this.render);
}

View File

@@ -3,6 +3,7 @@
<th><%- i18n('str', 'name') %></th>
<th><%- i18n('all-hosts', 'cert-provider') %></th>
<th><%- i18n('str', 'expires') %></th>
<th><%- i18n('str', 'status') %></th>
<% if (canManage) { %>
<th>&nbsp;</th>
<% } %>

View File

@@ -74,7 +74,7 @@ module.exports = Mn.View.extend({
e.preventDefault();
let query = this.ui.query.val();
this.fetch(['owner'], query)
this.fetch(['owner','proxy_hosts', 'dead_hosts', 'redirection_hosts'], query)
.then(response => this.showData(response))
.catch(err => {
this.showError(err);
@@ -89,7 +89,7 @@ module.exports = Mn.View.extend({
onRender: function () {
let view = this;
view.fetch(['owner'])
view.fetch(['owner','proxy_hosts', 'dead_hosts', 'redirection_hosts'])
.then(response => {
if (!view.isDestroyed()) {
if (response && response.length) {

View File

@@ -25,6 +25,17 @@
<div class="invalid-feedback secret-error"></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()) { %>
<div class="col-sm-12 col-md-12">
<div class="form-label"><%- i18n('roles', 'title') %></div>

View File

@@ -14,7 +14,11 @@ module.exports = Mn.View.extend({
buttons: '.modal-footer button',
cancel: 'button.cancel',
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: {
@@ -25,6 +29,10 @@ module.exports = Mn.View.extend({
let view = this;
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';
// admin@example.com is not allowed
@@ -62,6 +70,15 @@ module.exports = Mn.View.extend({
}
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 () {
if (method === App.Api.Users.create) {
// Show permissions dialog immediately
@@ -75,6 +92,20 @@ module.exports = Mn.View.extend({
this.ui.error.text(err.message).show();
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) {
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",
"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": {
"title": "Login to your account"
"title": "Login to your account",
"mfa-required-text": "Please enter your MFA token to continue"
},
"main": {
"app": "Nginx Proxy Manager",
@@ -208,7 +215,10 @@
"reachability-other": "There is a server found at this domain but it returned an unexpected status code {code}. Is it the NPM server? Please make sure your domain points to the IP where your NPM instance is running.",
"download": "Download",
"renew-title": "Renew Let's Encrypt Certificate",
"search": "Search Certificate…"
"search": "Search Certificate…",
"in-use" : "In use",
"inactive": "Inactive",
"active-domain_names": "Active domain names"
},
"access-lists": {
"title": "Access Lists",

View File

@@ -1,3 +1,4 @@
<div class="container">
<div class="row">
<div class="col col-login mx-auto">
@@ -24,6 +25,12 @@
<input name="secret" type="password" class="form-control" placeholder="<%- i18n('str', 'password') %>" required>
<div class="invalid-feedback secret-error"></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">
<button type="submit" class="btn btn-teal btn-block"><%- i18n('str', 'sign-in') %></button>
</div>
@@ -34,4 +41,4 @@
</form>
</div>
</div>
</div>
</div>

View File

@@ -13,7 +13,11 @@ module.exports = Mn.View.extend({
identity: 'input[name="identity"]',
secret: 'input[name="secret"]',
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: {
@@ -22,14 +26,36 @@ module.exports = Mn.View.extend({
this.ui.button.addClass('btn-loading').prop('disabled', true);
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(() => {
window.location = '/';
})
.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);
});
} 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({
}
}
});