core&ui: add webauthn support (#484)

Co-authored-by: panda <panda_dtdyy@outlook.com>
Co-authored-by: undefined <i@undefined.moe>
pull/498/head
panda 2 years ago committed by GitHub
parent 3d3a5480e3
commit d069fe127b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -14,6 +14,7 @@
"build:ui": "node packages/ui-default/build --gulp && node packages/ui-default/build",
"build:ui:gulp": "node packages/ui-default/build --gulp",
"build:ui:dev": "node packages/ui-default/build --gulp && node --trace-deprecation packages/ui-default/build --dev",
"build:ui:dev:https": "node packages/ui-default/build --gulp && node --trace-deprecation packages/ui-default/build --dev --https",
"build:ui:production": "cross-env NODE_OPTIONS=--max_old_space_size=8192 node packages/ui-default/build --gulp && node packages/ui-default/build --production",
"build:ui:production:webpack": "cross-env NODE_OPTIONS=--max_old_space_size=8192 node packages/ui-default/build --production",
"test": "node test/entry.js",
@ -29,6 +30,7 @@
"version": "1.0.0",
"license": "AGPL-3.0-only",
"devDependencies": {
"@simplewebauthn/typescript-types": "^7.0.0",
"@types/autocannon": "^7.9.0",
"@types/cross-spawn": "^6.0.2",
"@types/node": "^18.11.18",

@ -19,6 +19,7 @@
"@aws-sdk/s3-request-presigner": "^3.245.0",
"@graphql-tools/schema": "^9.0.12",
"@hydrooj/utils": "workspace:*",
"@simplewebauthn/server": "^7.0.0",
"adm-zip": "0.5.5",
"cac": "^6.7.14",
"cordis": "^2.6.0",

@ -117,7 +117,7 @@ export const OnlyOwnerCanDeleteDomainError = Err('OnlyOwnerCanDeleteDomainError'
export const CannotEditSuperAdminError = Err('CannotEditSuperAdminError', BadRequestError, 'You are not allowed to edit super admin in web.');
export const ProblemConfigError = Err('ProblemConfigError', BadRequestError, 'Invalid problem config.');
export const ProblemIsReferencedError = Err('ProblemIsReferencedError', BadRequestError, 'Cannot {0} of a referenced problem.');
export const TFAOperationError = Err('TFAOperationError', BadRequestError, '2FA is already {0}.');
export const AuthOperationError = Err('AuthOperationError', BadRequestError, '{0} is already {1}.');
export const UserNotFoundError = Err('UserNotFoundError', NotFoundError, 'User {0} not found.');
export const NoProblemError = Err('NoProblemError', NotFoundError, 'No problem.');

@ -1,10 +1,12 @@
import path from 'path';
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
import yaml from 'js-yaml';
import { ObjectID } from 'mongodb';
import { pick } from 'lodash';
import { Binary, ObjectID } from 'mongodb';
import { camelCase } from '@hydrooj/utils/lib/utils';
import { Context } from '../context';
import {
BlacklistedError, DomainAlreadyExistsError, InvalidTokenError,
AuthOperationError, BlacklistedError, DomainAlreadyExistsError, InvalidTokenError,
NotFoundError, PermissionError, UserAlreadyExistError,
UserNotFoundError, ValidationError, VerifyPasswordError,
} from '../error';
@ -13,6 +15,7 @@ import avatar from '../lib/avatar';
import * as mail from '../lib/mail';
import * as useragent from '../lib/useragent';
import { isDomainId, isEmail, isPassword } from '../lib/validator';
import { verifyTFA } from '../lib/verifyTFA';
import BlackListModel from '../model/blacklist';
import { PERM, PRIV } from '../model/builtin';
import * as contest from '../model/contest';
@ -167,6 +170,10 @@ class HomeSecurityHandler extends Handler {
this.response.template = 'home_security.html';
this.response.body = {
sessions,
authenticators: this.user._authenticators.map((c) => pick(c, [
'credentialID', 'name', 'credentialType', 'credentialDeviceType',
'authenticatorAttachment', 'regat', 'fmt',
])),
geoipProvider: geoip?.provider,
icon: useragent.icon,
};
@ -226,6 +233,81 @@ class HomeSecurityHandler extends Handler {
await token.delByUid(this.user._id);
this.response.redirect = this.url('user_login');
}
@requireSudo
@param('code', Types.String)
@param('secret', Types.String)
async postEnableTfa(domainId: string, code: string, secret: string) {
if (this.user._tfa) throw new AuthOperationError('2FA', 'enabled');
if (!verifyTFA(secret, code)) throw new InvalidTokenError('2FA');
await user.setById(this.user._id, { tfa: secret });
this.back();
}
@requireSudo
@param('type', Types.Range(['cross-platform', 'platform']))
async postRegister(domainId: string, type: 'cross-platform' | 'platform') {
const options = generateRegistrationOptions({
rpName: system.get('server.name'),
rpID: this.request.hostname,
userID: this.user._id.toString(),
userDisplayName: this.user.uname,
userName: `${this.user.uname}(${this.user.mail})`,
attestationType: 'direct',
excludeCredentials: this.user._authenticators.map((c) => ({
id: c.credentialID.buffer,
type: 'public-key',
})),
authenticatorSelection: {
authenticatorAttachment: type,
},
});
this.session.webauthnVerify = options.challenge;
this.response.body.authOptions = options;
}
@requireSudo
@param('name', Types.String)
async postEnableAuthn(domainId: string, name: string) {
if (!this.session.webauthnVerify) throw new InvalidTokenError(token.TYPE_TEXTS[token.TYPE_WEBAUTHN]);
const verification = await verifyRegistrationResponse({
response: this.args.result,
expectedChallenge: this.session.webauthnVerify,
expectedOrigin: this.request.headers.origin,
expectedRPID: this.request.hostname,
}).catch(() => { throw new ValidationError('verify'); });
if (!verification.verified) throw new ValidationError('verify');
const info = verification.registrationInfo;
const id = Buffer.from(info.credentialID);
if (this.user._authenticators.find((c) => c.credentialID.buffer.toString() === id.toString())) throw new ValidationError('authenticator');
this.user._authenticators.push({
...info,
credentialID: new Binary(id),
credentialPublicKey: new Binary(Buffer.from(info.credentialPublicKey)),
attestationObject: new Binary(Buffer.from(info.attestationObject)),
name,
regat: Date.now(),
authenticatorAttachment: this.args.result.authenticatorAttachment || 'cross-platform',
});
await user.setById(this.user._id, { authenticators: this.user._authenticators });
this.back();
}
@requireSudo
@param('id', Types.String)
async postDisableAuthn(domainId: string, id: string) {
const authenticators = this.user._authenticators?.filter((c) => c.credentialID.buffer.toString('base64') !== id);
if (this.user._authenticators?.length === authenticators?.length) throw new ValidationError('authenticator');
await user.setById(this.user._id, { authenticators });
this.back();
}
@requireSudo
async postDisableTfa() {
if (!this.user._tfa) throw new AuthOperationError('2FA', 'disabled');
await user.setById(this.user._id, undefined, { tfa: '' });
this.back();
}
}
function set(s: Setting, key: string, value: any) {

@ -1,15 +1,15 @@
import { generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server';
import moment from 'moment-timezone';
import notp from 'notp';
import b32 from 'thirty-two';
import {
BlacklistedError, BuiltinLoginError, ForbiddenError, InvalidTokenError,
SystemError, TFAOperationError, UserAlreadyExistError, UserFacingError,
AuthOperationError, BlacklistedError, BuiltinLoginError, ForbiddenError, InvalidTokenError,
SystemError, UserAlreadyExistError, UserFacingError,
UserNotFoundError, ValidationError, VerifyPasswordError,
} from '../error';
import { Udoc, User } from '../interface';
import avatar from '../lib/avatar';
import { sendMail } from '../lib/mail';
import { isEmail, isPassword } from '../lib/validator';
import { verifyTFA } from '../lib/verifyTFA';
import BlackListModel from '../model/blacklist';
import { PERM, PRIV, STATUS } from '../model/builtin';
import * as ContestModel from '../model/contest';
@ -27,12 +27,6 @@ import {
} from '../service/server';
import { registerResolver, registerValue } from './api';
function verifyToken(secret: string, code?: string) {
if (!code || !code.length) return null;
const bin = b32.decode(secret);
return notp.totp.verify(code.replace(/\W+/g, ''), bin);
}
registerValue('User', [
['_id', 'Int!'],
['uname', 'String!'],
@ -44,35 +38,10 @@ registerValue('User', [
['priv', 'Int!', 'User Privilege'],
['avatarUrl', 'String'],
['tfa', 'Boolean!'],
['authn', 'Boolean!'],
['displayName', 'String'],
]);
registerResolver(
'User', 'TFA', 'TFAContext', () => ({}),
'Two Factor Authentication Config',
);
registerResolver(
'TFAContext', 'enable(secret: String!, code: String!)', 'Boolean!',
async (arg, ctx) => {
if (ctx.user._tfa) throw new TFAOperationError('enabled');
if (!verifyToken(arg.secret, arg.code)) throw new InvalidTokenError('2FA');
await user.setById(ctx.user._id, { tfa: arg.secret });
// TODO: return backup codes
return true;
},
'Enable Two Factor Authentication for current user',
);
registerResolver(
'TFAContext', 'disable(code: String!)', 'Boolean!',
async (arg, ctx) => {
if (!ctx.user._tfa) throw new TFAOperationError('disabled');
if (!verifyToken(ctx.user._tfa, arg.code)) throw new InvalidTokenError('2FA');
await user.setById(ctx.user._id, undefined, { tfa: '' });
return true;
},
'Disable Two Factor Authentication for current user',
);
registerResolver('Query', 'user(id: Int, uname: String, mail: String)', 'User', (arg, ctx) => {
if (arg.id) return user.getById(ctx.args.domainId, arg.id);
if (arg.mail) return user.getByEmail(ctx.args.domainId, arg.mail);
@ -116,7 +85,11 @@ class UserLoginHandler extends Handler {
@param('rememberme', Types.Boolean)
@param('redirect', Types.String, true)
@param('tfa', Types.String, true)
async post(domainId: string, uname: string, password: string, rememberme = false, redirect = '', tfa = '') {
@param('authnChallenge', Types.String, true)
async post(
domainId: string, uname: string, password: string, rememberme = false, redirect = '',
tfa = '', authnChallenge = '',
) {
if (!system.get('server.login')) throw new BuiltinLoginError();
let udoc = await user.getByEmail(domainId, uname);
udoc ||= await user.getByUname(domainId, uname);
@ -126,7 +99,16 @@ class UserLoginHandler extends Handler {
this.limitRate(`user_login_${uname}`, 60, 5, false),
oplog.log(this, 'user.login', { redirect }),
]);
if (udoc._tfa && !verifyToken(udoc._tfa, tfa)) throw new InvalidTokenError('2FA');
if (udoc.tfa || udoc.authn) {
if (udoc.tfa && tfa) {
if (!verifyTFA(udoc._tfa, tfa)) throw new InvalidTokenError('2FA');
} else if (udoc.authn && authnChallenge) {
const challenge = await token.get(authnChallenge, token.TYPE_WEBAUTHN);
if (!challenge || challenge.uid !== udoc._id) throw new InvalidTokenError(token.TYPE_TEXTS[token.TYPE_WEBAUTHN]);
if (!challenge.verified) throw new ValidationError('challenge');
await token.del(authnChallenge, token.TYPE_WEBAUTHN);
} else throw new ValidationError('2FA', 'Authn');
}
udoc.checkPassword(password);
await user.setById(udoc._id, { loginat: new Date(), loginip: this.request.ip });
if (!udoc.hasPriv(PRIV.PRIV_USER_PROFILE)) throw new BlacklistedError(uname, udoc.banReason);
@ -148,19 +130,23 @@ class UserSudoHandler extends Handler {
this.response.template = 'user_sudo.html';
}
@param('password', Types.String)
@param('password', Types.String, true)
@param('tfa', Types.String, true)
async post(domainId: string, password: string, tfa = '') {
@param('authnChallenge', Types.String, true)
async post(domainId: string, password = '', tfa = '', authnChallenge = '') {
if (!this.session.sudoArgs?.method) throw new ForbiddenError();
await Promise.all([
this.limitRate('user_sudo', 60, 5, true),
oplog.log(this, 'user.sudo', {}),
]);
if (tfa) {
if (!this.user._tfa || !verifyToken(this.user._tfa, tfa)) throw new InvalidTokenError('2FA');
} else {
this.user.checkPassword(password);
}
if (this.user.authn && authnChallenge) {
const challenge = await token.get(authnChallenge, token.TYPE_WEBAUTHN);
if (challenge?.uid !== this.user._id) throw new InvalidTokenError(token.TYPE_TEXTS[token.TYPE_WEBAUTHN]);
if (!challenge.verified) throw new ValidationError('challenge');
await token.del(authnChallenge, token.TYPE_WEBAUTHN);
} else if (this.user.tfa && tfa) {
if (!verifyTFA(this.user._tfa, tfa)) throw new InvalidTokenError('2FA');
} else this.user.checkPassword(password);
this.session.sudo = Date.now();
if (this.session.sudoArgs.method.toLowerCase() !== 'get') {
this.response.template = 'user_sudo_redirect.html';
@ -170,6 +156,53 @@ class UserSudoHandler extends Handler {
}
}
class UserWebauthnHandler extends Handler {
noCheckPermView = true;
@param('uname', Types.Username, true)
async get(domainId: string, uname: string) {
const udoc = this.user._id ? this.user : ((await user.getByEmail(domainId, uname)) || await user.getByUname(domainId, uname));
if (!udoc._id) throw new UserNotFoundError(uname || 'user');
if (!udoc.authn) throw new AuthOperationError('authn', 'disabled');
const options = generateAuthenticationOptions({
allowCredentials: udoc._authenticators.map((authenticator) => ({
id: authenticator.credentialID.buffer,
type: 'public-key',
})),
userVerification: 'preferred',
});
await token.add(token.TYPE_WEBAUTHN, 60, { uid: udoc._id }, options.challenge);
this.session.challenge = options.challenge;
this.response.body.authOptions = options;
}
async post({ domainId, result }) {
const challenge = this.session.challenge;
if (!challenge) throw new ForbiddenError();
const tdoc = await token.get(challenge, token.TYPE_WEBAUTHN);
if (!tdoc) throw new InvalidTokenError(token.TYPE_TEXTS[token.TYPE_WEBAUTHN]);
const udoc = await user.getById(domainId, tdoc.uid);
const authenticator = udoc._authenticators?.find((c) => c.credentialID.toString('base64url') === result.id);
if (!authenticator) throw new ValidationError('authenticator');
const verification = await verifyAuthenticationResponse({
response: result,
expectedChallenge: challenge,
expectedOrigin: this.request.headers.origin,
expectedRPID: this.request.hostname,
authenticator: {
...authenticator,
credentialID: authenticator.credentialID.buffer,
credentialPublicKey: authenticator.credentialPublicKey.buffer,
},
}).catch(() => null);
if (!verification?.verified) throw new ValidationError('authenticator');
authenticator.counter = verification.authenticationInfo.newCounter;
await user.setById(udoc._id, { authenticators: udoc._authenticators });
await token.update(challenge, token.TYPE_WEBAUTHN, 60, { verified: true });
this.back();
}
}
class UserLogoutHandler extends Handler {
noCheckPermView = true;
@ -464,7 +497,8 @@ class OauthCallbackHandler extends Handler {
export async function apply(ctx) {
ctx.Route('user_login', '/login', UserLoginHandler);
ctx.Route('user_oauth', '/oauth/:type', OauthHandler);
ctx.Route('user_sudo', '/user/sudo', UserSudoHandler);
ctx.Route('user_sudo', '/user/sudo', UserSudoHandler, PRIV.PRIV_USER_PROFILE);
ctx.Route('user_webauthn', '/user/webauthn', UserWebauthnHandler);
ctx.Route('user_oauth_callback', '/oauth/:type/callback', OauthCallbackHandler);
ctx.Route('user_register', '/register', UserRegisterHandler, PRIV.PRIV_REGISTER_USER);
ctx.Route('user_register_with_code', '/register/:code', UserRegisterWithCodeHandler, PRIV.PRIV_REGISTER_USER);

@ -1,6 +1,9 @@
import { AttestationFormat } from '@simplewebauthn/server/dist/helpers/decodeAttestationObject';
import { AuthenticationExtensionsAuthenticatorOutputs } from '@simplewebauthn/server/dist/helpers/decodeAuthenticatorExtensions';
import { CredentialDeviceType } from '@simplewebauthn/typescript-types';
import type fs from 'fs';
import type { Dictionary, NumericDictionary } from 'lodash';
import type { Cursor, ObjectID } from 'mongodb';
import type { Binary, Cursor, ObjectID } from 'mongodb';
import type { Context } from './context';
import type { ProblemDoc } from './model/problem';
import type { Handler } from './service/server';
@ -56,6 +59,24 @@ export interface OAuthUserResponse {
viewLang?: string;
}
export interface Authenticator {
name: string;
regat: number;
fmt: AttestationFormat;
counter: number;
aaguid: string;
credentialID: Binary;
credentialPublicKey: Binary;
credentialType: 'public-key';
attestationObject: Binary;
userVerified: boolean;
credentialDeviceType: CredentialDeviceType;
credentialBackedUp: boolean;
authenticatorExtensionResults?: AuthenticationExtensionsAuthenticatorOutputs;
authenticatorAttachment: 'platform' | 'cross-platform';
}
export interface Udoc extends Dictionary<any> {
_id: number;
mail: string;

@ -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);
}

@ -13,6 +13,7 @@ class TokenModel {
static TYPE_LOSTPASS = 5;
static TYPE_EXPORT = 6;
static TYPE_IMPORT = 7;
static TYPE_WEBAUTHN = 8;
static TYPE_TEXTS = {
[TokenModel.TYPE_SESSION]: 'Session',
[TokenModel.TYPE_REGISTRATION]: 'Registration',
@ -21,6 +22,7 @@ class TokenModel {
[TokenModel.TYPE_LOSTPASS]: 'Lost Password',
[TokenModel.TYPE_EXPORT]: 'Export',
[TokenModel.TYPE_IMPORT]: 'Import',
[TokenModel.TYPE_WEBAUTHN]: 'WebAuthn',
};
static async add(

@ -3,8 +3,8 @@ import LRU from 'lru-cache';
import { Collection, FilterQuery, ObjectID } from 'mongodb';
import { LoginError, UserAlreadyExistError, UserNotFoundError } from '../error';
import {
BaseUserDict, FileInfo, GDoc, ownerInfo,
Udict, Udoc, VUdoc,
Authenticator, BaseUserDict, FileInfo, GDoc,
ownerInfo, Udict, Udoc, VUdoc,
} from '../interface';
import pwhash from '../lib/hash.hydro';
import * as bus from '../service/bus';
@ -55,6 +55,7 @@ export class User {
_regip: string;
_loginip: string;
_tfa: string;
_authenticators: Authenticator[];
mail: string;
uname: string;
@ -67,6 +68,7 @@ export class User {
scope: bigint;
_files: FileInfo[];
tfa: boolean;
authn: boolean;
group?: string[];
[key: string]: any;
@ -81,6 +83,7 @@ export class User {
this._loginip = udoc.loginip;
this._files = udoc._files || [];
this._tfa = udoc.tfa;
this._authenticators = udoc.authenticators || [];
this.mail = udoc.mail;
this.uname = udoc.uname;
@ -92,6 +95,7 @@ export class User {
this.scope = typeof scope === 'string' ? BigInt(scope) : scope;
this.role = dudoc.role || 'default';
this.tfa = !!udoc.tfa;
this.authn = (udoc.authenticators || []).length > 0;
if (dudoc.group) this.group = [...dudoc.group, this._id.toString()];
for (const key in setting.SETTINGS_BY_KEY) {

@ -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>
</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();
}
});
});

@ -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,

Loading…
Cancel
Save