From e15c05d36b940941e937ad490548cd27b8cb8ef0 Mon Sep 17 00:00:00 2001 From: undefined Date: Tue, 30 Jun 2020 22:21:07 +0800 Subject: [PATCH] Hydro.error; csrf_token validation --- README.md | 2 +- hydro/error.js | 309 ++++++++++------------------------------ hydro/handler/manage.js | 5 +- hydro/lib/nav.js | 4 +- hydro/model/builtin.js | 6 +- hydro/model/token.js | 17 ++- hydro/service/server.js | 31 ++-- hydro/utils.js | 24 ++++ templates/bsod.html | 3 +- 9 files changed, 134 insertions(+), 267 deletions(-) diff --git a/README.md b/README.md index 3e4edb88f..361b6d53f 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Hydro是一个高效的信息学在线测评系统。 特点: 易于部署,轻量,功能强大。 -[中文文档](wiki/deploy.md) +[中文文档](wiki/admin/deploy.md) Hydro 的界面基于 Vijos 二次开发。 diff --git a/hydro/error.js b/hydro/error.js index f4762f0af..a2cffc692 100644 --- a/hydro/error.js +++ b/hydro/error.js @@ -1,254 +1,92 @@ -class UserFacingError extends Error { - constructor(type) { - super(type); - this.code = 500; - // this.stack = ''; - this.params = []; - } -} +/* eslint-disable func-names */ +const { isClass } = require('./utils'); -class SystemError extends Error { - constructor(type) { - super(type); - this.code = 500; - // this.stack = ''; - this.params = []; - } -} - -class BadRequestError extends UserFacingError { - constructor(type) { - super(type); - this.code = 400; - } -} - -class ForbiddenError extends UserFacingError { - constructor(type) { - super(type); - this.code = 403; - } -} - -class NotFoundError extends UserFacingError { - constructor(type) { - super(type || 'NotFoundError'); - this.code = 404; - } -} - -class RemoteOnlineJudgeError extends UserFacingError { - constructor(message) { - super('RemoteOnlineJudgeError'); - this.params = [message]; - } -} - -class AlreadyVotedError extends BadRequestError { - constructor(psid, uid) { - super('You\'ve already voted.'); - this.params = [psid, uid]; - } -} - -class LoginError extends ForbiddenError { - constructor(uname) { - super('LoginError'); - this.params = [uname]; - } -} - -class UserAlreadyExistError extends ForbiddenError { - constructor(uname) { - super('UserAlreadyExistError'); - this.params = [uname]; - } -} - -class InvalidTokenError extends ForbiddenError { - constructor(token) { - super('InvalidTokenError'); - this.params = [token]; - } -} - -class BlacklistedError extends ForbiddenError { - constructor(ip) { - super('Address {0} is blacklisted.'); - this.params = [ip]; - } -} - -class UserNotFoundError extends NotFoundError { - constructor(user) { - super('UserNotFoundError'); - this.params = [user]; - } -} - -class NoProblemError extends NotFoundError { - constructor() { - super('NoProblemError'); - } -} - -class VerifyPasswordError extends ForbiddenError { - constructor() { - super('VerifyPasswordError'); - } -} - -class OpcountExceededError extends ForbiddenError { - constructor(op, periodSecs, maxOperations) { - super('OpcountExceededError'); - this.params = [op, periodSecs, maxOperations]; - } -} - -class PermissionError extends ForbiddenError { - constructor(perm) { - super('PermissionError'); - this.params = [perm]; - } -} - -class PrivilegeError extends ForbiddenError { - constructor(priv) { - if (priv === global.Hydro.model.builtin.PRIV.PRIV_USER_PROFILE) { - super("You're not logged in."); - } else { - super("You don't have the required privilege."); +const Err = (name, ...classMessageCode) => { + let Class; + let msg; + let code; + for (const item of classMessageCode) { + if (typeof item === 'number') { + code = item; + } else if (typeof item === 'string') { + msg = function () { return item; }; + } else if (isClass(item)) { + Class = item; + } else if (typeof item === 'function') { + msg = item; } - this.params = [priv]; + } + const HydroError = class extends Class { }; + HydroError.prototype.name = name; + if (msg) HydroError.prototype.msg = msg; + if (code) HydroError.prototype.code = code; + return HydroError; +}; + +class HydroError extends Error { + constructor(...params) { + super(); + this.params = params; } } -class ValidationError extends ForbiddenError { - constructor(field0, field1) { - super('ValidationError'); - if (!field1) this.params = [field0]; - else this.params = [field0, field1]; - } -} +const UserFacingError = Err('UserFacingError', HydroError, 'UserFacingError', 400); +const SystemError = Err('SystemError', HydroError, 'SystemError', 500); -class ContestNotAttendedError extends ForbiddenError { - constructor(tid) { - super('You haven\'t attended this contest yet.'); - this.params = [tid]; - } -} +const BadRequestError = Err('BadRequestError', UserFacingError, 400); +const ForbiddenError = Err('ForbiddenError', UserFacingError, 403); +const NotFoundError = Err('NotFoundError', UserFacingError, 404); +const RemoteOnlineJudgeError = Err('RemoteOnlineJudgeError', UserFacingError, 500); -class ContestAlreadyAttendedError extends ForbiddenError { - constructor(tid, uid) { - super('You\'ve already attended this contest.'); - this.params = [tid, uid]; +const AlreadyVotedError = Err('AlreadyVotedError', ForbiddenError, "You've already voted."); +const LoginError = Err('LoginError', ForbiddenError, 'Invalid password for user {0}.'); +const UserAlreadyExistError = Err('UserAlreadyExistError', ForbiddenError, 'User {0} already exists.'); +const InvalidTokenError = Err('InvalidTokenError', ForbiddenError); +const BlacklistedError = Err('BlacklistedError', ForbiddenError, 'Address or user {0} is blacklisted.'); +const VerifyPasswordError = Err('VerifyPasswordError', ForbiddenError, "Passwords don't match."); +const OpcountExceededError = Err('OpcountExceededError', ForbiddenError, 'Too frequent operations of {0} (limit: {2} operations in {1} seconds).'); +const PermissionError = Err('PermissionError', ForbiddenError, "You don't have the required permission ({0}) in this domain."); +const PrivilegeError = Err('PrivilegeError', ForbiddenError, function () { + if (this.params.includes(global.Hydro.model.builtin.PRIV.PRIV_USER_PROFILE)) { + return "You're not logged in."; } -} + return "You don't have the required privilege."; +}); +const ValidationError = Err('ValidationError', ForbiddenError, function () { + if (this.params.length === 1) return 'Field {0} validation failed.'; + return 'Field {0} or {1} validation failed.'; +}); +const ContestNotAttendedError = Err('ContestNotAttendedError', ForbiddenError, "You haven't attended this contest yet."); +const ContestAlreadyAttendedError = Err('ContestAlreadyAttendedError', ForbiddenError, "You've already attended this contest."); +const ContestNotLiveError = Err('ContestNotLiveError', ForbiddenError, 'This contest is not live.'); +const ContestScoreboardHiddenError = Err('ContestScoreboardHiddenError', ForbiddenError, 'Contest scoreboard is not visible.'); +const TrainingAlreadyEnrollError = Err('TrainingAlreadyEnrollError', ForbiddenError, "You've already enrolled this training."); +const RoleAlreadyExistError = Err('RoleAlreadyExistError', ForbiddenError, 'This role already exists.'); +const CsrfTokenError = Err('CsrfTokenError', ForbiddenError); -class ContestNotLiveError extends ForbiddenError { - constructor(tid) { - super('This contest is not live.'); - this.params = [tid]; - } -} +const UserNotFoundError = Err('UserNotFoundError', NotFoundError, 'User {0} not found.'); +const NoProblemError = Err('NoProblemError', NotFoundError, 'No problem.'); +const RecordNotFoundError = Err('RecordNotFoundError', NotFoundError, 'Record {0} not found.'); +const ProblemDataNotFoundError = Err('ProblemDataNotFoundError', NotFoundError, 'Data of problem {1} not found.'); +const MessageNotFoundError = Err('MessageNotFoundError', NotFoundError, 'Message {0} not found.'); +const DocumentNotFoundError = Err('DocumentNotFoundError', NotFoundError, 'Document {2} not found.'); -class ContestScoreboardHiddenError extends ForbiddenError { - constructor(tid) { - super('Contest scoreboard is not visible.'); - this.params = [tid]; - } -} - -class TrainingAlreadyEnrollError extends ForbiddenError { - constructor(tid, uid) { - super("You've already enrolled this training."); - this.params = [tid, uid]; - } -} - -class RoleAlreadyExistError extends ForbiddenError { - constructor(role) { - super('This role already exists.'); - this.params = [role]; - } -} - -class ProblemNotFoundError extends NotFoundError { - constructor(domainId, pid) { - super('ProblemNotFoundError'); - this.params = [domainId, pid]; - } -} - -class RecordNotFoundError extends NotFoundError { - constructor(rid) { - super('RecordNotFoundError'); - this.params = [rid]; - } -} - -class SolutionNotFoundError extends NotFoundError { - constructor(psid) { - super('SolutionNotFoundError'); - this.params = [psid]; - } -} - -class TrainingNotFoundError extends NotFoundError { - constructor(tid) { - super('TrainingNotFoundError'); - this.params = [tid]; - } -} - -class ContestNotFoundError extends NotFoundError { - constructor(cid) { - super('ContestNotFoundError'); - this.params = [cid]; - } -} - -class ProblemDataNotFoundError extends NotFoundError { - constructor(pid) { - super('Data of problem {0} not found.'); - this.params = [pid]; - } -} - -class DiscussionNodeNotFoundError extends NotFoundError { - constructor(type, docId) { - super('Discussion node {0}/{1} not found.'); - this.params = [type, docId]; - } -} - -class DocumentNotFoundError extends NotFoundError { - constructor(docId) { - super('Document {0} not found.'); - this.params = [docId]; - } -} - -class DiscussionNotFoundError extends NotFoundError { - constructor(did) { - super('Discussion {0} not found.'); - this.params = [did]; - } -} - -class MessageNotFoundError extends NotFoundError { - constructor(mid) { - super('Message {0} not found.'); - this.params = [mid]; - } -} +const ProblemNotFoundError = Err('ProblemNotFountError', DocumentNotFoundError, 'Problem {1} not found.'); +const SolutionNotFoundError = Err('SolutionNotFoundError', DocumentNotFoundError); +const TrainingNotFoundError = Err('TrainingNotFoundError', DocumentNotFoundError); +const ContestNotFoundError = Err('ContestNotFoundError', DocumentNotFoundError); +const DiscussionNotFoundError = Err('DiscussionNotFoundError', DocumentNotFoundError, 'Discussion {0} not found.'); +const DiscussionNodeNotFoundError = Err('DiscussionNodeNotFoundError', DocumentNotFoundError, 'Discussion node {1} not found.'); global.Hydro.error = module.exports = { + Err, + HydroError, BadRequestError, BlacklistedError, ForbiddenError, NotFoundError, LoginError, + CsrfTokenError, UserAlreadyExistError, InvalidTokenError, UserNotFoundError, @@ -291,9 +129,6 @@ class FileTypeNotAllowedError(ValidationError): def message(self): return 'This type of files are not allowed to be uploaded.' -class CsrfTokenError(ForbiddenError): - pass - class InvalidOperationError(ForbiddenError): pass diff --git a/hydro/handler/manage.js b/hydro/handler/manage.js index 8811cad2c..6178a34bd 100644 --- a/hydro/handler/manage.js +++ b/hydro/handler/manage.js @@ -1,16 +1,15 @@ const setting = require('../model/setting'); const system = require('../model/system'); -const { STATUS } = require('../model/builtin'); +const { STATUS, PRIV } = require('../model/builtin'); const record = require('../model/record'); const judge = require('./judge'); const { Route, Handler } = require('../service/server'); const hpm = require('../lib/hpm'); const loader = require('../loader'); -const { PermissionError } = require('../error'); class SystemHandler extends Handler { async prepare() { - if (!this.user.priv) throw new PermissionError('???'); + this.checkPriv(PRIV.PRIV_EDIT_SYSTEM); this.response.body = { path: [ ['Hydro', 'homepage'], diff --git a/hydro/lib/nav.js b/hydro/lib/nav.js index 339f24512..b20a34d73 100644 --- a/hydro/lib/nav.js +++ b/hydro/lib/nav.js @@ -42,8 +42,8 @@ Item('homework_main', null, 'homework', PERM.PERM_VIEW_HOMEWORK); Item('discussion_main', null, 'discussion', PERM.PERM_VIEW_DISCUSSION); Item('contest_main', null, 'contest', PERM.PERM_VIEW_CONTEST); Item('record_main', null, 'record'); -Item('ranking', null, 'ranking'); +Item('ranking', null, 'ranking', PERM.PERM_VIEW_RANKING); Item('domain_dashboard', null, 'domain', PERM.PERM_EDIT_DOMAIN); -Item('manage_dashboard', null, 'manage', PRIV.PRIV_SET_PRIV); +Item('manage_dashboard', null, 'manage', PRIV.PRIV_EDIT_SYSTEM); global.Hydro.lib.nav = module.exports = Item; diff --git a/hydro/model/builtin.js b/hydro/model/builtin.js index 8a4c2dca0..ba9a8f1ae 100644 --- a/hydro/model/builtin.js +++ b/hydro/model/builtin.js @@ -160,7 +160,8 @@ PERM.PERM_BASIC = PERM.PERM_VIEW + PERM.PERM_VIEW_CONTEST_SCOREBOARD + PERM.PERM_VIEW_HOMEWORK + PERM.PERM_VIEW_HOMEWORK_SCOREBOARD - + PERM.PERM_VIEW_TRAINING; + + PERM.PERM_VIEW_TRAINING + + PERM.PERM_VIEW_RANKING; PERM.PERM_DEFAULT = PERM.PERM_VIEW + PERM.PERM_VIEW_PROBLEM @@ -203,7 +204,8 @@ PERM.PERM_DEFAULT = PERM.PERM_VIEW + PERM.PERM_REPLY_DISCUSSION + PERM.PERM_ATTEND_CONTEST + PERM.PERM_CREATE_TRAINING - + PERM.PERM_ATTEND_HOMEWORK; + + PERM.PERM_ATTEND_HOMEWORK + + PERM.PERM_VIEW_RANKING; PERM.PERM_ADMIN = PERM.PERM_ALL; diff --git a/hydro/model/token.js b/hydro/model/token.js index e26de3027..2d79d1495 100644 --- a/hydro/model/token.js +++ b/hydro/model/token.js @@ -53,14 +53,17 @@ async function get(tokenId, tokenType) { */ async function update(tokenId, tokenType, expireSeconds, data) { const now = new Date(); - const res = await coll.findOneAndUpdate({ _id: tokenId, tokenType }, { - $set: { - ...data, - updateAt: now, - expireAt: new Date(now.getTime() + expireSeconds * 1000), - tokenType, + const res = await coll.updateOne( + { _id: tokenId, tokenType }, + { + $set: { + ...data, + updateAt: now, + expireAt: new Date(now.getTime() + expireSeconds * 1000), + tokenType, + }, }, - }); + ); return res.value; } diff --git a/hydro/service/server.js b/hydro/service/server.js index a1fef27a8..24bf37be6 100644 --- a/hydro/service/server.js +++ b/hydro/service/server.js @@ -22,6 +22,7 @@ const opcount = require('../model/opcount'); const { UserNotFoundError, BlacklistedError, PermissionError, UserFacingError, ValidationError, PrivilegeError, + CsrfTokenError, } = require('../error'); let enableLog = true; @@ -282,6 +283,11 @@ class Handler { this.preferJson = (this.request.headers.accept || '').includes('application/json'); } + async checkCsrfToken(csrfToken) { + const sdoc = await token.get(csrfToken, token.TYPE_CSRF_TOKEN); + if (!sdoc || sdoc.uid !== this.user._id) throw new CsrfTokenError(csrfToken); + } + async ___cleanup() { try { await this.renderBody(); @@ -361,12 +367,12 @@ class Handler { } async onerror(error) { - console.error(error.message, error.params); + console.error(error.msg(), error.params); console.error(error.stack); this.response.status = error instanceof UserFacingError ? error.code : 500; this.response.template = error instanceof UserFacingError ? 'error.html' : 'bsod.html'; this.response.body = { - error: { message: error.message, params: error.params, stack: error.stack }, + error: { message: error.msg(), params: error.params, stack: error.stack }, }; await this.___cleanup().catch(() => { }); } @@ -384,6 +390,7 @@ async function handle(ctx, HandlerClass, checker) { if (h.___prepare) await h.___prepare(args); if (checker) checker.call(h); + if (method === 'post') await h.checkCsrfToken(args.csrfToken); let checking = ''; try { @@ -421,10 +428,16 @@ async function handle(ctx, HandlerClass, checker) { } } +const Checker = (perm, priv, checker) => function __() { + checker(); + if (perm) this.checkPerm(perm); + if (priv) this.checkPriv(priv); +}; + function Route(name, route, RouteHandler, ...permPrivChecker) { let _priv; let _perm; - let checker; + let checker = () => { }; for (const item of permPrivChecker) { if (typeof item === 'object') { if (typeof item.call !== 'undefined') { @@ -440,17 +453,7 @@ function Route(name, route, RouteHandler, ...permPrivChecker) { _perm = item; } } - if ((_perm || _priv) && checker) { - checker = ((_chk) => function __() { - _chk(); - if (_perm) this.checkPerm(_perm); - if (_priv) this.checkPriv(_priv); - })(checker); - } else if (_perm) { - checker = function __() { - this.checkPerm(_perm); - }; - } + checker = Checker(_perm, _priv, checker); router.all(name, route, (ctx) => handle(ctx, RouteHandler, checker)); router.all(`${name}_with_domainId`, `/d/:domainId${route}`, (ctx) => handle(ctx, RouteHandler, checker)); } diff --git a/hydro/utils.js b/hydro/utils.js index 2d0aac066..ccaad2614 100644 --- a/hydro/utils.js +++ b/hydro/utils.js @@ -130,3 +130,27 @@ function parseMemoryMB(str) { } exports.parseMemoryMB = parseMemoryMB; + +/** + * Checks if an object could be an instantiable class. + * @param {any} obj + * @param {boolean} strict + * @returns {boolean} + */ +function isClass(obj, strict = false) { + if (typeof obj !== 'function') return false; + const str = obj.toString(); + if (obj.prototype === undefined) return false; + if (obj.prototype.constructor !== obj) return false; + if (str.slice(0, 5) === 'class') return true; + if (Object.getOwnPropertyNames(obj.prototype).length >= 2) return true; + if (/^function\s+\(|^function\s+anonymous\(/.test(str)) return false; + if (strict && /^function\s+[A-Z]/.test(str)) return true; + if (/\b\(this\b|\bthis[.[]\b/.test(str)) { + if (!strict || /classCallCheck\(this/.test(str)) return true; + return /^function\sdefault_\d+\s*\(/.test(str); + } + return false; +} + +exports.isClass = isClass; diff --git a/templates/bsod.html b/templates/bsod.html index 3d4654118..fe2c3bd5a 100644 --- a/templates/bsod.html +++ b/templates/bsod.html @@ -25,7 +25,8 @@ return 0; } /* -{{error.stack}} +{{ error.message }} +{{ error.stack }} */