core&ui: add webauthn support (#484)
Co-authored-by: panda <panda_dtdyy@outlook.com> Co-authored-by: undefined <i@undefined.moe>pull/498/head
parent
3d3a5480e3
commit
d069fe127b
@ -0,0 +1,8 @@
|
||||
import notp from 'notp';
|
||||
import b32 from 'thirty-two';
|
||||
|
||||
export function verifyTFA(secret: string, code?: string) {
|
||||
if (!code || !code.length) return null;
|
||||
const bin = b32.decode(secret);
|
||||
return notp.totp.verify(code.replace(/\W+/g, ''), bin);
|
||||
}
|
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><path d="M531.4 0c17.5 3.8 35.4 4.2 53 7.6C706.6 31 805.4 92.1 880.5 191c46.4 61.1 75.8 130 86.7 205.7 9.3 64.7 8.5 130 6.9 195.3-1.4 56.5-7.9 112.4-17.9 167.9-4.6 25.5-25.1 41-49.7 36.7-23.7-4.2-37.7-26-32.9-51.2 18.8-98.4 19.8-197.6 14.6-297.1-2.6-49.4-13.7-96.7-35.6-141-58.8-119.4-153.7-194.2-284.8-216.7C415 64.4 290.1 117.7 191.6 235.7c-12.3 14.7-26.7 22-45.4 17.7-30.6-7.1-41.9-42.8-21.8-68.3C152.6 149.3 185 117.7 222 91c68-49 143.5-78.6 226.7-88.6 5.4-.7 11.1.9 16.2-2.4h66.5zm272.2 523.2c2.7 111.7-11.9 220.9-49 326.6-11.2 32-24.4 63.1-39.6 93.4-11.3 22.4-35.4 31.1-56.8 20.6s-29.7-35.1-18.5-57.5c34.2-68.3 57-140.3 69.5-215.6 9.4-57.1 12.1-114.7 10.3-172.5-1-32.5 1.4-65.5-6.9-97.3C690 335 634 282 548.5 260.5c-49.4-12.4-97.6-5.9-143.7 15.8-23.9 11.3-48.5 3.3-58.7-18.9-10.2-22-.9-45.1 22.8-56.7 175.9-86.4 391.8 21.1 428.3 213.4 6.7 36 4.8 72.7 6.4 109.1zm-420.6-2c.9-27.3-2.2-54.8 3.8-81.9 12.8-58.5 64.7-98.9 125-97.2 60 1.7 113.2 45.8 121 103.8 6.1 45.2 7.6 90.9 5.9 136.6-.9 23.7-20.9 40.9-44.5 39.8-22.3-1-39.8-19.7-39.2-43.1 1.1-38.5-2.3-76.9-4-115.3-.9-18.8-18.1-35.7-37.6-37.5-22.2-2.1-39.1 9.5-44.4 30.9-1.4 5.6-1.9 11.6-2 17.4-.2 28.7.2 57.4-.3 86.1-2.6 145.6-69.9 250.1-200.7 313.8-12.6 6.1-25.6 11.3-38.7 16.1-22.8 8.3-46-2.5-54.1-24.6-8-21.6 2.2-45 24.6-53.5 44-16.8 84.6-38.9 117.6-73.2 38.8-40.3 58.9-89 65.5-143.9 3-24.8 1.5-49.6 2.1-74.3zm61.9 478.8c-9.3-.5-21.9-7.8-29.4-22.9-7.9-15.8-6.1-31.1 4.8-44.8C447.5 898.4 471 862.1 490 823c16-32.8 28.5-66.9 37.9-102.2 5.2-19.6 20.3-31.8 39.1-32.7 18.4-.9 35 10.4 41.5 28.2 3.2 8.9 2.8 17.8.4 26.9-23.5 88-63.9 167.4-120.7 238.6-9.4 11.4-20.6 18.7-43.3 18.2zM25.1 486.5c-1-66.2 9.5-130.3 33.4-192.1 7.5-19.5 23-29.2 43.3-28.1 17.1.9 32.6 13.9 37 31.3 2 8 2.1 16.3-1 24-24.6 61.3-30.8 125.4-28.7 190.7.3 9.4.1 18.9-.1 28.4-.4 25-17.2 42.7-40.9 43.1-23.9.4-42.2-17.4-42.8-42.5-.5-18.3-.2-36.5-.2-54.8zM279 565.1c-1.4 49.9-13.5 95.4-53.5 129.2-23.3 19.7-50.6 31.7-79.3 40.9-15.2 4.8-30.5 9.2-45.9 13.4-23.9 6.6-46.2-5.8-52.8-29.1-6.3-22.2 6.5-44.8 29.9-51.7 20.9-6.2 42-11.4 62.3-19.4 39.7-15.6 54.4-36.8 55.3-79.3.2-7.8-.6-15.6 1.4-23.3 5.1-20.2 24.7-34.7 44.2-32.4 22.6 2.7 38.2 19.6 38.4 41.9v9.8zm35.6-313c13.9.3 27 8.8 33.9 24.7 7.2 16.7 4.4 32.8-8.2 46.2-20.1 21.4-36.1 45.3-46.8 72.7-6.4 16.4-10.8 33.4-12.8 51-2.8 23.7-24.2 41.2-46.2 38-24.6-3.5-40.4-24.4-37.1-48.8 8.8-64.6 35.7-120.7 80-168.4 10.5-11.2 19.8-15.5 37.2-15.4z"/></svg>
|
After Width: | Height: | Size: 2.4 KiB |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><path d="M494.1 62.7c-38.4 0-75.9.2-113.1 0-11.7 0-13.4 7.6-13.4 16.7v204h126.5V62.7zm-35.8 118.5c0 6.6-5.3 12-11.9 12h-31.3c-6.6 0-11.9-5.3-11.9-12v-16.3c0-6.6 5.3-11.9 11.9-11.9h31.3c6.6 0 11.9 5.3 11.9 11.9v16.3zM632 77.5c0-11.3-5.9-15-16.9-15-28.6.4-57.1.2-85.7.2h-24.1v220.8H632v-206zm-35.8 103.7c0 6.6-5.3 12-12 12H553c-6.6 0-11.9-5.3-11.9-12v-16.3c0-6.6 5.3-11.9 11.9-11.9h31.2c6.7 0 12 5.3 12 11.9v16.3zm73.6 110.1c-113.2-.4-226.4 0-339.6 0-26.2 0-35.4 9.6-35.4 35.8v577.3c0 23.8 9.3 33.1 33.1 33.1h342.9c24.8 0 33.8-8.9 33.8-33.8V326.4c0-25.1-9.3-35.1-34.8-35.1zm9.3 576.7c0 21.5-7.6 29.5-29.5 29.5H349.4c-21.2 0-29.1-8-29.1-28.8v-15.2c0 20.8 8 28.8 29.1 28.8h300.2c21.9 0 29.5-8 29.5-29.5V868z"/></svg>
|
After Width: | Height: | Size: 776 B |
@ -1,92 +1,146 @@
|
||||
import { browserSupportsWebAuthn, platformAuthenticatorIsAvailable, startRegistration } from '@simplewebauthn/browser';
|
||||
import $ from 'jquery';
|
||||
import { escape } from 'lodash';
|
||||
import QRCode from 'qrcode';
|
||||
import { ActionDialog } from 'vj/components/dialog';
|
||||
import Notification from 'vj/components/notification';
|
||||
import { NamedPage } from 'vj/misc/Page';
|
||||
import {
|
||||
api, delay, gql, i18n, tpl,
|
||||
delay, i18n, request, secureRandomString, tpl,
|
||||
} from 'vj/utils';
|
||||
|
||||
export default new NamedPage('home_security', () => {
|
||||
$(document).on('click', '[name="tfa_enable"]', async () => {
|
||||
const result = new ActionDialog({
|
||||
$body: tpl`
|
||||
<div class="typo">
|
||||
<p>${i18n('Please use your two factor authentication app to scan the qrcode below:')}</p>
|
||||
<div style="text-align: center">
|
||||
<canvas id="qrcode"></canvas>
|
||||
<p id="secret">${i18n('Click to show secret')}</p>
|
||||
</div>
|
||||
<label>${i18n('6-Digit Code')}
|
||||
<div class="textbox-container">
|
||||
<input class="textbox" type="text" name="tfa_code" data-autofocus autocomplete="off"></input>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
`,
|
||||
onDispatch(action) {
|
||||
if (action === 'ok' && $('[name="tfa_code"]').val() === null) {
|
||||
$('[name="tfa_code"]').focus();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}).open();
|
||||
const secret = String.random(13, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567');
|
||||
$('#secret').on('click', () => $('#secret').html(secret));
|
||||
const uri = `otpauth://totp/Hydro:${UserContext.uname}?secret=${secret}&issuer=Hydro`;
|
||||
const canvas = document.getElementById('qrcode');
|
||||
await QRCode.toCanvas(canvas, uri);
|
||||
const action = await result;
|
||||
if (action !== 'ok') return;
|
||||
try {
|
||||
await api(gql`
|
||||
user {
|
||||
TFA {
|
||||
enable(code: ${$('[name="tfa_code"]').val()}, secret: ${secret})
|
||||
}
|
||||
}
|
||||
`);
|
||||
Notification.success(i18n('Successfully enabled.'));
|
||||
await delay(2000);
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
Notification.error(e.message);
|
||||
}
|
||||
});
|
||||
$(document).on('click', '[name="tfa_enabled"]', async () => {
|
||||
const op = await new ActionDialog({
|
||||
$body: tpl`
|
||||
<div class="typo">
|
||||
<label>${i18n('6-Digit Code')}
|
||||
<div class="textbox-container">
|
||||
<input class="textbox" type="text" name="tfa_code" data-focus autocomplete="off"></input>
|
||||
</div>
|
||||
</label>
|
||||
const t = (s) => escape(i18n(s));
|
||||
|
||||
async function enableTfa() {
|
||||
const enableTFA = new ActionDialog({
|
||||
$body: tpl`
|
||||
<div class="typo">
|
||||
<p>${i18n('Please use your two factor authentication app to scan the qrcode below:')}</p>
|
||||
<div style="text-align: center">
|
||||
<canvas id="qrcode"></canvas>
|
||||
<p id="secret">${i18n('Click to show secret')}</p>
|
||||
</div>
|
||||
`,
|
||||
onDispatch(action) {
|
||||
if (action === 'ok' && $('[name="tfa_code"]').val() === null) {
|
||||
$('[name="tfa_code"]').focus();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}).open();
|
||||
if (op !== 'ok') return;
|
||||
try {
|
||||
await api(gql`
|
||||
user {
|
||||
TFA {
|
||||
disable(code: ${$('[name="tfa_code"]').val()})
|
||||
}
|
||||
}
|
||||
`);
|
||||
Notification.success(i18n('Successfully disabled.'));
|
||||
await delay(2000);
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
Notification.error(e.message);
|
||||
<label>${i18n('6-Digit Code')}
|
||||
<div class="textbox-container">
|
||||
<input class="textbox" type="text" name="tfa_code" data-autofocus autocomplete="off"></input>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
`,
|
||||
onDispatch(action) {
|
||||
if (action === 'ok' && $('[name="tfa_code"]').val() === null) {
|
||||
$('[name="tfa_code"]').focus();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}).open();
|
||||
const secret = secureRandomString(13, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567');
|
||||
$('#secret').on('click', () => $('#secret').html(secret));
|
||||
const uri = `otpauth://totp/Hydro:${UserContext.uname}?secret=${secret}&issuer=Hydro`;
|
||||
const canvas = document.getElementById('qrcode');
|
||||
await QRCode.toCanvas(canvas, uri);
|
||||
const tfaAction = await enableTFA;
|
||||
if (tfaAction !== 'ok') return;
|
||||
try {
|
||||
await request.post('', {
|
||||
operation: 'enable_tfa',
|
||||
code: $('[name="tfa_code"]').val(),
|
||||
secret,
|
||||
});
|
||||
} catch (err) {
|
||||
Notification.error(err.message);
|
||||
console.error(err);
|
||||
return;
|
||||
}
|
||||
Notification.success(i18n('Successfully enabled.'));
|
||||
await delay(2000);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async function enableAuthn(type: string) {
|
||||
const authnInfo = await request.post('', { operation: 'register', type });
|
||||
if (!authnInfo.authOptions) {
|
||||
Notification.error(i18n('Failed to fetch registration data.'));
|
||||
return;
|
||||
}
|
||||
Notification.info(i18n('Please follow the instructions on your device to complete the verification.'));
|
||||
let credential;
|
||||
try {
|
||||
console.log(authnInfo);
|
||||
credential = await startRegistration(authnInfo.authOptions);
|
||||
} catch (err) {
|
||||
Notification.error(i18n('Failed to get credential: {0}', err));
|
||||
return;
|
||||
}
|
||||
const op = await new ActionDialog({
|
||||
$body: tpl`
|
||||
<div class="typo">
|
||||
<label>${i18n('Name')}
|
||||
<div class="textbox-container">
|
||||
<input class="textbox" type="text" name="webauthn_name" data-focus autocomplete="off"></input>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
`,
|
||||
onDispatch(action) {
|
||||
if (action === 'ok' && $('[name="webauthn_name"]').val() === null) {
|
||||
$('[name="webauthn_name"]').focus();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}).open();
|
||||
if (op !== 'ok') return;
|
||||
try {
|
||||
await request.post('', {
|
||||
operation: 'enable_authn',
|
||||
name: $('[name="webauthn_name"]').val(),
|
||||
result: credential,
|
||||
});
|
||||
} catch (err) {
|
||||
Notification.error(err.message);
|
||||
console.error(err);
|
||||
return;
|
||||
}
|
||||
Notification.success(i18n('Successfully enabled.'));
|
||||
await delay(2000);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
export default new NamedPage('home_security', () => {
|
||||
const menuLink = (inner: string, action?: string) => `
|
||||
<li class="menu__item" ${action ? '' : 'disabled'}>
|
||||
<a class="menu__link" ${action ? `data-action="${action}"` : 'disabled'}>${inner}</a>
|
||||
</li>
|
||||
`;
|
||||
const fingerprint = '<span class="icon icon-fingerprint"></span>';
|
||||
|
||||
$(document).on('click', '[name="auth_enable"]', async () => {
|
||||
let $body = `
|
||||
<div>
|
||||
<h3>${t('Choose Authenticator Type')}</h3>
|
||||
<ol class="menu">
|
||||
${menuLink(`<span class="icon icon-platform--mobile"></span>${t('Two Factor Authentication')}`, 'tfa')}
|
||||
<li class="menu__seperator"></li>
|
||||
`;
|
||||
if (!window.isSecureContext || !browserSupportsWebAuthn()) {
|
||||
const message = window.isSecureContext
|
||||
? "Your browser doesn't support WebAuthn."
|
||||
: 'Webauthn is not available in insecure context.';
|
||||
$body += menuLink(`${fingerprint}${t(message)}`);
|
||||
} else {
|
||||
if (!await platformAuthenticatorIsAvailable()) {
|
||||
$body += menuLink(`${fingerprint}${t("Your browser doesn't support platform authenticator.")}`);
|
||||
} else {
|
||||
$body += menuLink(`${fingerprint}${t('Your Device')}`, 'platform');
|
||||
}
|
||||
$body += menuLink(`<span class="icon icon-usb"></span>${t('Multi Platform Authenticator')}`, 'cross-platform');
|
||||
}
|
||||
$body += '</ol></div>';
|
||||
const action = await new ActionDialog({ $body, $action: [] }).open();
|
||||
if (!action || action === 'cancel') return;
|
||||
if (action === 'tfa') enableTfa();
|
||||
else enableAuthn(action);
|
||||
});
|
||||
});
|
||||
|
@ -1,18 +0,0 @@
|
||||
import $ from 'jquery';
|
||||
import { AutoloadPage } from 'vj/misc/Page';
|
||||
import { api, gql } from 'vj/utils';
|
||||
|
||||
export default new AutoloadPage('user_login', (pagename) => {
|
||||
(pagename === 'user_login' ? $(document) : $('.dialog--signin__main')).on('blur', '[name="uname"]', async () => {
|
||||
const uname = $('[name="uname"]').val() as string;
|
||||
if (uname.length > 0) {
|
||||
const tfa = await api(gql`
|
||||
user(uname:${uname}){
|
||||
tfa
|
||||
}
|
||||
`, ['data', 'user', 'tfa']);
|
||||
if (tfa) $('#tfa_div').show();
|
||||
else $('#tfa_div').hide();
|
||||
}
|
||||
});
|
||||
});
|
@ -0,0 +1,25 @@
|
||||
import $ from 'jquery';
|
||||
import { NamedPage } from 'vj/misc/Page';
|
||||
|
||||
function sudoSwitch(type, init = false) {
|
||||
$('.sudo-div').each((i, e) => {
|
||||
$(e).toggle($(e).data('sudo') === type);
|
||||
});
|
||||
$('.sudo-switch').each((i, e) => {
|
||||
$(e).toggle($(e).data('sudo') !== type);
|
||||
});
|
||||
if (type === 'authn') {
|
||||
$('.confirm-div input[name=confirm]').prop({ type: '', disabled: true }).hide();
|
||||
$('.confirm-div input[name=webauthn_verify]').prop({ type: 'submit', disabled: false }).show();
|
||||
if (!init) $('.confirm-div input[name=webauthn_verify]').trigger('click');
|
||||
} else {
|
||||
$('.confirm-div input[name=webauthn_verify]').prop({ type: '', disabled: true }).hide();
|
||||
$('.confirm-div input[name=confirm]').prop({ type: 'submit', disabled: false }).show();
|
||||
}
|
||||
$('.sudo-div:visible input:visible').first().trigger('focus');
|
||||
}
|
||||
|
||||
export default new NamedPage('user_sudo', () => {
|
||||
sudoSwitch($($('.sudo-div')[0]).data('sudo'), true);
|
||||
$('.sudo-switch').on('click', (ev) => sudoSwitch($(ev.currentTarget).data('sudo')));
|
||||
});
|
@ -0,0 +1,104 @@
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import $ from 'jquery';
|
||||
import { ActionDialog } from 'vj/components/dialog';
|
||||
import Notification from 'vj/components/notification';
|
||||
import { AutoloadPage } from 'vj/misc/Page';
|
||||
import {
|
||||
api, gql, i18n, request, tpl,
|
||||
} from 'vj/utils';
|
||||
|
||||
async function verifywebauthn($form) {
|
||||
if (!window.isSecureContext || !('credentials' in navigator)) {
|
||||
Notification.error(i18n('Your browser does not support WebAuthn or you are not in secure context.'));
|
||||
return null;
|
||||
}
|
||||
let uname = '';
|
||||
if ($form['uname']) uname = $form['uname'].value;
|
||||
const authnInfo = await request.get('/user/webauthn', uname ? { uname } : undefined);
|
||||
if (!authnInfo.authOptions) {
|
||||
Notification.error(i18n('Failed to fetch registration data.'));
|
||||
return null;
|
||||
}
|
||||
Notification.info(i18n('Please follow the instructions on your device to complete the verification.'));
|
||||
const result = await startAuthentication(authnInfo.authOptions)
|
||||
.catch((e) => {
|
||||
Notification.error(i18n('Failed to get credential: {0}', e));
|
||||
return null;
|
||||
});
|
||||
if (!result) return null;
|
||||
try {
|
||||
const authn = await request.post('/user/webauthn', {
|
||||
result,
|
||||
});
|
||||
if (!authn.error) return authnInfo.authOptions.challenge;
|
||||
} catch (err) {
|
||||
Notification.error(err.message);
|
||||
console.error(err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function chooseAction(authn?: boolean) {
|
||||
return await new ActionDialog({
|
||||
$body: tpl`
|
||||
<div class="typo">
|
||||
<h3>${i18n('Two Factor Authentication')}</h3>
|
||||
<p>${i18n('Your account has two factor authentication enabled. Please choose an authenticator to verify.')}</p>
|
||||
<div style="${authn ? '' : 'display:none;'}">
|
||||
<input value="${i18n('Use Authenticator')}" class="expanded rounded primary button" data-action="webauthn" autofocus>
|
||||
</div>
|
||||
<div>
|
||||
<label>${i18n('6-Digit Code')}
|
||||
<div class="textbox-container">
|
||||
<input class="textbox" type="text" name="tfa_code" autocomplete="off" autofocus>
|
||||
</div>
|
||||
</label>
|
||||
<input value="${i18n('Use TFA Code')}" class="expanded rounded primary button" data-action="tfa">
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
$action: [],
|
||||
canCancel: false,
|
||||
onDispatch(action) {
|
||||
if (action === 'tfa' && $('[name="tfa_code"]').val() === null) {
|
||||
$('[name="tfa_code"]').focus();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}).open();
|
||||
}
|
||||
|
||||
export default new AutoloadPage('user_verify', () => {
|
||||
$(document).on('click', '[name="login_submit"]', async (ev) => {
|
||||
ev.preventDefault();
|
||||
const $form = ev.currentTarget.form;
|
||||
const uname = $('[name="uname"]').val() as string;
|
||||
const { tfa, authn } = await api(gql`
|
||||
user(uname:${uname}){
|
||||
tfa
|
||||
authn
|
||||
}
|
||||
`, ['data', 'user']);
|
||||
if (authn || tfa) {
|
||||
let action = (authn && tfa) ? await chooseAction(true) : '';
|
||||
if (!action) action = tfa ? await chooseAction(false) : 'webauthn';
|
||||
if (action === 'webauthn') {
|
||||
const challenge = await verifywebauthn($form);
|
||||
if (challenge) $form['authnChallenge'].value = challenge;
|
||||
else return;
|
||||
} else $form['tfa'].value = $('[name="tfa_code"]').val() as string;
|
||||
}
|
||||
$form.submit();
|
||||
});
|
||||
$(document).on('click', '[name=webauthn_verify]', async (ev) => {
|
||||
ev.preventDefault();
|
||||
const $form = ev.currentTarget.form;
|
||||
if (!$form) return;
|
||||
const challenge = await verifywebauthn($form);
|
||||
if (challenge) {
|
||||
$form['authnChallenge'].value = challenge;
|
||||
$form.submit();
|
||||
}
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue