diff --git a/package.json b/package.json index b7eb2cd00..5f188ff60 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 9d0a2cb1d..9b727500f 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -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", diff --git a/packages/hydrooj/src/error.ts b/packages/hydrooj/src/error.ts index f52546df7..bb7133bbe 100644 --- a/packages/hydrooj/src/error.ts +++ b/packages/hydrooj/src/error.ts @@ -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.'); diff --git a/packages/hydrooj/src/handler/home.ts b/packages/hydrooj/src/handler/home.ts index fea6b8cf2..188e49604 100644 --- a/packages/hydrooj/src/handler/home.ts +++ b/packages/hydrooj/src/handler/home.ts @@ -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) { diff --git a/packages/hydrooj/src/handler/user.ts b/packages/hydrooj/src/handler/user.ts index 07502078b..9baf70ade 100644 --- a/packages/hydrooj/src/handler/user.ts +++ b/packages/hydrooj/src/handler/user.ts @@ -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); diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index d32cb57ed..788acb7ff 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -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 { _id: number; mail: string; diff --git a/packages/hydrooj/src/lib/verifyTFA.ts b/packages/hydrooj/src/lib/verifyTFA.ts new file mode 100644 index 000000000..4013cc94d --- /dev/null +++ b/packages/hydrooj/src/lib/verifyTFA.ts @@ -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); +} diff --git a/packages/hydrooj/src/model/token.ts b/packages/hydrooj/src/model/token.ts index 0a5be4fcf..e6897fcc3 100644 --- a/packages/hydrooj/src/model/token.ts +++ b/packages/hydrooj/src/model/token.ts @@ -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( diff --git a/packages/hydrooj/src/model/user.ts b/packages/hydrooj/src/model/user.ts index 475aace3b..ac260158e 100644 --- a/packages/hydrooj/src/model/user.ts +++ b/packages/hydrooj/src/model/user.ts @@ -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) { diff --git a/packages/ui-default/build/main.ts b/packages/ui-default/build/main.ts index 22b5bdc5f..f9c2cf44f 100644 --- a/packages/ui-default/build/main.ts +++ b/packages/ui-default/build/main.ts @@ -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', diff --git a/packages/ui-default/common/variables.inc.styl b/packages/ui-default/common/variables.inc.styl index d932d8570..7ff750a13 100644 --- a/packages/ui-default/common/variables.inc.styl +++ b/packages/ui-default/common/variables.inc.styl @@ -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 diff --git a/packages/ui-default/components/form/checkbox.page.styl b/packages/ui-default/components/form/checkbox.page.styl index 80124bcdc..c23e9cf2b 100644 --- a/packages/ui-default/components/form/checkbox.page.styl +++ b/packages/ui-default/components/form/checkbox.page.styl @@ -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 diff --git a/packages/ui-default/components/form/textbox.page.styl b/packages/ui-default/components/form/textbox.page.styl index c6ab88616..dbe25b22d 100644 --- a/packages/ui-default/components/form/textbox.page.styl +++ b/packages/ui-default/components/form/textbox.page.styl @@ -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 diff --git a/packages/ui-default/locales/zh.yaml b/packages/ui-default/locales/zh.yaml index 553dfbd7b..95b425fb9 100644 --- a/packages/ui-default/locales/zh.yaml +++ b/packages/ui-default/locales/zh.yaml @@ -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: 您的角色 \ No newline at end of file diff --git a/packages/ui-default/misc/icons/fingerprint.svg b/packages/ui-default/misc/icons/fingerprint.svg new file mode 100644 index 000000000..c83203d5d --- /dev/null +++ b/packages/ui-default/misc/icons/fingerprint.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui-default/misc/icons/usb.svg b/packages/ui-default/misc/icons/usb.svg new file mode 100644 index 000000000..d508f4ddc --- /dev/null +++ b/packages/ui-default/misc/icons/usb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui-default/misc/typography.styl b/packages/ui-default/misc/typography.styl index 7bacfcf85..bcd2e63eb 100644 --- a/packages/ui-default/misc/typography.styl +++ b/packages/ui-default/misc/typography.styl @@ -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 diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 7cedeff36..9d6200e19 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -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", diff --git a/packages/ui-default/pages/home_security.page.styl b/packages/ui-default/pages/home_security.page.styl index 91e0c1490..d10439a94 100644 --- a/packages/ui-default/pages/home_security.page.styl +++ b/packages/ui-default/pages/home_security.page.styl @@ -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 diff --git a/packages/ui-default/pages/home_security.page.ts b/packages/ui-default/pages/home_security.page.ts index 8b83b61d4..972d17315 100644 --- a/packages/ui-default/pages/home_security.page.ts +++ b/packages/ui-default/pages/home_security.page.ts @@ -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` -
-

${i18n('Please use your two factor authentication app to scan the qrcode below:')}

-
- -

${i18n('Click to show secret')}

+const t = (s) => escape(i18n(s)); + +async function enableTfa() { + const enableTFA = new ActionDialog({ + $body: tpl` +
+

