mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-17 17:36:08 +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
@@ -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>
|
||||
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