mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-20 01:38:19 +08:00
core&ui: add webauthn support (#484)
Co-authored-by: panda <panda_dtdyy@outlook.com> Co-authored-by: undefined <i@undefined.moe>
This commit is contained in:
1 parent
3d3a5480e3
commit
d069fe127b
29 files changed
+621
-195
No files matched your search
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user