${i18n('Please use your two factor authentication app to scan the qrcode below:')}

+
+ +

${i18n('Click to show secret')}

+
+
- `, - 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` -
- -
- `, - 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); + +
+ `, + 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` +
+ +
+ `, + 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) => ` + + `; + const fingerprint = ''; + + $(document).on('click', '[name="auth_enable"]', async () => { + let $body = ` +
+

${t('Choose Authenticator Type')}

+
'; + const action = await new ActionDialog({ $body, $action: [] }).open(); + if (!action || action === 'cancel') return; + if (action === 'tfa') enableTfa(); + else enableAuthn(action); }); }); diff --git a/packages/ui-default/pages/user_login.page.ts b/packages/ui-default/pages/user_login.page.ts deleted file mode 100644 index f16ef4417..000000000 --- a/packages/ui-default/pages/user_login.page.ts +++ /dev/null @@ -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(); - } - }); -}); diff --git a/packages/ui-default/pages/user_sudo.page.ts b/packages/ui-default/pages/user_sudo.page.ts new file mode 100644 index 000000000..bbde8dbe7 --- /dev/null +++ b/packages/ui-default/pages/user_sudo.page.ts @@ -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'))); +}); diff --git a/packages/ui-default/pages/user_verify.page.ts b/packages/ui-default/pages/user_verify.page.ts new file mode 100644 index 000000000..2618d18e9 --- /dev/null +++ b/packages/ui-default/pages/user_verify.page.ts @@ -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` +
+

${i18n('Two Factor Authentication')}

+

${i18n('Your account has two factor authentication enabled. Please choose an authenticator to verify.')}

+
+ +
+
+ + +
+
+ `, + $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(); + } + }); +}); diff --git a/packages/ui-default/service-worker.ts b/packages/ui-default/service-worker.ts index 2dbd77c67..3744b59a1 100644 --- a/packages/ui-default/service-worker.ts +++ b/packages/ui-default/service-worker.ts @@ -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; diff --git a/packages/ui-default/templates/home_security.html b/packages/ui-default/templates/home_security.html index 678c5ebd8..1f144656b 100644 --- a/packages/ui-default/templates/home_security.html +++ b/packages/ui-default/templates/home_security.html @@ -89,16 +89,55 @@
-

{{ _('Two Factor Authentication') }}

+

{{ _('Authenticators') }}

- {% if handler.user.tfa %} - - {% else %} - - {% endif %} +
-
+
+
    + {%- if handler.user.tfa -%} +
  • +
    +
    + +
    +
    +

    {{ _('Authenticator') }}: {{ _('Two Factor Authentication') }}

    +
    +
    +
    + + +
    +
    +
    +
  • + {%- endif -%} + {%- for authenticator in authenticators -%} +
  • +
    +
    + +
    +
    +

    {{ _('Authenticator') }}: {{ authenticator.name }} ({{ authenticator.credentialID.buffer.toString('base64')|truncate(6, end='') }})

    +

    {{ _('Type') }}: {{ authenticator.fmt }}

    +

    {{ _('Device Type') }}: {{ authenticator.credentialDeviceType }}

    +

    {{ _('Registered at') }}: {{ datetimeSpan(authenticator.regat)|safe }}

    +
    +
    +
    + + + +
    +
    +
    +
  • + {%- endfor -%} +
+
diff --git a/packages/ui-default/templates/partials/login_dialog.html b/packages/ui-default/templates/partials/login_dialog.html index 6db055c59..5326ce139 100644 --- a/packages/ui-default/templates/partials/login_dialog.html +++ b/packages/ui-default/templates/partials/login_dialog.html @@ -27,19 +27,15 @@ -
- + + +
{% endif %} {%- for v in handler.loginMethods -%} diff --git a/packages/ui-default/templates/user_login.html b/packages/ui-default/templates/user_login.html index 4c785dfd7..7c37b776f 100644 --- a/packages/ui-default/templates/user_login.html +++ b/packages/ui-default/templates/user_login.html @@ -17,20 +17,16 @@ -
+ +
- +
{% endif %} diff --git a/packages/ui-default/templates/user_sudo.html b/packages/ui-default/templates/user_sudo.html index 42e6a98af..100bb3e6d 100644 --- a/packages/ui-default/templates/user_sudo.html +++ b/packages/ui-default/templates/user_sudo.html @@ -4,30 +4,51 @@

{{ _('Confirm Access') }}

-
- + {% if UserContext.authn %} +
+ +
-