mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-20 09:46:28 +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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user