From fe1112e652368b05deb53df2c22708dcd2a3736c Mon Sep 17 00:00:00 2001 From: undefined Date: Fri, 12 Nov 2021 22:23:06 +0800 Subject: [PATCH] core: 2fa support --- packages/hydrooj/package.json | 5 +- packages/hydrooj/src/handler/api.ts | 10 +- packages/hydrooj/src/handler/user.ts | 40 +++++++- packages/hydrooj/src/interface.ts | 2 + packages/hydrooj/src/model/user.ts | 4 + packages/ui-default/package.json | 5 +- .../ui-default/pages/home_security.page.ts | 94 +++++++++++++++++++ packages/ui-default/pages/user_login.page.ts | 17 ++++ .../ui-default/templates/home_security.html | 17 ++++ .../templates/partials/login_dialog.html | 6 ++ packages/ui-default/templates/user_login.html | 6 ++ 11 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 packages/ui-default/pages/home_security.page.ts create mode 100644 packages/ui-default/pages/user_login.page.ts diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index ef1a2c1d0..044bd86cc 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "3.1.28", + "version": "3.1.29", "bin": "bin/hydrooj.js", "main": "src/loader", "module": "src/loader", @@ -38,12 +38,14 @@ "mongodb": "^3.7.3", "nanoid": "^3.1.30", "nodemailer": "^6.6.5", + "notp": "^2.0.3", "p-queue": "^7.1.0", "reflect-metadata": "^0.1.13", "serialize-javascript": "^6.0.0", "sockjs": "^0.3.21", "source-map-support": "^0.5.20", "superagent": "^6.1.0", + "thirty-two": "^1.0.2", "tx2": "^1.0.4" }, "devDependencies": { @@ -60,6 +62,7 @@ "@types/minio": "^7.0.11", "@types/mongodb": "^3.6.20", "@types/nodemailer": "^6.4.4", + "@types/notp": "^2", "@types/serialize-javascript": "^5.0.1", "@types/sockjs": "^0.3.33", "@types/source-map-support": "^0.5.4", diff --git a/packages/hydrooj/src/handler/api.ts b/packages/hydrooj/src/handler/api.ts index 506228f0e..ddaf3f1ee 100644 --- a/packages/hydrooj/src/handler/api.ts +++ b/packages/hydrooj/src/handler/api.ts @@ -31,10 +31,14 @@ interface Context extends ApiHandler { [key: string]: any; } -export function registerResolver(typeName: string, key: string, value: string, func: (args: any, ctx: Context) => any, description?: string) { +export function registerResolver( + typeName: string, key: string, value: string, + func: (args: any, ctx: Context, info: any) => any, + description?: string, +) { registerValue(typeName, key, value, description); - const wrappedFunc = async (arg, ctx) => { - const res = await func(arg, ctx); + const wrappedFunc = async (arg, ctx, info) => { + const res = await func(arg, ctx, info); if (typeof res !== 'object') return res; if (handlers[value]) Object.assign(res, handlers[value]); return res; diff --git a/packages/hydrooj/src/handler/user.ts b/packages/hydrooj/src/handler/user.ts index 2649548a8..d34a46ab9 100644 --- a/packages/hydrooj/src/handler/user.ts +++ b/packages/hydrooj/src/handler/user.ts @@ -1,4 +1,6 @@ import moment from 'moment-timezone'; +import notp from 'notp'; +import b32 from 'thirty-two'; import { BlacklistedError, InvalidTokenError, LoginError, SystemError, UserAlreadyExistError, UserFacingError, @@ -20,6 +22,12 @@ 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!'], @@ -31,8 +39,35 @@ registerValue('User', [ ['regat', 'Date!'], ['priv', 'Int!', 'User Privilege'], ['avatarUrl', 'String'], + ['tfa', 'Boolean!'], ]); +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 Error('2FA is already enabled'); + if (!verifyToken(arg.secret, arg.code)) throw new Error('Invalid 2FA code'); + 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 Error('2FA is already disabled'); + if (!verifyToken(ctx.user._tfa, arg.code)) throw new Error('Invalid 2FA code'); + 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.domainId, arg.id); if (arg.mail) return user.getByEmail(ctx.domainId, arg.mail); @@ -75,11 +110,14 @@ class UserLoginHandler extends Handler { @param('password', Types.String) @param('rememberme', Types.Boolean) @param('redirect', Types.String, true) - async post(domainId: string, uname: string, password: string, rememberme = false, redirect = '') { + @param('tfa', Types.String, true) + async post(domainId: string, uname: string, password: string, rememberme = false, redirect = '', tfa = '') { if (!system.get('server.login')) throw new LoginError('Builtin login disabled.'); let udoc = await user.getByEmail(domainId, uname); if (!udoc) udoc = await user.getByUname(domainId, uname); if (!udoc) throw new UserNotFoundError(uname); + await this.limitRate('user_login', 60, 5); + if (udoc._tfa && !verifyToken(udoc._tfa, tfa)) throw new InvalidTokenError('2FA token invalid.'); 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); diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index 39c6abb95..7226bbda0 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -98,6 +98,7 @@ export interface User extends Record { _hash: string, _regip: string, _loginip: string, + _tfa: string, mail: string, uname: string, @@ -108,6 +109,7 @@ export interface User extends Record { perm: bigint, scope: bigint, role: string, + tfa: boolean, own(doc: T, checkPerm: bigint): boolean own(doc: T, exact: boolean): boolean own(doc: T): boolean diff --git a/packages/hydrooj/src/model/user.ts b/packages/hydrooj/src/model/user.ts index d84332e63..17ad0b12e 100644 --- a/packages/hydrooj/src/model/user.ts +++ b/packages/hydrooj/src/model/user.ts @@ -53,6 +53,7 @@ class User implements _User { _hash: string; _regip: string; _loginip: string; + _tfa: string; mail: string; uname: string; @@ -64,6 +65,7 @@ class User implements _User { role: string; scope: bigint; _files: FileInfo[]; + tfa: boolean; [key: string]: any; constructor(udoc: Udoc, dudoc, scope = PERM.PERM_ALL) { @@ -76,6 +78,7 @@ class User implements _User { this._regip = udoc.ip?.[0] || ''; this._loginip = udoc.loginip; this._files = udoc._files || []; + this._tfa = udoc.tfa; this.mail = udoc.mail; this.uname = udoc.uname; @@ -86,6 +89,7 @@ class User implements _User { this.perm = dudoc.perm; this.scope = typeof scope === 'string' ? BigInt(scope) : scope; this.role = dudoc.role || 'default'; + this.tfa = !!udoc.tfa; for (const key in setting.SETTINGS_BY_KEY) { this[key] = udoc[key] ?? setting.SETTINGS_BY_KEY[key].value; diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 3d4dfdb44..a2d0586dd 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.26.15", + "version": "4.26.16", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", @@ -16,6 +16,7 @@ "@types/jquery": "^3.5.8", "@types/json-schema": "^7.0.9", "@types/katex": "^0.11.1", + "@types/qrcode": "^1", "@types/sockjs-client": "^1.5.1", "@undefined-moe/monaco-yaml": "^2.5.0", "ansi_up": "^5.0.1", @@ -64,6 +65,7 @@ "prism-themes": "^1.9.0", "prismjs": "^1.25.0", "prop-types": "^15.7.2", + "qrcode": "^1.4.4", "react": "^17.0.2", "react-dom": "^17.0.2", "react-redux": "^7.2.6", @@ -82,6 +84,7 @@ "stylus-loader": "^3.0.2", "tether": "^1.4.7", "tether-drop": "^1.4.2", + "thirty-two": "^1.0.2", "through2": "^4.0.2", "timeago-react": "^3.0.4", "timeago.js": "^4.0.2", diff --git a/packages/ui-default/pages/home_security.page.ts b/packages/ui-default/pages/home_security.page.ts new file mode 100644 index 000000000..a3bc54339 --- /dev/null +++ b/packages/ui-default/pages/home_security.page.ts @@ -0,0 +1,94 @@ +import { NamedPage } from 'vj/misc/Page'; +import QRCode from 'qrcode'; +import b32 from 'thirty-two'; +import Notification from 'vj/components/notification'; +import i18n from 'vj/utils/i18n'; +import tpl from 'vj/utils/tpl'; +import { ActionDialog } from 'vj/components/dialog'; +import api, { gql } from 'vj/utils/api'; +import delay from 'vj/utils/delay'; + +export default new NamedPage('home_security', () => { + $(document).on('click', '[name="tfa_enable"]', async () => { + const result = new ActionDialog({ + $body: tpl` +
+

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

+
+ +

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

+
+ +
+ `, + onDispatch(action) { + if (action === 'ok' && $('[name="tfa_code"]').val() === null) { + $('[name="tfa_code"]').focus(); + return false; + } + return true; + }, + }).open(); + const secret = b32.encode(crypto.getRandomValues(new Uint8Array(8))) + .toString('utf8').replace(/=/g, '').toUpperCase(); + $('#secret').on('click', () => $('#secret').html(secret)); + const uri = `otpauth://totp/Hydro:${UserContext.uname}?secret=${secret}&issuer=Hydro`; + const canvas = document.getElementById('qrcode'); + await QRCode.toCanvas(canvas, uri); + const action = await result; + if (action !== 'ok') return; + try { + await api(gql` + user { + TFA { + enable(code: ${$('[name="tfa_code"]').val()}, secret: ${secret}) + } + } + `); + Notification.success(i18n('Successfully enabled.')); + await delay(2000); + window.location.reload(); + } catch (e) { + Notification.error(e.message); + } + }); + $(document).on('click', '[name="tfa_enabled"]', async () => { + const op = await new ActionDialog({ + $body: tpl` +
+ +
+ `, + onDispatch(action) { + if (action === 'ok' && $('[name="tfa_code"]').val() === null) { + $('[name="tfa_code"]').focus(); + return false; + } + return true; + }, + }).open(); + if (op !== 'ok') return; + try { + await api(gql` + user { + TFA { + disable(code: ${$('[name="tfa_code"]').val()}) + } + } + `); + Notification.success(i18n('Successfully disabled.')); + await delay(2000); + window.location.reload(); + } catch (e) { + Notification.error(e.message); + } + }); +}); diff --git a/packages/ui-default/pages/user_login.page.ts b/packages/ui-default/pages/user_login.page.ts new file mode 100644 index 000000000..54a814d19 --- /dev/null +++ b/packages/ui-default/pages/user_login.page.ts @@ -0,0 +1,17 @@ +import { AutoloadPage } from 'vj/misc/Page'; +import api, { gql } from 'vj/utils/api'; + +export default new AutoloadPage('user_login', (pagename) => { + (pagename === 'user_login' ? $(document) : $('.dialog--signin__main')).on('blur', '[name="uname"]', async () => { + const uname = $('[name="uname"]').val() as string; + if (uname.length > 0) { + const tfa = await api(gql` + user(uname:${uname}){ + tfa + } + `, ['data', 'user', 'tfa']); + if (tfa) $('#tfa_div').show(); + else $('#tfa_div').hide(); + } + }); +}); diff --git a/packages/ui-default/templates/home_security.html b/packages/ui-default/templates/home_security.html index 82415f4ca..04b180085 100644 --- a/packages/ui-default/templates/home_security.html +++ b/packages/ui-default/templates/home_security.html @@ -87,6 +87,23 @@ +
+
+
+
+

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

+
+ {% if handler.user.tfa %} + + {% else %} + + {% endif %} +
+
+
+
+
+
diff --git a/packages/ui-default/templates/partials/login_dialog.html b/packages/ui-default/templates/partials/login_dialog.html index ba89734d8..eb73c585d 100644 --- a/packages/ui-default/templates/partials/login_dialog.html +++ b/packages/ui-default/templates/partials/login_dialog.html @@ -27,6 +27,12 @@
+
+