mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-20 01:38:19 +08:00
core: 2fa support
This commit is contained in:
1 parent
f18547ba16
commit
fe1112e652
11 files changed
+200
-6
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hydrooj",
|
"name": "hydrooj",
|
||||||
"version": "3.1.28",
|
"version": "3.1.29",
|
||||||
"bin": "bin/hydrooj.js",
|
"bin": "bin/hydrooj.js",
|
||||||
"main": "src/loader",
|
"main": "src/loader",
|
||||||
"module": "src/loader",
|
"module": "src/loader",
|
||||||
@@ -38,12 +38,14 @@
|
|||||||
"mongodb": "^3.7.3",
|
"mongodb": "^3.7.3",
|
||||||
"nanoid": "^3.1.30",
|
"nanoid": "^3.1.30",
|
||||||
"nodemailer": "^6.6.5",
|
"nodemailer": "^6.6.5",
|
||||||
|
"notp": "^2.0.3",
|
||||||
"p-queue": "^7.1.0",
|
"p-queue": "^7.1.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"serialize-javascript": "^6.0.0",
|
"serialize-javascript": "^6.0.0",
|
||||||
"sockjs": "^0.3.21",
|
"sockjs": "^0.3.21",
|
||||||
"source-map-support": "^0.5.20",
|
"source-map-support": "^0.5.20",
|
||||||
"superagent": "^6.1.0",
|
"superagent": "^6.1.0",
|
||||||
|
"thirty-two": "^1.0.2",
|
||||||
"tx2": "^1.0.4"
|
"tx2": "^1.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -60,6 +62,7 @@
|
|||||||
"@types/minio": "^7.0.11",
|
"@types/minio": "^7.0.11",
|
||||||
"@types/mongodb": "^3.6.20",
|
"@types/mongodb": "^3.6.20",
|
||||||
"@types/nodemailer": "^6.4.4",
|
"@types/nodemailer": "^6.4.4",
|
||||||
|
"@types/notp": "^2",
|
||||||
"@types/serialize-javascript": "^5.0.1",
|
"@types/serialize-javascript": "^5.0.1",
|
||||||
"@types/sockjs": "^0.3.33",
|
"@types/sockjs": "^0.3.33",
|
||||||
"@types/source-map-support": "^0.5.4",
|
"@types/source-map-support": "^0.5.4",
|
||||||
|
|||||||
@@ -31,10 +31,14 @@ interface Context extends ApiHandler {
|
|||||||
[key: string]: any;
|
[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);
|
registerValue(typeName, key, value, description);
|
||||||
const wrappedFunc = async (arg, ctx) => {
|
const wrappedFunc = async (arg, ctx, info) => {
|
||||||
const res = await func(arg, ctx);
|
const res = await func(arg, ctx, info);
|
||||||
if (typeof res !== 'object') return res;
|
if (typeof res !== 'object') return res;
|
||||||
if (handlers[value]) Object.assign(res, handlers[value]);
|
if (handlers[value]) Object.assign(res, handlers[value]);
|
||||||
return res;
|
return res;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import moment from 'moment-timezone';
|
import moment from 'moment-timezone';
|
||||||
|
import notp from 'notp';
|
||||||
|
import b32 from 'thirty-two';
|
||||||
import {
|
import {
|
||||||
BlacklistedError, InvalidTokenError, LoginError,
|
BlacklistedError, InvalidTokenError, LoginError,
|
||||||
SystemError, UserAlreadyExistError, UserFacingError,
|
SystemError, UserAlreadyExistError, UserFacingError,
|
||||||
@@ -20,6 +22,12 @@ import {
|
|||||||
} from '../service/server';
|
} from '../service/server';
|
||||||
import { registerResolver, registerValue } from './api';
|
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', [
|
registerValue('User', [
|
||||||
['_id', 'Int!'],
|
['_id', 'Int!'],
|
||||||
['uname', 'String!'],
|
['uname', 'String!'],
|
||||||
@@ -31,8 +39,35 @@ registerValue('User', [
|
|||||||
['regat', 'Date!'],
|
['regat', 'Date!'],
|
||||||
['priv', 'Int!', 'User Privilege'],
|
['priv', 'Int!', 'User Privilege'],
|
||||||
['avatarUrl', 'String'],
|
['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) => {
|
registerResolver('Query', 'user(id: Int, uname: String, mail: String)', 'User', (arg, ctx) => {
|
||||||
if (arg.id) return user.getById(ctx.domainId, arg.id);
|
if (arg.id) return user.getById(ctx.domainId, arg.id);
|
||||||
if (arg.mail) return user.getByEmail(ctx.domainId, arg.mail);
|
if (arg.mail) return user.getByEmail(ctx.domainId, arg.mail);
|
||||||
@@ -75,11 +110,14 @@ class UserLoginHandler extends Handler {
|
|||||||
@param('password', Types.String)
|
@param('password', Types.String)
|
||||||
@param('rememberme', Types.Boolean)
|
@param('rememberme', Types.Boolean)
|
||||||
@param('redirect', Types.String, true)
|
@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.');
|
if (!system.get('server.login')) throw new LoginError('Builtin login disabled.');
|
||||||
let udoc = await user.getByEmail(domainId, uname);
|
let udoc = await user.getByEmail(domainId, uname);
|
||||||
if (!udoc) udoc = await user.getByUname(domainId, uname);
|
if (!udoc) udoc = await user.getByUname(domainId, uname);
|
||||||
if (!udoc) throw new UserNotFoundError(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);
|
udoc.checkPassword(password);
|
||||||
await user.setById(udoc._id, { loginat: new Date(), loginip: this.request.ip });
|
await user.setById(udoc._id, { loginat: new Date(), loginip: this.request.ip });
|
||||||
if (!udoc.hasPriv(PRIV.PRIV_USER_PROFILE)) throw new BlacklistedError(uname);
|
if (!udoc.hasPriv(PRIV.PRIV_USER_PROFILE)) throw new BlacklistedError(uname);
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export interface User extends Record<string, any> {
|
|||||||
_hash: string,
|
_hash: string,
|
||||||
_regip: string,
|
_regip: string,
|
||||||
_loginip: string,
|
_loginip: string,
|
||||||
|
_tfa: string,
|
||||||
|
|
||||||
mail: string,
|
mail: string,
|
||||||
uname: string,
|
uname: string,
|
||||||
@@ -108,6 +109,7 @@ export interface User extends Record<string, any> {
|
|||||||
perm: bigint,
|
perm: bigint,
|
||||||
scope: bigint,
|
scope: bigint,
|
||||||
role: string,
|
role: string,
|
||||||
|
tfa: boolean,
|
||||||
own<T extends ownerInfo>(doc: T, checkPerm: bigint): boolean
|
own<T extends ownerInfo>(doc: T, checkPerm: bigint): boolean
|
||||||
own<T extends ownerInfo>(doc: T, exact: boolean): boolean
|
own<T extends ownerInfo>(doc: T, exact: boolean): boolean
|
||||||
own<T extends ownerInfo>(doc: T): boolean
|
own<T extends ownerInfo>(doc: T): boolean
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class User implements _User {
|
|||||||
_hash: string;
|
_hash: string;
|
||||||
_regip: string;
|
_regip: string;
|
||||||
_loginip: string;
|
_loginip: string;
|
||||||
|
_tfa: string;
|
||||||
|
|
||||||
mail: string;
|
mail: string;
|
||||||
uname: string;
|
uname: string;
|
||||||
@@ -64,6 +65,7 @@ class User implements _User {
|
|||||||
role: string;
|
role: string;
|
||||||
scope: bigint;
|
scope: bigint;
|
||||||
_files: FileInfo[];
|
_files: FileInfo[];
|
||||||
|
tfa: boolean;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
|
|
||||||
constructor(udoc: Udoc, dudoc, scope = PERM.PERM_ALL) {
|
constructor(udoc: Udoc, dudoc, scope = PERM.PERM_ALL) {
|
||||||
@@ -76,6 +78,7 @@ class User implements _User {
|
|||||||
this._regip = udoc.ip?.[0] || '';
|
this._regip = udoc.ip?.[0] || '';
|
||||||
this._loginip = udoc.loginip;
|
this._loginip = udoc.loginip;
|
||||||
this._files = udoc._files || [];
|
this._files = udoc._files || [];
|
||||||
|
this._tfa = udoc.tfa;
|
||||||
|
|
||||||
this.mail = udoc.mail;
|
this.mail = udoc.mail;
|
||||||
this.uname = udoc.uname;
|
this.uname = udoc.uname;
|
||||||
@@ -86,6 +89,7 @@ class User implements _User {
|
|||||||
this.perm = dudoc.perm;
|
this.perm = dudoc.perm;
|
||||||
this.scope = typeof scope === 'string' ? BigInt(scope) : scope;
|
this.scope = typeof scope === 'string' ? BigInt(scope) : scope;
|
||||||
this.role = dudoc.role || 'default';
|
this.role = dudoc.role || 'default';
|
||||||
|
this.tfa = !!udoc.tfa;
|
||||||
|
|
||||||
for (const key in setting.SETTINGS_BY_KEY) {
|
for (const key in setting.SETTINGS_BY_KEY) {
|
||||||
this[key] = udoc[key] ?? setting.SETTINGS_BY_KEY[key].value;
|
this[key] = udoc[key] ?? setting.SETTINGS_BY_KEY[key].value;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@hydrooj/ui-default",
|
"name": "@hydrooj/ui-default",
|
||||||
"version": "4.26.15",
|
"version": "4.26.16",
|
||||||
"author": "undefined <i@undefined.moe>",
|
"author": "undefined <i@undefined.moe>",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"main": "hydro.js",
|
"main": "hydro.js",
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"@types/jquery": "^3.5.8",
|
"@types/jquery": "^3.5.8",
|
||||||
"@types/json-schema": "^7.0.9",
|
"@types/json-schema": "^7.0.9",
|
||||||
"@types/katex": "^0.11.1",
|
"@types/katex": "^0.11.1",
|
||||||
|
"@types/qrcode": "^1",
|
||||||
"@types/sockjs-client": "^1.5.1",
|
"@types/sockjs-client": "^1.5.1",
|
||||||
"@undefined-moe/monaco-yaml": "^2.5.0",
|
"@undefined-moe/monaco-yaml": "^2.5.0",
|
||||||
"ansi_up": "^5.0.1",
|
"ansi_up": "^5.0.1",
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
"prism-themes": "^1.9.0",
|
"prism-themes": "^1.9.0",
|
||||||
"prismjs": "^1.25.0",
|
"prismjs": "^1.25.0",
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
|
"qrcode": "^1.4.4",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-redux": "^7.2.6",
|
"react-redux": "^7.2.6",
|
||||||
@@ -82,6 +84,7 @@
|
|||||||
"stylus-loader": "^3.0.2",
|
"stylus-loader": "^3.0.2",
|
||||||
"tether": "^1.4.7",
|
"tether": "^1.4.7",
|
||||||
"tether-drop": "^1.4.2",
|
"tether-drop": "^1.4.2",
|
||||||
|
"thirty-two": "^1.0.2",
|
||||||
"through2": "^4.0.2",
|
"through2": "^4.0.2",
|
||||||
"timeago-react": "^3.0.4",
|
"timeago-react": "^3.0.4",
|
||||||
"timeago.js": "^4.0.2",
|
"timeago.js": "^4.0.2",
|
||||||
|
|||||||
@@ -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`
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
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`
|
||||||
|
<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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -87,6 +87,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row nojs--hide">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="section">
|
||||||
|
<div class="section__header">
|
||||||
|
<h1 class="section__title" id="tfa" data-heading>{{ _('Two Factor Authentication') }}</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 %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section__body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
|
|||||||
@@ -27,6 +27,12 @@
|
|||||||
<input name="password" type="password">
|
<input name="password" type="password">
|
||||||
</label>
|
</label>
|
||||||
</div></div>
|
</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">
|
<div class="row"><div class="columns">
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input type="checkbox" name="rememberme">{{ _('Remember me') }}
|
<input type="checkbox" name="rememberme">{{ _('Remember me') }}
|
||||||
|
|||||||
@@ -17,6 +17,12 @@
|
|||||||
<input name="password" type="password">
|
<input name="password" type="password">
|
||||||
</label>
|
</label>
|
||||||
</div></div>
|
</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">
|
<div class="row"><div class="columns">
|
||||||
<label class="inverse material checkbox">
|
<label class="inverse material checkbox">
|
||||||
<input type="checkbox" name="rememberme">{{ _('Remember me') }}
|
<input type="checkbox" name="rememberme">{{ _('Remember me') }}
|
||||||
|
|||||||
Reference in new issue
Block a user