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:
pandaandundefined authored and GitHub committed 2023-01-23 17:49:40 +08:00
1 parent 3d3a5480e3
commit d069fe127b
29 files changed
+621 -195

No files matched your search

+84 -2
View File
@@ -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) {
+80 -46
View File
@@ -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);