Add per-user TOTP 2FA, client-level user assignment, self-service portal
- TOTP (RFC 6238, stdlib-only) enrollment in profile, login step-up, admin emergency reset. - Admins can grant a user visibility into individual clients (User.ClientIDs) in addition to whole-server access (User.ServerIDs). - New "My Access" page: non-admin users see only their assigned clients (view/QR/download only, no management), reachable from the main nav. - GetUser/GetUsers now redact TOTPSecret before returning JSON. No Go toolchain was available while writing this - not yet build-verified. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PvrfUytqd74H6WcQkRzFM4
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c29edfdcc3
commit
34bc8f76f9
@@ -126,6 +126,14 @@
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{.basePath}}/my-access" class="nav-link {{if eq .baseData.Active "my-access" }}active{{end}}">
|
||||
<i class="nav-icon fas fa-id-badge"></i>
|
||||
<p>
|
||||
My Access
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{{if .baseData.Admin}}
|
||||
<li class="nav-header">SETTINGS</li>
|
||||
|
||||
+52
-1
@@ -64,6 +64,22 @@
|
||||
<!-- /.col -->
|
||||
</div>
|
||||
</form>
|
||||
<form id="totp-form" action="" method="post" style="display:none;">
|
||||
<p class="login-box-msg">Enter the 6-digit code from your authenticator app</p>
|
||||
<div class="input-group mb-3">
|
||||
<input id="totp_code" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="form-control" placeholder="123456">
|
||||
<div class="input-group-append">
|
||||
<div class="input-group-text">
|
||||
<span class="fas fa-shield-alt"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<button id="btn_totp" type="submit" class="btn btn-primary btn-block">Verify</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="text-center mb-3">
|
||||
<p id="message"></p>
|
||||
</div>
|
||||
@@ -93,11 +109,16 @@
|
||||
</script>
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
$('form').on('submit', function(e) {
|
||||
$('#username, #password').closest('form').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
$("#btn_login").trigger('click');
|
||||
});
|
||||
|
||||
$('#totp-form').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
$("#btn_totp").trigger('click');
|
||||
});
|
||||
|
||||
$("#btn_login").click(function () {
|
||||
const username = $("#username").val();
|
||||
const password = $("#password").val();
|
||||
@@ -114,6 +135,36 @@
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify(data),
|
||||
success: function(data) {
|
||||
if (data['totp_required']) {
|
||||
document.getElementById("message").innerHTML = "";
|
||||
$('#username').closest('form').hide();
|
||||
$('#totp-form').show();
|
||||
$('#totp_code').focus();
|
||||
return;
|
||||
}
|
||||
document.getElementById("message").innerHTML = `<p style="color:green">${data['message']}</p>`;
|
||||
// redirect after logging in successfully
|
||||
redirectNext();
|
||||
},
|
||||
error: function(jqXHR, exception) {
|
||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||
document.getElementById("message").innerHTML = `<p style="color:#ff0000">${responseJson['message']}</p>`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$("#btn_totp").click(function () {
|
||||
const code = $("#totp_code").val();
|
||||
const data = {"code": code}
|
||||
|
||||
$.ajax({
|
||||
cache: false,
|
||||
method: 'POST',
|
||||
url: '{{.basePath}}/login/totp',
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify(data),
|
||||
success: function(data) {
|
||||
document.getElementById("message").innerHTML = `<p style="color:green">${data['message']}</p>`;
|
||||
// redirect after logging in successfully
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
{{define "title"}}
|
||||
My Access
|
||||
{{end}}
|
||||
|
||||
{{define "top_css"}}
|
||||
{{end}}
|
||||
|
||||
{{define "username"}}
|
||||
{{ .username }}
|
||||
{{end}}
|
||||
|
||||
{{define "page_title"}}
|
||||
My Access
|
||||
{{end}}
|
||||
|
||||
{{define "page_content"}}
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-2 flex-wrap">
|
||||
<h5 class="mb-2">My Access</h5>
|
||||
</div>
|
||||
|
||||
{{if not .clients}}
|
||||
<div class="alert alert-info">
|
||||
No VPN access has been assigned to your account yet — contact an administrator.
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="row" id="my-access-list">
|
||||
{{range .clients}}
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">{{.Name}}</h3>
|
||||
<div class="card-tools">
|
||||
{{if .Enabled}}
|
||||
<span class="badge badge-success">Enabled</span>
|
||||
{{else}}
|
||||
<span class="badge badge-secondary">Disabled</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-1"><strong>Email:</strong> {{if .Email}}{{.Email}}{{else}}—{{end}}</p>
|
||||
<p class="mb-1"><strong>Server:</strong> {{.ServerName}} ({{.Interface}})</p>
|
||||
<p class="mb-1"><strong>Allocated IP:</strong> {{ StringsJoin .AllocatedIPs ", " }}</p>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-show-qr" data-clientid="{{.ID}}" data-clientname="{{.Name}}" data-toggle="modal" data-target="#modal_qr_client">
|
||||
<i class="fas fa-qrcode"></i> Show QR
|
||||
</button>
|
||||
<a class="btn btn-outline-primary btn-sm" href="{{$.basePath}}/my-access/client/{{.ID}}/download">
|
||||
<i class="fas fa-download"></i> Download config
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="modal fade" id="modal_qr_client">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">QR Code</h4>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<img id="qr_code" class="w-100" style="image-rendering: pixelated;" src="" alt="QR code" />
|
||||
</div>
|
||||
<div class="modal-footer justify-content-between">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.modal-content -->
|
||||
</div>
|
||||
<!-- /.modal-dialog -->
|
||||
</div>
|
||||
<!-- /.modal -->
|
||||
{{end}}
|
||||
|
||||
{{define "bottom_js"}}
|
||||
<script>
|
||||
$("#modal_qr_client").on('show.bs.modal', function (event) {
|
||||
const button = $(event.relatedTarget);
|
||||
const clientId = button.data('clientid');
|
||||
const clientName = button.data('clientname');
|
||||
const modal = $(this);
|
||||
const qrImg = $("#qr_code");
|
||||
|
||||
modal.find(".modal-title").text("Scan QR Code for " + clientName);
|
||||
qrImg.hide();
|
||||
|
||||
$.ajax({
|
||||
cache: false,
|
||||
method: 'GET',
|
||||
url: '{{.basePath}}/my-access/client/' + clientId + '/qrcode',
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
success: function (resp) {
|
||||
if (resp.QRCode) {
|
||||
qrImg.attr('src', resp.QRCode).show();
|
||||
} else {
|
||||
toastr.error('No QR code available for this client');
|
||||
}
|
||||
},
|
||||
error: function (jqXHR) {
|
||||
try {
|
||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||
toastr.error(responseJson['message']);
|
||||
} catch (e) {
|
||||
toastr.error('Failed to load QR code');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -47,6 +47,43 @@ Profile
|
||||
</div>
|
||||
<!-- /.card -->
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card card-primary">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Two-Factor Authentication (TOTP)</h3>
|
||||
</div>
|
||||
<!-- /.card-header -->
|
||||
<div class="card-body">
|
||||
<div id="totp-disabled-panel">
|
||||
<p id="totp-status-text">Two-factor authentication is not enabled.</p>
|
||||
<button type="button" class="btn btn-primary" id="btn_totp_setup">Set up 2FA</button>
|
||||
|
||||
<div id="totp-enroll-panel" style="display:none; margin-top: 15px;">
|
||||
<p>Scan this QR code with your authenticator app, or enter the secret manually:</p>
|
||||
<div class="text-center mb-3">
|
||||
<img id="totp-qrcode" src="" alt="TOTP QR code" style="max-width:200px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="totp-secret" class="control-label">Secret</label>
|
||||
<input type="text" class="form-control" id="totp-secret" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="totp-confirm-code" class="control-label">Enter the 6-digit code to confirm</label>
|
||||
<input type="text" inputmode="numeric" maxlength="6" class="form-control" id="totp-confirm-code" placeholder="123456">
|
||||
</div>
|
||||
<button type="button" class="btn btn-success" id="btn_totp_confirm">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="totp-enabled-panel" style="display:none;">
|
||||
<p><span class="badge badge-success">Enabled</span> Two-factor authentication is enabled on your account.</p>
|
||||
<button type="button" class="btn btn-danger" id="btn_totp_disable">Disable 2FA</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.card-body -->
|
||||
</div>
|
||||
<!-- /.card -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.row -->
|
||||
</div>
|
||||
@@ -132,5 +169,97 @@ Profile
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function refreshTotpUiState(enabled) {
|
||||
if (enabled) {
|
||||
$("#totp-disabled-panel").hide();
|
||||
$("#totp-enroll-panel").hide();
|
||||
$("#totp-enabled-panel").show();
|
||||
} else {
|
||||
$("#totp-enabled-panel").hide();
|
||||
$("#totp-disabled-panel").show();
|
||||
}
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
$.ajax({
|
||||
cache: false,
|
||||
method: 'GET',
|
||||
url: '{{.basePath}}/api/user/{{.baseData.CurrentUser}}',
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
success: function (resp) {
|
||||
refreshTotpUiState(!!resp.totp_enabled);
|
||||
},
|
||||
error: function (jqXHR, exception) {
|
||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||
toastr.error(responseJson['message']);
|
||||
}
|
||||
});
|
||||
|
||||
$("#btn_totp_setup").click(function () {
|
||||
$.ajax({
|
||||
cache: false,
|
||||
method: 'GET',
|
||||
url: '{{.basePath}}/profile/totp/enroll',
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
success: function (resp) {
|
||||
$("#totp-qrcode").attr("src", resp.qrcode);
|
||||
$("#totp-secret").val(resp.secret);
|
||||
$("#totp-enroll-panel").show();
|
||||
},
|
||||
error: function (jqXHR, exception) {
|
||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||
toastr.error(responseJson['message']);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$("#btn_totp_confirm").click(function () {
|
||||
const code = $("#totp-confirm-code").val();
|
||||
$.ajax({
|
||||
cache: false,
|
||||
method: 'POST',
|
||||
url: '{{.basePath}}/profile/totp/confirm',
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({"code": code}),
|
||||
success: function (resp) {
|
||||
toastr.success("Two-factor authentication enabled");
|
||||
refreshTotpUiState(true);
|
||||
},
|
||||
error: function (jqXHR, exception) {
|
||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||
toastr.error(responseJson['message']);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$("#btn_totp_disable").click(function () {
|
||||
if (!confirm("Disable two-factor authentication?")) {
|
||||
return;
|
||||
}
|
||||
$.ajax({
|
||||
cache: false,
|
||||
method: 'POST',
|
||||
url: '{{.basePath}}/profile/totp/disable',
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({}),
|
||||
success: function (resp) {
|
||||
toastr.success("Two-factor authentication disabled");
|
||||
$("#totp-secret").val("");
|
||||
$("#totp-confirm-code").val("");
|
||||
$("#totp-qrcode").attr("src", "");
|
||||
refreshTotpUiState(false);
|
||||
},
|
||||
error: function (jqXHR, exception) {
|
||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||
toastr.error(responseJson['message']);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
@@ -59,6 +59,12 @@ Users Settings
|
||||
</select>
|
||||
<small class="form-text text-muted">Servers this user (if non-admin) may access. Admins always have access to all servers.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="_client_ids" class="control-label">Individual Client Access</label>
|
||||
<select multiple class="form-control" id="_client_ids" name="_client_ids">
|
||||
</select>
|
||||
<small class="form-text text-muted">Grants a non-admin user visibility into these specific clients, even without full server access.</small>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer justify-content-between">
|
||||
@@ -179,7 +185,9 @@ Users Settings
|
||||
success: function (servers) {
|
||||
const select = modal.find("#_server_ids");
|
||||
select.empty();
|
||||
const serverNameById = {};
|
||||
$.each(servers, function (index, srv) {
|
||||
serverNameById[srv.id] = srv.name;
|
||||
select.append($('<option>').val(srv.id).text(srv.name + " (" + srv.id + ")"));
|
||||
});
|
||||
|
||||
@@ -188,6 +196,35 @@ Users Settings
|
||||
if (user_name !== "") {
|
||||
select.val(select.data('preselect') || []);
|
||||
}
|
||||
|
||||
// populate the individual client access select, labeling each option
|
||||
// with both the client's name/email and the server it belongs to so
|
||||
// admins aren't picking blind between same-named clients on different servers
|
||||
$.ajax({
|
||||
cache: false,
|
||||
method: 'GET',
|
||||
url: '{{.basePath}}/api/clients',
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
success: function (clientDataList) {
|
||||
const clientSelect = modal.find("#_client_ids");
|
||||
clientSelect.empty();
|
||||
$.each(clientDataList, function (index, clientData) {
|
||||
const client = clientData.Client;
|
||||
const serverName = serverNameById[client.server_id] || client.server_id || "unknown server";
|
||||
const label = (client.name || client.email || client.id) + " — " + serverName;
|
||||
clientSelect.append($('<option>').val(client.id).text(label));
|
||||
});
|
||||
|
||||
if (user_name !== "") {
|
||||
clientSelect.val(clientSelect.data('preselect') || []);
|
||||
}
|
||||
},
|
||||
error: function (jqXHR, exception) {
|
||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||
toastr.error(responseJson['message']);
|
||||
}
|
||||
});
|
||||
},
|
||||
error: function (jqXHR, exception) {
|
||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||
@@ -216,6 +253,10 @@ Users Settings
|
||||
// once its options have been populated (see the servers ajax above)
|
||||
modal.find("#_server_ids").data('preselect', user.server_ids || []);
|
||||
modal.find("#_server_ids").val(user.server_ids || []);
|
||||
// remember the granted client ids so the select can pre-select them
|
||||
// once its options have been populated (see the clients ajax above)
|
||||
modal.find("#_client_ids").data('preselect', user.client_ids || []);
|
||||
modal.find("#_client_ids").val(user.client_ids || []);
|
||||
},
|
||||
error: function (jqXHR, exception) {
|
||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||
@@ -230,6 +271,7 @@ Users Settings
|
||||
modal.find("#_user_password").prop("placeholder", "")
|
||||
modal.find("#_admin").prop("checked", false);
|
||||
modal.find("#_server_ids").data('preselect', []);
|
||||
modal.find("#_client_ids").data('preselect', []);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -243,12 +285,14 @@ Users Settings
|
||||
admin = true;
|
||||
}
|
||||
const server_ids = $("#_server_ids").val() || [];
|
||||
const client_ids = $("#_client_ids").val() || [];
|
||||
const data = {
|
||||
"username": username,
|
||||
"password": password,
|
||||
"previous_username": previous_username,
|
||||
"admin": admin,
|
||||
"server_ids": server_ids
|
||||
"server_ids": server_ids,
|
||||
"client_ids": client_ids
|
||||
};
|
||||
|
||||
if (previous_username !== "") {
|
||||
|
||||
Reference in New Issue
Block a user