mirror of
https://github.com/Ylianst/MeshCentral.git
synced 2024-09-11 08:55:32 +03:00
Improved U2F authentication, added multiple U2F key support.
This commit is contained in:
parent
922ed49cdd
commit
7272ecb089
46
meshuser.js
46
meshuser.js
@ -445,12 +445,19 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use
|
||||
break;
|
||||
}
|
||||
case 'showconfig': {
|
||||
// Make a copy of the configuration and hide any secrets
|
||||
var config = obj.common.Clone(obj.parent.parent.config);
|
||||
if (config.settings) {
|
||||
if (config.settings.configkey) { config.settings.configkey = '(present)'; }
|
||||
if (config.settings.sessionkey) { config.settings.sessionkey = '(present)'; }
|
||||
if (config.settings.dbencryptkey) { config.settings.dbencryptkey = '(present)'; }
|
||||
}
|
||||
if (config.domains) {
|
||||
for (var i in config.domains) {
|
||||
if (config.domains[i].yubikey && config.domains[i].yubikey.secret) { config.domains[i].yubikey.secret = '(present)'; }
|
||||
}
|
||||
}
|
||||
|
||||
r = JSON.stringify(removeAllUnderScore(config), null, 4);
|
||||
break;
|
||||
}
|
||||
@ -1433,7 +1440,7 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use
|
||||
if (twoStepLoginSupported) {
|
||||
// Perform the one time password setup
|
||||
const otplib = require('otplib');
|
||||
otplib.authenticator.options = { window: 6 }; // Set +/- 3 minute window
|
||||
otplib.authenticator.options = { window: 2 }; // Set +/- 1 minute window
|
||||
if (otplib.authenticator.check(command.token, command.secret) === true) {
|
||||
// Token is valid, activate 2-step login on this account.
|
||||
user.otpsecret = command.secret;
|
||||
@ -1493,6 +1500,9 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use
|
||||
if (user.otpsecret || ((user.otphkeys != null) && (user.otphkeys.length > 0))) {
|
||||
ws.send(JSON.stringify({ action: 'otpauth-getpasswords', passwords: user.otpkeys ? user.otpkeys.keys : null }));
|
||||
}
|
||||
|
||||
// Notify change TODO: Should be done on all sessions/servers for this user.
|
||||
try { ws.send(JSON.stringify({ action: 'userinfo', userinfo: obj.parent.CloneSafeUser(user) })); } catch (ex) { }
|
||||
break;
|
||||
}
|
||||
case 'otp-hkey-get':
|
||||
@ -1573,35 +1583,43 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use
|
||||
if (twoStepLoginSupported == false) break;
|
||||
|
||||
// Build list of known keys
|
||||
//var knownKeys = [];
|
||||
//if (user.otphkeys != null) { for (var i = 0; i < user.otphkeys.length; i++) { knownKeys.push(user.otphkeys[i].keyHandle); } }
|
||||
var knownKeys = [];
|
||||
if (user.otphkeys != null) { for (var i = 0; i < user.otphkeys.length; i++) { if (user.otphkeys[i].type == 1) { knownKeys.push(user.otphkeys[i]); } } }
|
||||
|
||||
// Build a key registration request and send it over
|
||||
const registrationRequest = require('u2f').request('https://' + obj.parent.parent.certificates.CommonName);
|
||||
if (registrationRequest) { ws.send(JSON.stringify({ action: 'otp-hkey-setup-request', request: registrationRequest, name: command.name })); }
|
||||
require('authdog').startRegistration('https://' + obj.parent.parent.certificates.CommonName, knownKeys, { requestId: 556, timeoutSeconds: 100 }).then(function (registrationRequest) {
|
||||
// Save registration request to session for later use
|
||||
obj.hardwareKeyRegistrationRequest = registrationRequest;
|
||||
|
||||
// Send registration request to client
|
||||
ws.send(JSON.stringify({ action: 'otp-hkey-setup-request', request: registrationRequest, name: command.name }));
|
||||
}, function (error) {
|
||||
// Handle registration request error
|
||||
ws.send(JSON.stringify({ action: 'otp-hkey-setup-request', request: null, error: error, name: command.name }));
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'otp-hkey-setup-response':
|
||||
{
|
||||
// Check is 2-step login is supported
|
||||
const twoStepLoginSupported = ((domain.auth != 'sspi') && (obj.parent.parent.certificates.CommonName != 'un-configured') && (obj.args.lanonly !== true) && (obj.args.nousers !== true));
|
||||
if ((twoStepLoginSupported == false) || (command.request == null) || (command.response == null) || (command.name == null)) break;
|
||||
if ((twoStepLoginSupported == false) || (command.response == null) || (command.name == null) || (obj.hardwareKeyRegistrationRequest == null)) break;
|
||||
|
||||
// Check the key registration request
|
||||
const result = require('u2f').checkRegistration(command.request, command.response);
|
||||
if (result) {
|
||||
require('authdog').finishRegistration(obj.hardwareKeyRegistrationRequest, command.response).then(function (registrationStatus) {
|
||||
var keyIndex = obj.parent.crypto.randomBytes(4).readUInt32BE(0);
|
||||
ws.send(JSON.stringify({ action: 'otp-hkey-setup-response', result: result.successful, name: command.name, index: keyIndex }));
|
||||
if (result.successful) {
|
||||
ws.send(JSON.stringify({ action: 'otp-hkey-setup-response', result: true, name: command.name, index: keyIndex }));
|
||||
if (user.otphkeys == null) { user.otphkeys = []; }
|
||||
user.otphkeys.push({ name: command.name, type: 1, publicKey: result.publicKey, keyHandle: result.keyHandle, keyIndex: keyIndex });
|
||||
user.otphkeys.push({ name: command.name, type: 1, publicKey: registrationStatus.publicKey, keyHandle: registrationStatus.keyHandle, certificate: registrationStatus.certificate, keyIndex: keyIndex });
|
||||
obj.parent.db.SetUser(user);
|
||||
//console.log('KEYS', JSON.stringify(user.otphkeys));
|
||||
|
||||
// Notify change TODO: Should be done on all sessions/servers for this user.
|
||||
try { ws.send(JSON.stringify({ action: 'userinfo', userinfo: obj.parent.CloneSafeUser(user) })); } catch (ex) { }
|
||||
}
|
||||
}
|
||||
delete obj.hardwareKeyRegistrationRequest;
|
||||
}, function (error) {
|
||||
ws.send(JSON.stringify({ action: 'otp-hkey-setup-response', result: false, error: error, name: command.name, index: keyIndex }));
|
||||
delete obj.hardwareKeyRegistrationRequest;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'getNotes':
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "meshcentral",
|
||||
"version": "0.2.7-s",
|
||||
"version": "0.2.7-t",
|
||||
"keywords": [
|
||||
"Remote Management",
|
||||
"Intel AMT",
|
||||
@ -27,6 +27,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"archiver": "^3.0.0",
|
||||
"authdog": "^0.1.1",
|
||||
"body-parser": "^1.18.2",
|
||||
"compression": "^1.7.3",
|
||||
"connect-redis": "^3.4.0",
|
||||
@ -42,7 +43,6 @@
|
||||
"nedb": "^1.8.0",
|
||||
"node-forge": "^0.7.6",
|
||||
"otplib": "^10.0.1",
|
||||
"u2f": "^0.1.3",
|
||||
"util.promisify": "^1.0.0",
|
||||
"ws": "^6.1.2",
|
||||
"xmldom": "^0.1.27",
|
||||
|
@ -40,7 +40,8 @@
|
||||
"_UserAllowedIP": "127.0.0.1,192.168.1.0/24",
|
||||
"_UserBlockedIP": "127.0.0.1,::1,192.168.0.100",
|
||||
"_AgentAllowedIP": "192.168.0.100/24",
|
||||
"_AgentBlockedIP": "127.0.0.1,::1"
|
||||
"_AgentBlockedIP": "127.0.0.1,::1",
|
||||
"_yubikey": { "id": "0000", "secret": "xxxxxxxxxxxxxxxxxxxxx", "_proxy": "http://myproxy.domain.com:80" },
|
||||
},
|
||||
"customer1": {
|
||||
"DNS": "customer1.myserver.com",
|
||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -234,14 +234,14 @@
|
||||
<div id=p3info style="overflow-y:scroll;position:absolute;top:55px;bottom:0px;width:100%">
|
||||
<div style="margin-left:8px">
|
||||
<div id="p3AccountActions">
|
||||
<p><strong>Account actions</strong></p>
|
||||
<p><strong>Account Security</strong></p>
|
||||
<div style="margin-left:9px;margin-bottom:8px">
|
||||
<div id="manageAuthApp" style="margin-top:5px;display:none"><a onclick="account_manageAuthApp()" style="cursor:pointer">Manage authenticator app</a></div>
|
||||
<div id="manageOtp" style="margin-top:5px;display:none"><a onclick="account_manageOtp(0)" style="cursor:pointer">Manage backup codes</a></div>
|
||||
</div>
|
||||
<p><strong>Account Actions</strong></p>
|
||||
<div style="margin-left:9px;margin-bottom:8px">
|
||||
<div style="margin-top:5px"><span id="verifyEmailId" style="display:none"><a onclick="account_showVerifyEmail()" style="cursor:pointer">Verify email</a></span></div>
|
||||
<div style="margin-top:5px">
|
||||
<span id="otpAuth" style="display:none"><a onclick="account_addOtp()" style="cursor:pointer">Add 2-step login</a><br /></span>
|
||||
<span id="otpAuthRemove" style="display:none"><a onclick="account_removeOtp()" style="cursor:pointer">Remove 2-step login</a><br /></span>
|
||||
</div>
|
||||
<div id="manageOtp" style="margin-top:5px;display:none"><a onclick="account_manageOtp(0)" style="cursor:pointer">One-time passwords</a></div>
|
||||
<div style="margin-top:5px"><a onclick="account_showChangeEmail()" style="cursor:pointer">Change email address</a></div>
|
||||
<div style="margin-top:5px"><a onclick="account_showChangePassword()" style="cursor:pointer">Change password</a></div>
|
||||
<div style="margin-top:5px"><a onclick="account_showDeleteAccount()" style="cursor:pointer">Delete account</a></div>
|
||||
@ -652,9 +652,8 @@
|
||||
|
||||
function updateSelf() {
|
||||
QV('verifyEmailId', (userinfo.emailVerified !== true) && (userinfo.email != null) && (serverinfo.emailcheck == true));
|
||||
QV('otpAuth', ((features & 4096) != 0) && (userinfo.otpsecret != 1));
|
||||
QV('otpAuthRemove', ((features & 4096) != 0) && (userinfo.otpsecret == 1));
|
||||
QV('manageOtp', ((features & 4096) != 0) && (userinfo.otpsecret == 1));
|
||||
QV('manageAuthApp', features & 4096);
|
||||
QV('manageOtp', ((features & 4096) != 0) && ((userinfo.otpsecret == 1) || (userinfo.otphkeys > 0)));
|
||||
}
|
||||
|
||||
function onMessage(server, message) {
|
||||
@ -742,12 +741,12 @@
|
||||
}
|
||||
case 'otpauth-setup': {
|
||||
if (xxdialogMode) return;
|
||||
setDialogMode(2, "Add 2-Step Login", 1, null, message.success ? "<b style=color:green>2-step login activation successful</b>. You will now need a valid token to login again." : "<b style=color:red>2-step login activation failed</b>. Clear the secret from the application and try again. You only have a few minutes to enter the proper code.");
|
||||
setDialogMode(2, "Authenticator App", 1, null, message.success ? "<b style=color:green>2-step login activation successful</b>. You will now need a valid token to login again." : "<b style=color:red>2-step login activation failed</b>. Clear the secret from the application and try again. You only have a few minutes to enter the proper code.");
|
||||
break;
|
||||
}
|
||||
case 'otpauth-clear': {
|
||||
if (xxdialogMode) return;
|
||||
setDialogMode(2, "Remove 2-Step Login", 1, null, message.success ? "<b style=color:green>2-step login activation removed</b>. You can reactivate this feature at any time." : "<b style=color:red>2-step login activation removal failed</b>. Try again.");
|
||||
setDialogMode(2, "Authenticator App", 1, null, message.success ? "<b style=color:green>2-step login activation removed</b>. You can reactivate this feature at any time." : "<b style=color:red>2-step login activation removal failed</b>. Try again.");
|
||||
break;
|
||||
}
|
||||
case 'otpauth-getpasswords': {
|
||||
@ -770,7 +769,7 @@
|
||||
x += "<input type=button value='New Tokens' onclick='account_manageOtp(1);'></input>";
|
||||
if (message.passwords != null) { x += "<input type=button value='Clear' onclick='account_manageOtp(2);'></input>"; }
|
||||
x += "</div><br />";
|
||||
setDialogMode(2, "One-Time Passwords", 8, null, x, 'otpauth-manage');
|
||||
setDialogMode(2, "Manage Backup Codes", 8, null, x, 'otpauth-manage');
|
||||
break;
|
||||
}
|
||||
case 'event': {
|
||||
@ -976,13 +975,13 @@
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.log('Unknown message.event.action', message.event.action);
|
||||
//console.log('Unknown message.event.action', message.event.action);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.log('Unknown message.action', message.action);
|
||||
//console.log('Unknown message.action', message.action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -1028,9 +1027,14 @@
|
||||
// MY ACCOUNT
|
||||
//
|
||||
|
||||
function account_manageAuthApp() {
|
||||
if (xxdialogMode || ((features & 4096) == 0)) return;
|
||||
if (userinfo.otpsecret == 1) { account_removeOtp(); } else { account_addOtp(); }
|
||||
}
|
||||
|
||||
function account_addOtp() {
|
||||
if (xxdialogMode || (userinfo.otpsecret == 1) || ((features & 4096) == 0)) return;
|
||||
setDialogMode(2, "Add 2-Step Login", 2, function () { meshserver.send({ action: 'otpauth-setup', secret: Q('d2optsecret').attributes.secret.value, token: Q('d2otpauthinput').value }); }, "<div id=d2optinfo>Loading...</div>", 'otpauth-request');
|
||||
setDialogMode(2, "Authenticator App", 2, function () { meshserver.send({ action: 'otpauth-setup', secret: Q('d2optsecret').attributes.secret.value, token: Q('d2otpauthinput').value }); }, "<div id=d2optinfo>Loading...</div>", 'otpauth-request');
|
||||
meshserver.send({ action: 'otpauth-request' });
|
||||
}
|
||||
|
||||
@ -1042,7 +1046,7 @@
|
||||
|
||||
function account_removeOtp() {
|
||||
if (xxdialogMode || (userinfo.otpsecret != 1) || ((features & 4096) == 0)) return;
|
||||
setDialogMode(2, "Remove 2-Step Login", 3, function () { meshserver.send({ action: 'otpauth-clear' }); }, "Confirm removal of 2-step login?");
|
||||
setDialogMode(2, "Authenticator App", 3, function () { meshserver.send({ action: 'otpauth-clear' }); }, "Confirm removal of authenticator application 2-step login?");
|
||||
}
|
||||
|
||||
function account_manageOtp(action) {
|
||||
|
@ -246,14 +246,19 @@
|
||||
</div>
|
||||
<div id=p2 style="display:none">
|
||||
<h1>My Account</h1>
|
||||
<img alt="" width=150 height=103 src=images/mainaccount.jpg style=margin-bottom:10px;margin-right:20px;float:right />
|
||||
<div id="p2AccountSecurity" style="display:none">
|
||||
<p><strong>Account security</strong></p>
|
||||
<div style="margin-left:25px">
|
||||
<div id="manageAuthApp"><div style="width:15px;display:inline-block"><span id="authAppSetupCheck" style="color:green;font-size:10px"><strong>✓</strong></span></div><span><a onclick="account_manageAuthApp()" style="cursor:pointer">Manage authenticator app</a><br /></span></div>
|
||||
<div id="manageHardwareOtp"><div style="width:15px;display:inline-block"><span id="authKeySetupCheck" style="color:green;font-size:10px"><strong>✓</strong></span></div><span><a onclick="account_manageHardwareOtp(0)" style="cursor:pointer">Manage security keys</a><br /></span></div>
|
||||
<div id="manageOtp"><div style="width:15px;display:inline-block"><span id="authCodesSetupCheck" style="color:green;font-size:10px"><strong>✓</strong></span></div><span><a onclick="account_manageOtp(0)" style="cursor:pointer">Manage backup codes</a><br /></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="p2AccountActions">
|
||||
<p><strong><img alt="" width=150 height=103 src=images/mainaccount.jpg style=margin-bottom:10px;margin-right:20px;float:right />Account actions</strong></p>
|
||||
<p><strong>Account actions</strong></p>
|
||||
<p style="margin-left:40px">
|
||||
<span id="verifyEmailId" style="display:none"><a onclick="account_showVerifyEmail()" style="cursor:pointer">Verify email</a><br /></span>
|
||||
<span id="otpAuth" style="display:none"><a onclick="account_addOtp()" style="cursor:pointer">Add 2-step login</a><br /></span>
|
||||
<span id="otpAuthRemove" style="display:none"><a onclick="account_removeOtp()" style="cursor:pointer">Remove 2-step login</a><br /></span>
|
||||
<span id="manageHardwareOtp" style="display:none"><a onclick="account_manageHardwareOtp(0)" style="cursor:pointer">Manage hardware login keys</a><br /></span>
|
||||
<span id="manageOtp" style="display:none"><a onclick="account_manageOtp(0)" style="cursor:pointer">Manage one time passwords</a><br /></span>
|
||||
<a onclick="account_showChangeEmail()" style="cursor:pointer">Change email address</a><br />
|
||||
<a onclick="account_showChangePassword()" style="cursor:pointer">Change password</a><br />
|
||||
<a onclick="account_showDeleteAccount()" style="cursor:pointer">Delete account</a><br />
|
||||
@ -1109,6 +1114,7 @@
|
||||
|
||||
// Update account actions
|
||||
QV('p2AccountActions', ((features & 4) == 0) && (serverinfo.domainauth == false)); // Hide Account Actions if in single user mode or domain authentication
|
||||
QV('p2AccountSecurity', ((features & 4) == 0) && (serverinfo.domainauth == false) && ((features & 4096) != 0)); // Hide Account Security if in single user mode, domain authentication to 2 factor auth not supported.
|
||||
QV('p2ServerActions', siteRights & 21);
|
||||
QV('LeftMenuMyServer', siteRights & 21); // 16 + 4 + 1
|
||||
QV('MainMenuMyServer', siteRights & 21);
|
||||
@ -1171,10 +1177,10 @@
|
||||
function updateSelf() {
|
||||
QV('verifyEmailId', (userinfo.emailVerified !== true) && (userinfo.email != null) && (serverinfo.emailcheck == true));
|
||||
QV('verifyEmailId2', (userinfo.emailVerified !== true) && (userinfo.email != null) && (serverinfo.emailcheck == true));
|
||||
QV('otpAuth', ((features & 4096) != 0) && (userinfo.otpsecret != 1));
|
||||
QV('otpAuthRemove', ((features & 4096) != 0) && (userinfo.otpsecret == 1));
|
||||
QV('manageOtp', ((features & 4096) != 0) && ((userinfo.otpsecret == 1) || (userinfo.otphkeys > 0)));
|
||||
QV('manageHardwareOtp', ((features & 0x5000) != 0)); // Requires 2-step login + YubiKey support
|
||||
QV('manageOtp', (userinfo.otpsecret == 1) || (userinfo.otphkeys > 0));
|
||||
QV('authAppSetupCheck', userinfo.otpsecret == 1);
|
||||
QV('authKeySetupCheck', userinfo.otphkeys > 0);
|
||||
QV('authCodesSetupCheck', userinfo.otpkeys > 0);
|
||||
}
|
||||
|
||||
function onMessage(server, message) {
|
||||
@ -1410,12 +1416,12 @@
|
||||
}
|
||||
case 'otpauth-setup': {
|
||||
if (xxdialogMode) return;
|
||||
setDialogMode(2, "Add 2-Step Login", 1, null, message.success ? "<b style=color:green>2-step login activation successful</b>. You will now need a valid token to login again." : "<b style=color:red>2-step login activation failed</b>. Clear the secret from the application and try again. You only have a few minutes to enter the proper code.");
|
||||
setDialogMode(2, "Authenticator App", 1, null, message.success ? "<b style=color:green>Authenticator app activation successful</b>. You will now need a valid token to login again." : "<b style=color:red>2-step login activation failed</b>. Clear the secret from the application and try again. You only have a few minutes to enter the proper code.");
|
||||
break;
|
||||
}
|
||||
case 'otpauth-clear': {
|
||||
if (xxdialogMode) return;
|
||||
setDialogMode(2, "Remove 2-Step Login", 1, null, message.success ? "<b style=color:green>2-step login activation removed</b>. You can reactivate this feature at any time." : "<b style=color:red>2-step login activation removal failed</b>. Try again.");
|
||||
setDialogMode(2, "Authenticator App", 1, null, message.success ? "<b>Authenticator application removed</b>. You can reactivate this feature at any time." : "<b style=color:red>2-step login activation removal failed</b>. Try again.");
|
||||
break;
|
||||
}
|
||||
case 'otpauth-getpasswords': {
|
||||
@ -1438,55 +1444,53 @@
|
||||
x += "<input type=button value='Generate New Tokens' onclick='account_manageOtp(1);'></input>";
|
||||
if (message.passwords != null) { x += "<input type=button value='Clear Tokens' onclick='account_manageOtp(2);'></input>"; }
|
||||
x += "</div><br />";
|
||||
setDialogMode(2, "Manage One Time Passwords", 8, null, x, 'otpauth-manage');
|
||||
setDialogMode(2, "Manage Backup Codes", 8, null, x, 'otpauth-manage');
|
||||
break;
|
||||
}
|
||||
case 'otp-hkey-get': {
|
||||
if (xxdialogMode && (xxdialogTag != 'otpauth-hardware-manage')) return;
|
||||
var start = "<div style='border-radius:6px;border: 2px solid #CCC;background-color:#BBB;width:100%;margin-top:8px'><div style='padding:8px;font-family:Arial, Helvetica, sans-serif;font-size:16px;font-weight:bold'><table style=width:100%;text-align:left>";
|
||||
var start = "<div style='border-radius:6px;border:2px solid #CCC;background-color:#BBB;width:100%;box-sizing:border-box;margin-bottom:6px'><div style='margin:3px;font-family:Arial, Helvetica, sans-serif;font-size:16px;font-weight:bold'><table style=width:100%;text-align:left>";
|
||||
var end = "</table></div></div>";
|
||||
var x = "<a href='https://www.yubico.com/' rel='noreferrer noopener' target='_blank'>Hardware keys</a> are used as secondary login authentication.";
|
||||
x += "";
|
||||
var keyType1 = 0;
|
||||
x += "<div style='max-height:150px;overflow-y:auto;overflow-x:hidden;margin-top:6px;margin-bottom:6px'>";
|
||||
if (message.keys && message.keys.length > 0) {
|
||||
for (var i in message.keys) {
|
||||
var key = message.keys[i];
|
||||
var type = 'OTP';
|
||||
if (key.type == 1) { keyType1++; type = 'U2F'; }
|
||||
var key = message.keys[i], type = (key.type == 1)?'U2F':'OTP';
|
||||
x += start + '<tr style=margin:5px><td style=width:30px><img width=24 height=18 src="images/hardware-key-' + type + '-24.png" style=margin-top:4px><td style=width:250px>' + key.name + "<td><input type=button value='Remove' onclick=account_removehkey(" + key.i + ")></input>" + end;
|
||||
}
|
||||
} else {
|
||||
x += start + '<tr style=text-align:center><td>No Hardware Keys Configured' + end;
|
||||
x += start + '<tr style=text-align:center><td>No Keys Configured' + end;
|
||||
}
|
||||
x += "<br />";
|
||||
x += "</div>";
|
||||
x += "<div><input type=button value='Close' onclick=setDialogMode(0) style=float:right></input>";
|
||||
x += "<input id=d2addkey1 type=button value='Add U2F Key' onclick='account_addhkey(1);'></input>";
|
||||
if ((features & 0x4000) != 0) { x += "<input id=d2addkey2 type=button value='Add OTP Key' onclick='account_addhkey(2);'></input>"; }
|
||||
x += "</div><br />";
|
||||
setDialogMode(2, "Manage Hardware Login Keys", 8, null, x, 'otpauth-hardware-manage');
|
||||
if ((u2fSupported() == false) || (keyType1 > 0)) { QE('d2addkey1', false); }
|
||||
setDialogMode(2, "Manage Security Keys", 8, null, x, 'otpauth-hardware-manage');
|
||||
if (u2fSupported() == false) { QE('d2addkey1', false); }
|
||||
break;
|
||||
}
|
||||
case 'otp-hkey-yubikey-add': {
|
||||
if (message.result) {
|
||||
meshserver.send({ action: 'otp-hkey-get' }); // Success, ask for the full list of keys.
|
||||
} else {
|
||||
setDialogMode(2, "Add Hardware Login Key", 1, null, '<br />Error, Unable to add key.<br /><br />');
|
||||
setDialogMode(2, "Add Security Key", 1, null, '<br />Error, Unable to add key.<br /><br />');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'otp-hkey-setup-request': {
|
||||
if (xxdialogMode && (xxdialogTag != 'otpauth-hardware-manage')) return;
|
||||
var x = "Press the key button now.<br /><br /><div style=width:100%;text-align:center><img width=120 height=117 src='images/hardware-keypress-120.png' /></div><input id=dp1keyname style=display:none value=" + message.name + " />";
|
||||
setDialogMode(2, "Add Hardware Login Key", 2, null, x);
|
||||
window.u2f.register(message.request.appId, [message.request], [], function (registrationResponse) {
|
||||
setDialogMode(2, "Add Security Key", 2, null, x);
|
||||
window.u2f.register(message.request.appId, message.request.registerRequests, message.request.registeredKeys, function (registrationResponse) {
|
||||
if (registrationResponse.registrationData) {
|
||||
meshserver.send({ action: 'otp-hkey-setup-response', request: message.request, response: registrationResponse, name: Q('dp1keyname').value });
|
||||
setDialogMode(2, "Add Hardware Login Key", 0, null, '<br />Checking...<br /><br /><br />', 'otpauth-hardware-manage');
|
||||
meshserver.send({ action: 'otp-hkey-setup-response', response: registrationResponse, name: Q('dp1keyname').value });
|
||||
setDialogMode(2, "Add Security Key", 0, null, '<br />Checking...<br /><br /><br />', 'otpauth-hardware-manage');
|
||||
} else {
|
||||
setDialogMode(2, "Add Hardware Login Key", 1, null, '<br />Error code ' + registrationResponse.errorCode + '<br /><br />');
|
||||
var errorCodes = ['', 'Unknown error', 'Bad request', 'Unsupported configuration', 'This key was already registered', 'Timeout'];
|
||||
setDialogMode(2, "Add Security Key", 1, null, '<br />' + errorCodes[registrationResponse.errorCode] + '.<br /><br />');
|
||||
}
|
||||
});
|
||||
}, message.request.timeoutSeconds);
|
||||
break;
|
||||
}
|
||||
case 'otp-hkey-setup-response': {
|
||||
@ -1494,7 +1498,7 @@
|
||||
if (message.result == true) {
|
||||
meshserver.send({ action: 'otp-hkey-get' }); // Success, ask for the full list of keys.
|
||||
} else {
|
||||
setDialogMode(2, "Add Hardware Login Key", 1, null, '<br />ERROR: Unable to add key.<br /><br />', 'otpauth-hardware-manage');
|
||||
setDialogMode(2, "Add Security Key", 1, null, '<br />ERROR: Unable to add key.<br /><br />', 'otpauth-hardware-manage');
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -5287,9 +5291,14 @@
|
||||
// MY ACCOUNT
|
||||
//
|
||||
|
||||
function account_manageAuthApp() {
|
||||
if (xxdialogMode || ((features & 4096) == 0)) return;
|
||||
if (userinfo.otpsecret == 1) { account_removeOtp(); } else { account_addOtp(); }
|
||||
}
|
||||
|
||||
function account_addOtp() {
|
||||
if (xxdialogMode || (userinfo.otpsecret == 1) || ((features & 4096) == 0)) return;
|
||||
setDialogMode(2, "Add 2-Step Login", 2, function () { meshserver.send({ action: 'otpauth-setup', secret: Q('d2optsecret').attributes.secret.value, token: Q('d2otpauthinput').value }); }, "<div id=d2optinfo>Loading...</div>", 'otpauth-request');
|
||||
setDialogMode(2, "Authenticator App", 2, function () { meshserver.send({ action: 'otpauth-setup', secret: Q('d2optsecret').attributes.secret.value, token: Q('d2otpauthinput').value }); }, "<div id=d2optinfo>Loading...</div>", 'otpauth-request');
|
||||
meshserver.send({ action: 'otpauth-request' });
|
||||
}
|
||||
|
||||
@ -5301,7 +5310,7 @@
|
||||
|
||||
function account_removeOtp() {
|
||||
if (xxdialogMode || (userinfo.otpsecret != 1) || ((features & 4096) == 0)) return;
|
||||
setDialogMode(2, "Remove 2-Step Login", 3, function () { meshserver.send({ action: 'otpauth-clear' }); }, "Confirm removal of 2-step login?");
|
||||
setDialogMode(2, "Authenticator App", 3, function () { meshserver.send({ action: 'otpauth-clear' }); }, "Confirm removal of authenticator application 2-step login?");
|
||||
}
|
||||
|
||||
function account_manageOtp(action) {
|
||||
@ -5321,11 +5330,11 @@
|
||||
var x = "Type in the name of the key to add.<br /><br />";
|
||||
x += addHtmlValue('Key Name', '<input id=dp1keyname style=width:230px maxlength=20 autocomplete=off placeholder="MyKey" onkeyup=account_addhkeyValidate(event,2) />');
|
||||
} else if (type == 2) {
|
||||
var x = "Type in a key name, select the OTP box and press the USB key button<br /><br />";
|
||||
var x = "Type in a key name, select the OTP box and press the button on the YubiKey™.<br /><br />";
|
||||
x += addHtmlValue('Key Name', '<input id=dp1keyname style=width:230px maxlength=20 autocomplete=off placeholder="MyKey" onkeyup=account_addhkeyValidate(event,1) />');
|
||||
x += addHtmlValue('OTP from key', '<input id=dp1key style=width:230px autocomplete=off onkeyup=account_addhkeyValidate(event,2) />');
|
||||
x += addHtmlValue('YubiKey™ OTP', '<input id=dp1key style=width:230px autocomplete=off onkeyup=account_addhkeyValidate(event,2) />');
|
||||
}
|
||||
setDialogMode(2, "Add Hardware Login Key", 3, account_addhkeyEx, x, type);
|
||||
setDialogMode(2, "Add Security Key", 3, account_addhkeyEx, x, type);
|
||||
Q('dp1keyname').focus();
|
||||
}
|
||||
|
||||
@ -5340,7 +5349,7 @@
|
||||
meshserver.send({ action: 'otp-hkey-setup-request', name: name });
|
||||
} else if (type == 2) {
|
||||
meshserver.send({ action: 'otp-hkey-yubikey-add', name: name, otp: Q('dp1key').value });
|
||||
setDialogMode(2, "Add Hardware Login Key", 0, null, "<br />Checking...<br /><br /><br />", 'otpauth-hardware-manage');
|
||||
setDialogMode(2, "Add Security Key", 0, null, "<br />Checking...<br /><br /><br />", 'otpauth-hardware-manage');
|
||||
}
|
||||
}
|
||||
|
||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -151,8 +151,7 @@
|
||||
<td align=right width=100>Login token:</td>
|
||||
<td>
|
||||
<input id=tokenInput type=text name=token maxlength=50 onkeyup=checkToken(event) onkeydown=checkToken(event) />
|
||||
<input id=hwtokenInput1 type=text name=hwtoken1 style="display:none" />
|
||||
<input id=hwtokenInput2 type=text name=hwtoken2 style="display:none" />
|
||||
<input id=hwtokenInput type=text name=hwtoken style="display:none" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@ -229,15 +228,13 @@
|
||||
if ('{{loginmode}}' == '4') {
|
||||
try { if (hardwareKeyChallenge.length > 0) { hardwareKeyChallenge = JSON.parse(hardwareKeyChallenge); } else { hardwareKeyChallenge = null; } } catch (ex) { hardwareKeyChallenge = null }
|
||||
if ((hardwareKeyChallenge != null) && u2fSupported()) {
|
||||
var c = hardwareKeyChallenge[0];
|
||||
window.u2f.sign(c.appId, c.challenge, hardwareKeyChallenge, function (authResponse) {
|
||||
window.u2f.sign(hardwareKeyChallenge.appId, hardwareKeyChallenge.challenge, hardwareKeyChallenge.registeredKeys, function (authResponse) {
|
||||
if (authResponse.signatureData) {
|
||||
Q('hwtokenInput1').value = JSON.stringify(hardwareKeyChallenge);
|
||||
Q('hwtokenInput2').value = JSON.stringify(authResponse);
|
||||
Q('hwtokenInput').value = JSON.stringify(authResponse);
|
||||
QE('tokenOkButton', true);
|
||||
Q('tokenOkButton').click();
|
||||
}
|
||||
});
|
||||
}, hardwareKeyChallenge.timeoutSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -224,8 +224,7 @@
|
||||
<td align=right width=100>Login token:</td>
|
||||
<td>
|
||||
<input id=tokenInput type=text name=token maxlength=50 onkeyup=checkToken(event) onkeydown=checkToken(event) />
|
||||
<input id=hwtokenInput1 type=text name=hwtoken1 style="display:none" />
|
||||
<input id=hwtokenInput2 type=text name=hwtoken2 style="display:none" />
|
||||
<input id=hwtokenInput type=text name=hwtoken style="display:none" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@ -312,15 +311,13 @@
|
||||
if ('{{loginmode}}' == '4') {
|
||||
try { if (hardwareKeyChallenge.length > 0) { hardwareKeyChallenge = JSON.parse(hardwareKeyChallenge); } else { hardwareKeyChallenge = null; } } catch (ex) { hardwareKeyChallenge = null }
|
||||
if ((hardwareKeyChallenge != null) && u2fSupported()) {
|
||||
var c = hardwareKeyChallenge[0];
|
||||
window.u2f.sign(c.appId, c.challenge, hardwareKeyChallenge, function (authResponse) {
|
||||
window.u2f.sign(hardwareKeyChallenge.appId, hardwareKeyChallenge.challenge, hardwareKeyChallenge.registeredKeys, function (authResponse) {
|
||||
if (authResponse.signatureData) {
|
||||
Q('hwtokenInput1').value = JSON.stringify(hardwareKeyChallenge);
|
||||
Q('hwtokenInput2').value = JSON.stringify(authResponse);
|
||||
Q('hwtokenInput').value = JSON.stringify(authResponse);
|
||||
QE('tokenOkButton', true);
|
||||
Q('tokenOkButton').click();
|
||||
}
|
||||
});
|
||||
}, hardwareKeyChallenge.timeoutSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
102
webserver.js
102
webserver.js
@ -342,32 +342,29 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
|
||||
}
|
||||
|
||||
// Check the 2-step auth token
|
||||
function checkUserOneTimePassword(domain, user, token, hwtoken1, hwtoken2, func) {
|
||||
const twoStepLoginSupported = ((domain.auth != 'sspi') && (obj.parent.certificates.CommonName != 'un-configured') && (obj.args.lanonly !== true) && (obj.args.nousers !== true));
|
||||
function checkUserOneTimePassword(req, domain, user, token, hwtoken, func) {
|
||||
const twoStepLoginSupported = ((domain.auth != 'sspi') && (obj.parent.certificates.CommonName != 'un-configured') && (obj.args.nousers !== true));
|
||||
if (twoStepLoginSupported == false) { func(true); return; };
|
||||
|
||||
// Check U2F hardware key
|
||||
if (user.otphkeys && (user.otphkeys.length > 0) && (typeof (hwtoken1) == 'string') && (typeof (hwtoken2) == 'string')) {
|
||||
var u2fpublicKey = null;
|
||||
|
||||
// Find a U2F key
|
||||
for (var i = 0; i < user.otphkeys.length; i++) { if (user.otphkeys[i].type == 1) { u2fpublicKey = user.otphkeys[i].publicKey; } }
|
||||
|
||||
if (u2fpublicKey != null) {
|
||||
// Check hardware token
|
||||
var authRequest = null, authResponse = null;
|
||||
try { authRequest = JSON.parse(hwtoken1); } catch (ex) { }
|
||||
try { authResponse = JSON.parse(hwtoken2); } catch (ex) { }
|
||||
if ((authRequest != null) && (authResponse != null)) {
|
||||
const u2f = require('u2f');
|
||||
const result = u2f.checkSignature(authRequest[0], authResponse, u2fpublicKey);
|
||||
if (result.successful === true) { func(true); return; };
|
||||
if (user.otphkeys && (user.otphkeys.length > 0) && (typeof (hwtoken) == 'string') && (hwtoken.length > 0)) {
|
||||
// Get all U2F keys
|
||||
var u2fKeys = [];
|
||||
for (var i = 0; i < user.otphkeys.length; i++) { if (user.otphkeys[i].type == 1) { u2fKeys.push(user.otphkeys[i]); } }
|
||||
if (u2fKeys.length > 0) {
|
||||
var authResponse = null;
|
||||
try { authResponse = JSON.parse(hwtoken); } catch (ex) { }
|
||||
if (authResponse != null) {
|
||||
// Check authentication response
|
||||
require('authdog').finishAuthentication(req.session.u2fchallenge, authResponse, u2fKeys).then(function (authenticationStatus) { func(true); }, function (error) { func(false); });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check Google Authenticator
|
||||
const otplib = require('otplib')
|
||||
otplib.authenticator.options = { window: 2 }; // Set +/- 1 minute window
|
||||
if (user.otpsecret && (typeof (token) == 'string') && (token.length == 6) && (otplib.authenticator.check(token, user.otpsecret) == true)) { func(true); return; };
|
||||
|
||||
// Check written down keys
|
||||
@ -399,21 +396,31 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
|
||||
|
||||
// Return a U2F hardware key challenge
|
||||
// TODO: Figure out how to support many U2F keys at the same time.
|
||||
function getHardwareKeyChallenge(domain, user) {
|
||||
function getHardwareKeyChallenge(req, domain, user, func) {
|
||||
if (req.session.u2fchallenge) { delete req.session.u2fchallenge; };
|
||||
if (user.otphkeys && (user.otphkeys.length > 0)) {
|
||||
// Find a U2F key
|
||||
var u2fKeyHandle = null;
|
||||
for (var i = 0; i < user.otphkeys.length; i++) { if (user.otphkeys[i].type == 1) { u2fKeyHandle = user.otphkeys[i].keyHandle; } }
|
||||
// Get all U2F keys
|
||||
var u2fKeys = [];
|
||||
for (var i = 0; i < user.otphkeys.length; i++) { if (user.otphkeys[i].type == 1) { u2fKeys.push(user.otphkeys[i]); } }
|
||||
|
||||
// Generate a U2F challenge
|
||||
if (u2fKeyHandle != null) {
|
||||
var requests = [];
|
||||
const u2f = require('u2f');
|
||||
for (var i in user.otphkeys) { requests.push(u2f.request('https://' + obj.parent.certificates.CommonName, u2fKeyHandle)); }
|
||||
return JSON.stringify(requests);
|
||||
if (u2fKeys.length > 0) {
|
||||
require('authdog').startAuthentication('https://' + obj.parent.certificates.CommonName, u2fKeys, { requestId: 0, timeoutSeconds: 60 }).then(function (registrationRequest) {
|
||||
// Save authentication request to session for later use
|
||||
req.session.u2fchallenge = registrationRequest;
|
||||
|
||||
// Send authentication request to client
|
||||
func(JSON.stringify(registrationRequest));
|
||||
}, function (error) {
|
||||
// Handle authentication request error
|
||||
func('');
|
||||
});
|
||||
} else {
|
||||
func('');
|
||||
}
|
||||
} else {
|
||||
func('');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function handleLoginRequest(req, res) {
|
||||
@ -430,8 +437,8 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
|
||||
var user = obj.users[userid];
|
||||
|
||||
// Check if this user has 2-step login active
|
||||
if (checkUserOneTimePasswordRequired(domain, user)) {
|
||||
checkUserOneTimePassword(domain, user, req.body.token, req.body.hwtoken1, req.body.hwtoken2, function (result) {
|
||||
if (checkUserOneTimePasswordRequired(req.domain, user)) {
|
||||
checkUserOneTimePassword(req, domain, user, req.body.token, req.body.hwtoken, function (result) {
|
||||
if (result == false) {
|
||||
// 2-step auth is required, but the token is not present or not valid.
|
||||
if (user.otpsecret != null) { req.session.error = '<b style=color:#8C001A>Invalid token, try again.</b>'; }
|
||||
@ -877,9 +884,9 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
|
||||
if (obj.args.allowhighqualitydesktop == true) { features += 0x0200; } // Enable AllowHighQualityDesktop (Default false)
|
||||
if (obj.args.lanonly == true || obj.args.mpsport == 0) { features += 0x0400; } // No CIRA
|
||||
if ((obj.parent.serverSelfWriteAllowed == true) && (user != null) && (user.siteadmin == 0xFFFFFFFF)) { features += 0x0800; } // Server can self-write (Allows self-update)
|
||||
if ((domain.auth != 'sspi') && (obj.parent.certificates.CommonName != 'un-configured') && (obj.args.lanonly !== true) && (obj.args.nousers !== true)) { features += 0x1000; } // 2-step login supported
|
||||
if ((domain.auth != 'sspi') && (obj.parent.certificates.CommonName != 'un-configured') && (obj.args.nousers !== true)) { features += 0x1000; } // 2-step login supported
|
||||
if (domain.agentnoproxy === true) { features += 0x2000; } // Indicates that agents should be installed without using a HTTP proxy
|
||||
if (domain.yubikey && domain.yubikey.id && domain.yubikey.secret) { features += 0x4000; } // Indicates Yubikey support (???)
|
||||
if (domain.yubikey && domain.yubikey.id && domain.yubikey.secret) { features += 0x4000; } // Indicates Yubikey support
|
||||
|
||||
// Create a authentication cookie
|
||||
const authCookie = obj.parent.encodeCookie({ userid: user._id, domainid: domain.id }, obj.parent.loginCookieEncryptionKey);
|
||||
@ -902,20 +909,24 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
|
||||
}
|
||||
} else {
|
||||
// Send back the login application
|
||||
var loginmode = req.session.loginmode;
|
||||
features = 0;
|
||||
delete req.session.loginmode; // Clear this state, if the user hits refresh, we want to go back to the login page.
|
||||
if ((parent.config != null) && (parent.config.settings != null) && (parent.config.settings.allowframing == true)) { features += 32; } // Allow site within iframe
|
||||
var httpsPort = ((obj.args.aliasport == null) ? obj.args.port : obj.args.aliasport); // Use HTTPS alias port is specified
|
||||
|
||||
// If this is a 2 factor auth request, look for a hardware key challenge.
|
||||
var hardwareKeyChallenge = '';
|
||||
if ((loginmode == '4') && (req.session.tokenusername)) {
|
||||
if ((req.session.loginmode == '4') && (req.session.tokenusername)) {
|
||||
var user = obj.users['user/' + domain.id + '/' + req.session.tokenusername];
|
||||
if (user != null) {
|
||||
hardwareKeyChallenge = getHardwareKeyChallenge(domain, user);
|
||||
getHardwareKeyChallenge(req, domain, user, function (u2fChallenge) { handleRootRequestLogin(req, res, domain, u2fChallenge, passRequirements); });
|
||||
return;
|
||||
}
|
||||
}
|
||||
handleRootRequestLogin(req, res, domain, '', passRequirements);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRootRequestLogin(req, res, domain, hardwareKeyChallenge, passRequirements) {
|
||||
var features = 0;
|
||||
if ((parent.config != null) && (parent.config.settings != null) && (parent.config.settings.allowframing == true)) { features += 32; } // Allow site within iframe
|
||||
var httpsPort = ((obj.args.aliasport == null) ? obj.args.port : obj.args.aliasport); // Use HTTPS alias port is specified
|
||||
var loginmode = req.session.loginmode;
|
||||
delete req.session.loginmode; // Clear this state, if the user hits refresh, we want to go back to the login page.
|
||||
|
||||
if (obj.args.minify && !req.query.nominify) {
|
||||
// Try to server the minified version if we can.
|
||||
@ -939,7 +950,6 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
|
||||
});
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
// Get the link to the root certificate if needed
|
||||
function getRootCertLink() {
|
||||
@ -1093,14 +1103,14 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
|
||||
function handleMeScriptRequest(req, res) {
|
||||
if ((obj.userAllowedIp != null) && (checkIpAddressEx(req, res, obj.userAllowedIp, false) === false)) { return; } // Check server-wide IP filter only.
|
||||
if (req.query.type == 1) {
|
||||
getCiraConfigurationScript(req.query.meshid, function (script) {
|
||||
obj.getCiraConfigurationScript(req.query.meshid, function (script) {
|
||||
if (script == null) { res.sendStatus(404); } else {
|
||||
res.set({ 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0', 'Content-Type': 'application/octet-stream', 'Content-Disposition': 'attachment; filename=cira_setup.mescript' });
|
||||
res.send(script);
|
||||
}
|
||||
});
|
||||
} else if (req.query.type == 2) {
|
||||
getCiraCleanupScript(function (script) {
|
||||
obj.getCiraCleanupScript(function (script) {
|
||||
if (script == null) { res.sendStatus(404); } else {
|
||||
res.set({ 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0', 'Content-Type': 'application/octet-stream', 'Content-Disposition': 'attachment; filename=cira_cleanup.mescript' });
|
||||
res.send(script);
|
||||
@ -2395,9 +2405,9 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
|
||||
delete user2.domain;
|
||||
delete user2.subscriptions;
|
||||
delete user2.passtype;
|
||||
if (user2.otpsecret) { user2.otpsecret = 1; } // Indicates a time secret is present.
|
||||
if (user2.otpkeys) { user2.otpkeys = 1; } // Indicates a set of one time passwords are present.
|
||||
if (user2.otphkeys) { user2.otphkeys = user2.otphkeys.length; } // Indicates the number of hardware keys setup
|
||||
if (typeof user2.otpsecret == 'string') { user2.otpsecret = 1; } // Indicates a time secret is present.
|
||||
if (typeof user2.otpkeys == 'object') { user2.otpkeys = 0; if (user.otpkeys != null) { for (var i = 0; i < user.otpkeys.keys.length; i++) { if (user.otpkeys.keys[i].u == true) { user2.otpkeys = 1; } } } } // Indicates the number of one time backup codes that are active.
|
||||
if (typeof user2.otphkeys == 'object') { user2.otphkeys = user2.otphkeys.length; } // Indicates the number of hardware keys setup
|
||||
return user2;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user