mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-20 01:38:19 +08:00
core&ui: add webauthn support (#484)
Co-authored-by: panda <panda_dtdyy@outlook.com> Co-authored-by: undefined <i@undefined.moe>
This commit is contained in:
1 parent
3d3a5480e3
commit
d069fe127b
29 files changed
+621
-195
No files matched your search
@@ -20,6 +20,24 @@
|
||||
line-height: rem($form-control-height)
|
||||
color: green
|
||||
|
||||
.autherlist
|
||||
font-size: rem($font-size-secondary)
|
||||
|
||||
.autherlist__icon
|
||||
font-size: rem(30px)
|
||||
color: #888
|
||||
margin: rem(0 20px)
|
||||
|
||||
.autherlist__item
|
||||
margin: rem(10px 0)
|
||||
padding: rem(10px 0)
|
||||
|
||||
&:not(:last-child)
|
||||
border-bottom: 1px solid #DDD
|
||||
|
||||
input
|
||||
margin: 0
|
||||
|
||||
+mobile()
|
||||
.sessionlist__item
|
||||
.media__left
|
||||
|
||||
@@ -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>
|
||||
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>
|
||||
<label>${i18n('6-Digit Code')}
|
||||
<div class="textbox-container">
|
||||
<input class="textbox" type="text" name="tfa_code" data-autofocus autocomplete="off"></input>
|
||||
</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>
|
||||
</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>
|
||||
</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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user