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
@@ -17,14 +17,16 @@ import root from './utils/root';
|
||||
const argv = cac().parse();
|
||||
|
||||
async function runWebpack({
|
||||
watch, production, measure, dev,
|
||||
watch, production, measure, dev, https,
|
||||
}) {
|
||||
const compiler = webpack(webpackConfig({ watch, production, measure }));
|
||||
if (dev) {
|
||||
const server = new WebpackDevServer({
|
||||
port: 8000,
|
||||
port: https ? 8001 : 8000,
|
||||
compress: true,
|
||||
hot: true,
|
||||
server: https ? 'https' : 'http',
|
||||
allowedHosts: 'all',
|
||||
proxy: {
|
||||
context: (p) => p !== '/ws',
|
||||
target: 'http://localhost:2333',
|
||||
|
||||
@@ -24,7 +24,7 @@ $border-1-color = $default-color
|
||||
$border-2-color = lighten($default-color, 20%)
|
||||
|
||||
$immersive-primary-color = #ffef87
|
||||
$immersive-text-color = #f8f8f8
|
||||
$immersive-text-color = #ffffff
|
||||
$immersive-header-color = #ffffff
|
||||
|
||||
$success-color = #25ad40
|
||||
|
||||
@@ -70,7 +70,7 @@ $checkbox-size = 16px
|
||||
margin-right: rem(5px)
|
||||
|
||||
label.checkbox.inverse
|
||||
color: rgba($immersive-text-color, 0.8) !important
|
||||
color: $immersive-text-color !important
|
||||
|
||||
input
|
||||
color: $immersive-text-color
|
||||
@@ -80,7 +80,7 @@ label.checkbox.inverse
|
||||
color: rgba($immersive-text-color, 0.6)
|
||||
|
||||
&:enabled:read-write:hover
|
||||
border-color: rgba($immersive-text-color, 0.8)
|
||||
border-color: $immersive-text-color
|
||||
|
||||
&:enabled:read-write:focus
|
||||
border-color: $immersive-primary-color
|
||||
|
||||
@@ -57,7 +57,7 @@ label.textbox.material
|
||||
color: $input-focus-border-color
|
||||
|
||||
label.textbox.material.inverse
|
||||
color: rgba($immersive-text-color, 0.8)
|
||||
color: $immersive-text-color
|
||||
|
||||
input
|
||||
color: $immersive-text-color
|
||||
@@ -67,7 +67,7 @@ label.textbox.material.inverse
|
||||
color: rgba($immersive-text-color, 0.6)
|
||||
|
||||
&:enabled:read-write:hover
|
||||
border-color: rgba($immersive-text-color, 0.8)
|
||||
border-color: $immersive-text-color
|
||||
|
||||
&:enabled:read-write:focus
|
||||
border-color: $immersive-primary-color
|
||||
|
||||
@@ -26,6 +26,7 @@ __langname: 简体中文
|
||||
'Copy failed :(': '复制失败 :('
|
||||
'Current dataset: {0}': '当前测试数据: {0}'
|
||||
'Discussion: Terms Of Service': 讨论区服务条款
|
||||
'Failed to get credential: {0}': '获取认证数据失败: {0}'
|
||||
'File upload failed: {0}': 文件上传失败:{0}
|
||||
'Format: category 1, category 2, ..., category n': 格式:分类1, 分类2, ..., 分类n
|
||||
'Hello, {0}! You can click following link to active your new email of your {1} account:': 您好,{0}!您可以点击以下链接来激活您 {1} 账户的新电子邮件地址:
|
||||
@@ -35,6 +36,7 @@ __langname: 简体中文
|
||||
'No': 否
|
||||
'Note: Problem title may not be hidden.': 注意:题目标题可能不会被隐藏。
|
||||
'Packages have new version: {0}': '以下组件有新版本: {0}'
|
||||
'Please use your two factor authentication app to scan the qrcode below:': '请使用你的两步验证APP扫描此二维码:'
|
||||
'Role {1} already exists in domain {0}.': '域 {0} 中已存在角色 {1}。'
|
||||
'Solved {0} problems, RP: {1} (No. {2})': '解决了 {0} 道题目,RP: {1} (No. {2})'
|
||||
'The value `{1}` of {0} already exists.': '{0} 的值 `{1}` 已经存在。'
|
||||
@@ -47,6 +49,7 @@ __langname: 简体中文
|
||||
(leave blank if none): (若不需要则留空)
|
||||
(None): (无)
|
||||
(Not changed): 未更改
|
||||
6-Digit Code: 6位代码
|
||||
Aborted: 已放弃
|
||||
About {0}: 关于 {0}
|
||||
About Markdown: 关于 Markdown
|
||||
@@ -63,6 +66,7 @@ According to the contest rules, you cannot view your submission details at curre
|
||||
Account Settings: 账户设置
|
||||
Action: 动作
|
||||
Active Sessions: 活动会话
|
||||
Add Authenticator: 添加认证器
|
||||
Add File: 添加文件
|
||||
Add module: 添加模块
|
||||
Add new data: 添加新数据
|
||||
@@ -95,6 +99,8 @@ Attend Contest: 参加比赛
|
||||
Attend contests: 参加比赛
|
||||
Attended: 已参加
|
||||
Attendee Manage: 参赛者管理
|
||||
Authenticator: 认证器
|
||||
Authenticators: 认证器
|
||||
author: 作者
|
||||
Auto hide problems during the contest: 在比赛过程中自动隐藏题目
|
||||
Auto Read Tasks: 自动识别任务
|
||||
@@ -133,6 +139,7 @@ Check In: 签到
|
||||
Checker: 比较器
|
||||
CheckerType: 比较器类型
|
||||
Chinese: 中文
|
||||
Choose Authenticator Type: 选择认证器类型
|
||||
Choose the background image in your profile page.: 选择您资料页面的背景图片。
|
||||
Claim homework: 认领作业
|
||||
Claim Homework: 认领作业
|
||||
@@ -344,6 +351,7 @@ Export as {0}: 导出为 {0}
|
||||
Extension (days): 最长延期 (日)
|
||||
Extension Score Penalty: 延期递交扣分规则
|
||||
Extra Files Config: 额外文件设置
|
||||
Failed to fetch registration data.: 获取注册数据失败。
|
||||
Failed to join the domain. You are already a member.: 加入域失败,您已是该域的成员。
|
||||
Failed to parse subtask.: 无法解析子任务信息。
|
||||
Failed to parse testcase.: 无法解析测试点信息。
|
||||
@@ -511,6 +519,7 @@ Month: 月
|
||||
Monthly Popular: 月度最受欢迎
|
||||
More: 更多
|
||||
Most Upvoted Solutions: 最被赞同的题解
|
||||
Multi Platform Authenticator: 跨平台认证器
|
||||
My Domains: 我的域
|
||||
My Files: 我的文件
|
||||
My Profile: 我的资料
|
||||
@@ -605,6 +614,7 @@ PingFang SC: 苹方简体
|
||||
Plan: 计划
|
||||
Please attend contest to see the problems.: 请参加比赛来查看题目。
|
||||
Please claim the assignment to see the problems.: 认领作业后才可以查看作业内容。
|
||||
Please follow the instructions on your device to complete the verification.: 请按照您设备上的指示完成验证。
|
||||
Please select at least one file to perform this operation.: 请选择至少一个文件来执行此操作。
|
||||
Please select at least one role to perform this operation.: 请选择至少一个角色来进行操作。
|
||||
Please select at least one user to perform this operation.: 请选择至少一个用户来进行操作。
|
||||
@@ -862,8 +872,10 @@ training_create: 创建训练
|
||||
training_edit: 编辑训练
|
||||
training_main: 训练
|
||||
Training: 训练
|
||||
Transports: 支持类型
|
||||
Tried: 尝试
|
||||
Two Factor Authentication: 两步验证码
|
||||
Two Factor Authentication Code: 两步验证码
|
||||
Two Factor Authentication: 两步验证
|
||||
Type: 类型
|
||||
UI Language: 用户界面语言
|
||||
Uncompleted: 未完成
|
||||
@@ -891,8 +903,11 @@ Upvote: 好评
|
||||
Usage exceeded.: 用量超限。
|
||||
Use admin specificed: 由管理员指定
|
||||
Use algorithm calculated: 使用算法计算
|
||||
Use Authenticator: 使用认证器
|
||||
Use average of above: 使用上面的平均值
|
||||
Use Password: 使用密码
|
||||
Use Subtasks Mode: 使用子任务模式
|
||||
Use TFA Code: 使用两步验证码
|
||||
User {0} already exists.: 用户 {0} 已存在。
|
||||
User {0} not found.: 用户 {0} 不存在。
|
||||
User can join this domain by visiting the following URL: 用户可以访问此链接来加入此域
|
||||
@@ -983,5 +998,8 @@ You're not logged in.: 您没有登录。
|
||||
You've already attended this contest.: 您已经参加本次比赛。
|
||||
You've already claimed this homework.: 您已认领过该作业。
|
||||
You've already voted.: 您已经投过票。
|
||||
Your account has two factor authentication enabled. Please choose an authenticator to verify.: 您的账户已启用双因素认证。请选择一个验证器来验证。
|
||||
Your browser does not support WebAuthn or you are not in secure context.: 您的浏览器不支持WebAuthn或是您不在安全环境中。
|
||||
Your Device: 你的设备
|
||||
Your permission: 您的权限
|
||||
Your role: 您的角色
|
||||
@@ -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 |
@@ -212,11 +212,11 @@ u, .typo-u
|
||||
display: none
|
||||
|
||||
.supplementary.inverse
|
||||
color: rgba($immersive-text-color, 0.8)
|
||||
color: $immersive-text-color !important
|
||||
|
||||
a
|
||||
&, &:visited, &:hover
|
||||
color: rgba($immersive-text-color, 0.8)
|
||||
color: $immersive-text-color !important
|
||||
|
||||
.group-list__item
|
||||
margin-bottom: 1rem
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"@fontsource/source-code-pro": "^4.5.12",
|
||||
"@fontsource/ubuntu-mono": "^4.5.11",
|
||||
"@hydrooj/utils": "workspace:*",
|
||||
"@simplewebauthn/browser": "^7.0.0",
|
||||
"@svgr/webpack": "^6.5.1",
|
||||
"@types/gulp-if": "^0.0.34",
|
||||
"@types/jquery": "^3.5.16",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ const DO_NOT_CACHE = ['vditor', '.worker.js', 'fonts', 'i.monaco'];
|
||||
function shouldCache(name: string, request?: Request) {
|
||||
if (!name.split('/').pop()) return false;
|
||||
if (!name.split('/').pop().includes('.')) return false;
|
||||
if (process.env.NODE_ENV !== 'production' && (name.includes('.hot-update.') || name.includes('?version='))) return false;
|
||||
// For files download, a response is formatted as string
|
||||
if (request && request.headers.get('Do-Not-Cache')) return false;
|
||||
return true;
|
||||
|
||||
@@ -89,16 +89,55 @@
|
||||
<div class="columns">
|
||||
<div class="section">
|
||||
<div class="section__header">
|
||||
<h1 class="section__title" id="tfa" data-heading>{{ _('Two Factor Authentication') }}</h1>
|
||||
<h1 class="section__title" id="authenticator" data-heading>{{ _('Authenticators') }}</h1>
|
||||
<div class="section__tools">
|
||||
{% if handler.user.tfa %}
|
||||
<button class="primary rounded button" name="tfa_enabled"><span class="icon icon-check"></span> {{ _('Enabled') }}</button>
|
||||
{% else %}
|
||||
<button class="primary rounded button" name="tfa_enable"><span class="icon icon-wrench"></span> {{ _('Enable') }}</button>
|
||||
{% endif %}
|
||||
<button class="primary rounded button" name="auth_enable"><span class="icon icon-wrench"></span> {{ _('Add Authenticator') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section__body"></div>
|
||||
<div class="section__body">
|
||||
<ul class="autherlist">
|
||||
{%- if handler.user.tfa -%}
|
||||
<li class="autherlist__item">
|
||||
<div class="media">
|
||||
<div class="media__left medium">
|
||||
<span class="autherlist__icon icon icon-platform--mobile }}"></span>
|
||||
</div>
|
||||
<div class="media__body medium typo">
|
||||
<p>{{ _('Authenticator') }}: {{ _('Two Factor Authentication') }}</p>
|
||||
</div>
|
||||
<div class="media__right medium">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="operation" value="disable_tfa">
|
||||
<input type="submit" value="{{ _('Remove') }}" class="rounded button">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{%- endif -%}
|
||||
{%- for authenticator in authenticators -%}
|
||||
<li class="autherlist__item">
|
||||
<div class="media">
|
||||
<div class="media__left medium">
|
||||
<span class="autherlist__icon icon icon-{{ 'fingerprint' if authenticator.authenticatorAttachment === 'platform' else 'usb' }}"></span>
|
||||
</div>
|
||||
<div class="media__body medium typo">
|
||||
<p>{{ _('Authenticator') }}: {{ authenticator.name }} ({{ authenticator.credentialID.buffer.toString('base64')|truncate(6, end='') }})</p>
|
||||
<p>{{ _('Type') }}: {{ authenticator.fmt }} </p>
|
||||
<p>{{ _('Device Type') }}: {{ authenticator.credentialDeviceType }} </p>
|
||||
<p>{{ _('Registered at') }}: {{ datetimeSpan(authenticator.regat)|safe }}</p>
|
||||
</div>
|
||||
<div class="media__right medium">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="id" value="{{ authenticator.credentialID.buffer.toString('base64') }}">
|
||||
<input type="hidden" name="operation" value="disable_authn">
|
||||
<input type="submit" value="{{ _('Remove') }}" class="rounded button">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{%- endfor -%}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,19 +27,15 @@
|
||||
<input name="password" type="password">
|
||||
</label>
|
||||
</div></div>
|
||||
<div class="row" style="display: none" id="tfa_div"><div class="columns">
|
||||
<label class="material textbox">
|
||||
{{ _('Two Factor Authentication Code') }}
|
||||
<input name="tfa" type="number">
|
||||
</label>
|
||||
</div></div>
|
||||
<div class="row"><div class="columns">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="rememberme" class="checkbox">{{ _('Remember me') }}
|
||||
</label>
|
||||
</div></div>
|
||||
<div class="row"><div class="columns">
|
||||
<input type="submit" value="{{ _('Sign In') }}" class="expanded rounded primary button">
|
||||
<input type="hidden" name="tfa" value="">
|
||||
<input type="hidden" name="authnChallenge" value="">
|
||||
<input type="submit" value="{{ _('Login') }}" name="login_submit" class="expanded rounded primary button">
|
||||
</div></div>
|
||||
{% endif %}
|
||||
{%- for v in handler.loginMethods -%}
|
||||
|
||||
@@ -17,20 +17,16 @@
|
||||
<input name="password" type="password">
|
||||
</label>
|
||||
</div></div>
|
||||
<div class="row" style="display: none" id="tfa_div"><div class="columns">
|
||||
<label class="inverse material textbox">
|
||||
{{ _('Two Factor Authentication Code') }}
|
||||
<input name="tfa" type="number">
|
||||
</label>
|
||||
</div></div>
|
||||
<div class="row"><div class="columns">
|
||||
<label class="inverse material checkbox" style="color:#fff;">
|
||||
<input type="checkbox" name="rememberme" class="checkbox">{{ _('Remember me') }}
|
||||
</label>
|
||||
</div></div>
|
||||
<div class="row"><div class="columns">
|
||||
<input type="hidden" name="tfa" value="">
|
||||
<input type="hidden" name="authnChallenge" value="">
|
||||
<div class="text-center">
|
||||
<input type="submit" value="{{ _('Login') }}" class="inverse expanded rounded primary button">
|
||||
<input type="submit" value="{{ _('Login') }}" name="login_submit" class="inverse expanded rounded primary button">
|
||||
</div>
|
||||
</div></div>
|
||||
{% endif %}
|
||||
|
||||
@@ -4,30 +4,51 @@
|
||||
<div class="immersive--content immersive--center">
|
||||
<h1>{{ _('Confirm Access') }}</h1>
|
||||
<form method="POST">
|
||||
<div class="row"><div class="columns">
|
||||
<label class="inverse material textbox">
|
||||
{{ _('Password') }}
|
||||
<input name="password" type="password" autofocus>
|
||||
</label>
|
||||
{% if UserContext.authn %}
|
||||
<div class="row sudo-div confirm-div nojs--hide" data-sudo="authn"><div class="columns">
|
||||
<input value="{{ _('Use Authenticator') }}" class="inverse expanded rounded primary button" name="webauthn_verify">
|
||||
<input type="hidden" name="authnChallenge" value="">
|
||||
</div></div>
|
||||
<div class="row" style="display: none" id="tfa_div"><div class="columns">
|
||||
{% endif %}
|
||||
{% if UserContext.tfa %}
|
||||
<div class="row sudo-div" data-sudo="tfa" style="display:none"><div class="columns">
|
||||
<label class="inverse material textbox">
|
||||
{{ _('Two Factor Authentication Code') }}
|
||||
<input name="tfa" type="number">
|
||||
</label>
|
||||
</div></div>
|
||||
<div class="row"><div class="columns">
|
||||
<div class="text-center">
|
||||
<input type="submit" value="{{ _('Confirm') }}" class="inverse expanded rounded primary button">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row sudo-div" data-sudo="password"><div class="columns">
|
||||
<label class="inverse material textbox">
|
||||
{{ _('Password') }}
|
||||
<input name="password" type="password">
|
||||
</label>
|
||||
</div></div>
|
||||
<div class="row"><div class="columns">
|
||||
<div class="text-center supplementary inverse">
|
||||
<p><strong>Tip:</strong> You are entering <a href="https://docs.github.com/articles/sudo-mode">sudo mode</a>.</p>
|
||||
<p>After you've performed a sudo-protected action, you'll only be asked to re-authenticate again after a few hours of inactivity.</p>
|
||||
<div class="row confirm-div"><div class="columns">
|
||||
<div class="text-center">
|
||||
<input value="{{ _('Confirm') }}" class="inverse expanded rounded primary button" name="confirm">
|
||||
</div>
|
||||
</div></div>
|
||||
</form>
|
||||
<div class="row"><div class="columns">
|
||||
<div class="text-center supplementary inverse">
|
||||
<p><strong>Tip:</strong> You are entering <a href="https://docs.github.com/articles/sudo-mode">sudo mode</a>.</p>
|
||||
<p>After you've performed a sudo-protected action, you'll only be asked to re-authenticate again after a few hours of inactivity.</p>
|
||||
</div>
|
||||
</div></div>
|
||||
{% if UserContext.authn or UserContext.tfa %}
|
||||
<br>
|
||||
<div class="row nojs--hide"><div class="columns">
|
||||
<div class="supplementary inverse typo">
|
||||
<p><strong>{{ _('Or use other methods:') }}</strong></p>
|
||||
<ol>
|
||||
{% if UserContext.authn %}<li class="sudo-switch" data-sudo="authn"><a>{{ _('Use Authenticator') }}</a></li>{% endif %}
|
||||
{% if UserContext.tfa %}<li class="sudo-switch" data-sudo="tfa"><a>{{ _('Use TFA Code') }}</a></li>{% endif %}
|
||||
<li class="sudo-switch" data-sudo="password" style="display:none"><a>{{ _('Use Password') }}</a></li>
|
||||
</ol>
|
||||
</div>
|
||||
</div></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div></div>
|
||||
{% endblock %}
|
||||
@@ -17,6 +17,18 @@ export function delay(ms) {
|
||||
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
const defaultDict = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890';
|
||||
|
||||
export function secureRandomString(digit = 32, dict = defaultDict) {
|
||||
let result = '';
|
||||
const crypto = window.crypto || (window as any).msCrypto;
|
||||
if (!crypto?.getRandomValues) throw new Error('crypto.getRandomValues not supported');
|
||||
const array = new Uint32Array(digit);
|
||||
crypto.getRandomValues(array);
|
||||
for (let i = 0; i < digit; i++) result += dict[array[i] % dict.length];
|
||||
return result;
|
||||
}
|
||||
|
||||
type Substitution = string | number | { templateRaw: true, html: string };
|
||||
|
||||
export function tpl(pieces: TemplateStringsArray, ...substitutions: Substitution[]) {
|
||||
@@ -146,6 +158,7 @@ Object.assign(window.Hydro.utils, {
|
||||
i18n,
|
||||
rawHtml,
|
||||
substitute,
|
||||
secureRandomString,
|
||||
request,
|
||||
tpl,
|
||||
delay,
|
||||
|
||||
Reference in new issue
Block a user