From 12773d539ef5df4d746af9886da53bac1ec19aec Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Wed, 6 Aug 2025 22:08:07 +0200 Subject: [PATCH] Add interface to block ip-addresses or clients due to abuse (#15484) * Read IP blocks from database * begin building general blocking solution * add new frontend files * Add UI for managing blockers * correctly reset local data after creating blocker * Tweak wording * Add UI for managing blockers * restructure admin pages * improve test coverage * Improve blocker UI * add blocker to block emails from registration * lint fix * fix * lint fixes * fix import * add new permission for managing blockers * improve permission check * fix managing permissions from admin * improve navbar display for non fullAccess admin * update block error strings * lint fix * add option to errorHandler to skip logging * validate blocker value during input * improve blocker form display * chore(subproj): reconcile habitica-images * fix(scripts): use same Mongo version for dev/test * fix(whitespace): eof * documentation improvements * remove nconf import * remove old test --------- Co-authored-by: Kalista Payne Co-authored-by: Kalista Payne --- package-lock.json | 72 ------ package.json | 4 +- test/api/unit/middlewares/blocker.test.js | 197 +++++++++++++++ test/api/unit/middlewares/ipBlocker.test.js | 76 ------ test/api/unit/models/user.test.js | 73 ++++++ .../admin-panel/filters/formatDate.js | 0 .../{ => admin}/admin-panel/index.vue | 4 +- .../admin-panel/mixins/getItemDescription.js | 0 .../admin-panel/mixins/saveHero.js | 0 .../{ => admin}/admin-panel/search.vue | 2 +- .../admin-panel/user-support/achievements.vue | 0 .../user-support/avatarAndDrops.vue | 0 .../admin-panel/user-support/basicDetails.vue | 0 .../user-support/contributorDetails.vue | 21 +- .../admin-panel/user-support/cronAndAuth.vue | 2 +- .../user-support/customizationsOwned.vue | 2 +- .../admin-panel/user-support/index.vue | 10 +- .../admin-panel/user-support/itemsOwned.vue | 0 .../user-support/partyAndQuest.vue | 69 ++--- .../user-support/privilegesAndGems.vue | 30 ++- .../admin-panel/user-support/stats-row.vue | 0 .../admin-panel/user-support/stats.vue | 97 ++++--- .../user-support/subscriptionAndPerks.vue | 227 +++++++++++------ .../admin-panel/user-support/transactions.vue | 4 +- .../admin-panel/user-support/userHistory.vue | 2 +- .../admin-panel/user-support/userProfile.vue | 16 +- .../components/admin/blocker/blocker_form.vue | 133 ++++++++++ .../src/components/admin/blocker/index.vue | 238 ++++++++++++++++++ .../client/src/components/admin/container.vue | 40 +++ website/client/src/components/appFooter.vue | 4 +- .../components/groups/questSidebarSection.vue | 3 +- website/client/src/components/hall/heroes.vue | 2 +- website/client/src/components/header/menu.vue | 72 +++++- .../header/notifications/itemReceived.vue | 3 +- .../header/notifications/newMysteryItems.vue | 3 +- .../src/components/news/newsContent.vue | 5 +- .../client/src/components/static/features.vue | 48 +++- .../pages/settings/components/yourBalance.vue | 2 +- website/client/src/router/index.js | 63 +++-- website/client/src/store/actions/blockers.js | 19 ++ website/client/src/store/actions/index.js | 2 + website/common/locales/en/admin.json | 7 + website/common/locales/en/front.json | 1 + .../common/script/errors/apiErrorMessages.js | 3 +- website/server/controllers/api-v4/admin.js | 72 ++++++ website/server/middlewares/blocker.js | 56 +++++ website/server/middlewares/errorHandler.js | 24 +- website/server/middlewares/index.js | 4 +- website/server/middlewares/ipBlocker.js | 38 --- website/server/models/blocker.js | 103 ++++++++ website/server/models/user/schema.js | 29 +++ 51 files changed, 1454 insertions(+), 428 deletions(-) create mode 100644 test/api/unit/middlewares/blocker.test.js delete mode 100644 test/api/unit/middlewares/ipBlocker.test.js rename website/client/src/components/{ => admin}/admin-panel/filters/formatDate.js (100%) rename website/client/src/components/{ => admin}/admin-panel/index.vue (97%) rename website/client/src/components/{ => admin}/admin-panel/mixins/getItemDescription.js (100%) rename website/client/src/components/{ => admin}/admin-panel/mixins/saveHero.js (100%) rename website/client/src/components/{ => admin}/admin-panel/search.vue (98%) rename website/client/src/components/{ => admin}/admin-panel/user-support/achievements.vue (100%) rename website/client/src/components/{ => admin}/admin-panel/user-support/avatarAndDrops.vue (100%) rename website/client/src/components/{ => admin}/admin-panel/user-support/basicDetails.vue (100%) rename website/client/src/components/{ => admin}/admin-panel/user-support/contributorDetails.vue (90%) rename website/client/src/components/{ => admin}/admin-panel/user-support/cronAndAuth.vue (99%) rename website/client/src/components/{ => admin}/admin-panel/user-support/customizationsOwned.vue (98%) rename website/client/src/components/{ => admin}/admin-panel/user-support/index.vue (95%) rename website/client/src/components/{ => admin}/admin-panel/user-support/itemsOwned.vue (100%) rename website/client/src/components/{ => admin}/admin-panel/user-support/partyAndQuest.vue (91%) rename website/client/src/components/{ => admin}/admin-panel/user-support/privilegesAndGems.vue (91%) rename website/client/src/components/{ => admin}/admin-panel/user-support/stats-row.vue (100%) rename website/client/src/components/{ => admin}/admin-panel/user-support/stats.vue (76%) rename website/client/src/components/{ => admin}/admin-panel/user-support/subscriptionAndPerks.vue (76%) rename website/client/src/components/{ => admin}/admin-panel/user-support/transactions.vue (91%) rename website/client/src/components/{ => admin}/admin-panel/user-support/userHistory.vue (99%) rename website/client/src/components/{ => admin}/admin-panel/user-support/userProfile.vue (89%) create mode 100644 website/client/src/components/admin/blocker/blocker_form.vue create mode 100644 website/client/src/components/admin/blocker/index.vue create mode 100644 website/client/src/components/admin/container.vue create mode 100644 website/client/src/store/actions/blockers.js create mode 100644 website/common/locales/en/admin.json create mode 100644 website/server/middlewares/blocker.js delete mode 100644 website/server/middlewares/ipBlocker.js create mode 100644 website/server/models/blocker.js diff --git a/package-lock.json b/package-lock.json index 9b8ab54224..68402c0071 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,6 @@ "morgan": "^1.10.0", "nconf": "^0.12.1", "node-gcm": "^1.0.5", - "nodemon": "^3.1.9", "on-headers": "^1.0.2", "passport": "^0.5.3", "passport-facebook": "^3.0.0", @@ -16011,55 +16010,6 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" }, - "node_modules/nodemon": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", - "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", - "dependencies": { - "chokidar": "^3.5.2", - "debug": "^4", - "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", - "pstree.remy": "^1.1.8", - "semver": "^7.5.3", - "simple-update-notifier": "^2.0.0", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.5" - }, - "bin": { - "nodemon": "bin/nodemon.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nodemon" - } - }, - "node_modules/nodemon/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/nodemon/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/noop-logger": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz", @@ -19423,28 +19373,6 @@ "is-arrayish": "^0.3.1" } }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/sinon": { "version": "15.2.0", "resolved": "https://registry.npmjs.org/sinon/-/sinon-15.2.0.tgz", diff --git a/package.json b/package.json index 51f9eb1719..5e3482d381 100644 --- a/package.json +++ b/package.json @@ -106,8 +106,8 @@ "start": "node --watch ./website/server/index.js", "start:simple": "node ./website/server/index.js", "debug": "node --watch --inspect ./website/server/index.js", - "mongo:dev": "run-rs -v 5.0.23 -l ubuntu1804 --keep --dbpath mongodb-data --number 1 --quiet", - "mongo:test": "run-rs -v 5.0.23 -l ubuntu1804 --keep --dbpath mongodb-data-testing --number 1 --quiet", + "mongo:dev": "run-rs -v 7.0.23 -l ubuntu2404 --keep --dbpath mongodb-data --number 1 --quiet", + "mongo:test": "run-rs -v 7.0.23 -l ubuntu2404 --keep --dbpath mongodb-data-testing --number 1 --quiet", "postinstall": "git config --global url.\"https://\".insteadOf git:// && gulp build && cd website/client && npm install", "apidoc": "gulp apidoc", "heroku-postbuild": ".heroku/report_deploy.sh" diff --git a/test/api/unit/middlewares/blocker.test.js b/test/api/unit/middlewares/blocker.test.js new file mode 100644 index 0000000000..ad4050fa4d --- /dev/null +++ b/test/api/unit/middlewares/blocker.test.js @@ -0,0 +1,197 @@ +import nconf from 'nconf'; +import requireAgain from 'require-again'; +import { + generateRes, + generateReq, + generateNext, +} from '../../../helpers/api-unit.helper'; +import { Forbidden } from '../../../../website/server/libs/errors'; +import { apiError } from '../../../../website/server/libs/apiError'; +import { model as Blocker } from '../../../../website/server/models/blocker'; + +function checkIPBlockedErrorThrown (next) { + expect(next).to.have.been.calledOnce; + const calledWith = next.getCall(0).args; + expect(calledWith[0].message).to.equal(apiError('ipAddressBlocked')); + expect(calledWith[0] instanceof Forbidden).to.equal(true); +} + +function checkClientBlockedErrorThrown (next) { + expect(next).to.have.been.calledOnce; + const calledWith = next.getCall(0).args; + expect(calledWith[0].message).to.equal(apiError('clientBlocked')); + expect(calledWith[0] instanceof Forbidden).to.equal(true); +} + +function checkErrorNotThrown (next) { + expect(next).to.have.been.calledOnce; + const calledWith = next.getCall(0).args; + expect(typeof calledWith[0] === 'undefined').to.equal(true); +} + +describe('Blocker middleware', () => { + const pathToBlocker = '../../../../website/server/middlewares/blocker'; + + let res; let req; let next; + + beforeEach(() => { + res = generateRes(); + req = generateReq(); + next = generateNext(); + }); + + describe('Blocking IPs', () => { + it('is disabled when the env var is not defined', () => { + sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns(undefined); + const attachBlocker = requireAgain(pathToBlocker).default; + attachBlocker(req, res, next); + + checkErrorNotThrown(next); + }); + + it('is disabled when the env var is an empty string', () => { + sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns(''); + const attachBlocker = requireAgain(pathToBlocker).default; + attachBlocker(req, res, next); + + checkErrorNotThrown(next); + }); + + it('is disabled when the env var contains comma separated empty strings', () => { + sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns(' , , '); + const attachBlocker = requireAgain(pathToBlocker).default; + attachBlocker(req, res, next); + + checkErrorNotThrown(next); + }); + + it('does not throw when the ip does not match', () => { + req.ip = '192.168.1.1'; + sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns('192.168.1.2'); + const attachBlocker = requireAgain(pathToBlocker).default; + attachBlocker(req, res, next); + + checkErrorNotThrown(next); + }); + + it('does not throw when the blocker IP does not match', async () => { + req.ip = '192.168.1.1'; + sandbox.stub(Blocker, 'watchBlockers').returns({ + on: (event, callback) => { + if (event === 'change') { + callback({ operation: 'add', blocker: { type: 'ipaddress', area: 'full', value: '192.168.1.2' } }); + } + }, + }); + const attachBlocker = requireAgain(pathToBlocker).default; + attachBlocker(req, res, next); + + checkErrorNotThrown(next); + }); + + it('does not throw when a client is blocked', async () => { + sandbox.stub(Blocker, 'watchBlockers').returns({ + on: (event, callback) => { + if (event === 'change') { + callback({ operation: 'add', blocker: { type: 'client', area: 'full', value: '192.168.1.1' } }); + } + }, + }); + const attachBlocker = requireAgain(pathToBlocker).default; + attachBlocker(req, res, next); + + checkErrorNotThrown(next); + }); + + it('throws when the blocker IP is blocked', async () => { + req.ip = '192.168.1.1'; + sandbox.stub(Blocker, 'watchBlockers').returns({ + on: (event, callback) => { + if (event === 'change') { + callback({ operation: 'add', blocker: { type: 'ipaddress', area: 'full', value: '192.168.1.1' } }); + } + }, + }); + const attachBlocker = requireAgain(pathToBlocker).default; + attachBlocker(req, res, next); + + checkIPBlockedErrorThrown(next); + }); + }); + + describe('Blocking clients', () => { + beforeEach(() => { + sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns(''); + req.headers['x-client'] = 'test-client'; + }); + it('is disabled when no clients are blocked', () => { + const attachBlocker = requireAgain(pathToBlocker).default; + attachBlocker(req, res, next); + + checkErrorNotThrown(next); + }); + + it('does not throw when the client does not match', async () => { + sandbox.stub(Blocker, 'watchBlockers').returns({ + on: (event, callback) => { + if (event === 'change') { + callback({ operation: 'add', blocker: { type: 'client', area: 'full', value: 'another-client' } }); + } + }, + }); + const attachBlocker = requireAgain(pathToBlocker).default; + attachBlocker(req, res, next); + + checkErrorNotThrown(next); + }); + + it('throws when the client is blocked', async () => { + sandbox.stub(Blocker, 'watchBlockers').returns({ + on: (event, callback) => { + if (event === 'change') { + callback({ operation: 'add', blocker: { type: 'client', area: 'full', value: 'test-client' } }); + } + }, + }); + const attachBlocker = requireAgain(pathToBlocker).default; + attachBlocker(req, res, next); + + checkClientBlockedErrorThrown(next); + }); + + it('does not throw when an ip is blocked', async () => { + sandbox.stub(Blocker, 'watchBlockers').returns({ + on: (event, callback) => { + if (event === 'change') { + callback({ operation: 'add', blocker: { type: 'ipaddress', area: 'full', value: 'test-client' } }); + } + }, + }); + const attachBlocker = requireAgain(pathToBlocker).default; + attachBlocker(req, res, next); + + checkErrorNotThrown(next); + }); + + it('updates the list when data changes', async () => { + let blockCallback; + sandbox.stub(Blocker, 'watchBlockers').returns({ + on: (event, callback) => { + blockCallback = callback; + if (event === 'change') { + callback({ operation: 'add', blocker: { type: 'client', area: 'full', value: 'another-client' } }); + } + }, + }); + const attachBlocker = requireAgain(pathToBlocker).default; + attachBlocker(req, res, next); + checkErrorNotThrown(next); + blockCallback({ operation: 'add', blocker: { type: 'client', area: 'full', value: 'test-client' } }); + attachBlocker(req, res, next); + expect(next).to.have.been.calledTwice; + const calledWith = next.getCall(1).args; + expect(calledWith[0].message).to.equal(apiError('clientBlocked')); + expect(calledWith[0] instanceof Forbidden).to.equal(true); + }); + }); +}); diff --git a/test/api/unit/middlewares/ipBlocker.test.js b/test/api/unit/middlewares/ipBlocker.test.js deleted file mode 100644 index e75994bfb9..0000000000 --- a/test/api/unit/middlewares/ipBlocker.test.js +++ /dev/null @@ -1,76 +0,0 @@ -import nconf from 'nconf'; -import requireAgain from 'require-again'; -import { - generateRes, - generateReq, - generateNext, -} from '../../../helpers/api-unit.helper'; -import { Forbidden } from '../../../../website/server/libs/errors'; -import { apiError } from '../../../../website/server/libs/apiError'; - -function checkErrorThrown (next) { - expect(next).to.have.been.calledOnce; - const calledWith = next.getCall(0).args; - expect(calledWith[0].message).to.equal(apiError('ipAddressBlocked')); - expect(calledWith[0] instanceof Forbidden).to.equal(true); -} - -function checkErrorNotThrown (next) { - expect(next).to.have.been.calledOnce; - const calledWith = next.getCall(0).args; - expect(typeof calledWith[0] === 'undefined').to.equal(true); -} - -describe('ipBlocker middleware', () => { - const pathToIpBlocker = '../../../../website/server/middlewares/ipBlocker'; - - let res; let req; let next; - - beforeEach(() => { - res = generateRes(); - req = generateReq(); - next = generateNext(); - }); - - it('is disabled when the env var is not defined', () => { - sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns(undefined); - const attachIpBlocker = requireAgain(pathToIpBlocker).default; - attachIpBlocker(req, res, next); - - checkErrorNotThrown(next); - }); - - it('is disabled when the env var is an empty string', () => { - sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns(''); - const attachIpBlocker = requireAgain(pathToIpBlocker).default; - attachIpBlocker(req, res, next); - - checkErrorNotThrown(next); - }); - - it('is disabled when the env var contains comma separated empty strings', () => { - sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns(' , , '); - const attachIpBlocker = requireAgain(pathToIpBlocker).default; - attachIpBlocker(req, res, next); - - checkErrorNotThrown(next); - }); - - it('does not throw when the ip does not match', () => { - req.ip = '192.168.1.1'; - sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns('192.168.1.2'); - const attachIpBlocker = requireAgain(pathToIpBlocker).default; - attachIpBlocker(req, res, next); - - checkErrorNotThrown(next); - }); - - it('throws when the ip is blocked', () => { - req.ip = '192.168.1.1'; - sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns('192.168.1.1'); - const attachIpBlocker = requireAgain(pathToIpBlocker).default; - attachIpBlocker(req, res, next); - - checkErrorThrown(next); - }); -}); diff --git a/test/api/unit/models/user.test.js b/test/api/unit/models/user.test.js index c5b6054c73..8db5a1aa83 100644 --- a/test/api/unit/models/user.test.js +++ b/test/api/unit/models/user.test.js @@ -1,9 +1,13 @@ import moment from 'moment'; +import requireAgain from 'require-again'; import { model as User } from '../../../../website/server/models/user'; import { model as NewsPost } from '../../../../website/server/models/newsPost'; import { model as Group } from '../../../../website/server/models/group'; +import { model as Blocker } from '../../../../website/server/models/blocker'; import common from '../../../../website/common'; +const pathToUserSchema = '../../../../website/server/models/user/schema'; + describe('User Model', () => { describe('.toJSON()', () => { it('keeps user._tmp when calling .toJSON', () => { @@ -912,4 +916,73 @@ describe('User Model', () => { expect(user.toJSON().flags.newStuff).to.equal(true); }); }); + + describe('validates email', () => { + it('does not throw an error for a valid email', () => { + const user = new User(); + user.auth.local.email = 'hello@example.com'; + const errors = user.validateSync(); + expect(errors.errors['auth.local.email']).to.not.exist; + }); + + it('throws an error if email is not valid', () => { + const user = new User(); + user.auth.local.email = 'invalid-email'; + const errors = user.validateSync(); + expect(errors.errors['auth.local.email'].message).to.equal(common.i18n.t('invalidEmail')); + }); + + it('throws an error if email is using a restricted domain', () => { + const user = new User(); + user.auth.local.email = 'scammer@habitica.com'; + const errors = user.validateSync(); + expect(errors.errors['auth.local.email'].message).to.equal(common.i18n.t('invalidEmailDomain', { domains: 'habitica.com, habitrpg.com' })); + }); + + it('throws an error if email was blocked specifically', () => { + sandbox.stub(Blocker, 'watchBlockers').returns({ + on: (event, callback) => { + callback({ operation: 'add', blocker: { type: 'email', area: 'full', value: 'blocked@example.com' } }); + }, + }); + const schema = requireAgain(pathToUserSchema).UserSchema; + const valid = schema.paths['auth.local.email'].options.validate.every(v => v.validator('blocked@example.com')); + expect(valid).to.equal(false); + }); + + it('throws an error if email domain was blocked', () => { + sandbox.stub(Blocker, 'watchBlockers').returns({ + on: (event, callback) => { + callback({ operation: 'add', blocker: { type: 'email', area: 'full', value: '@example.com' } }); + }, + }); + const schema = requireAgain(pathToUserSchema).UserSchema; + const valid = schema.paths['auth.local.email'].options.validate.every(v => v.validator('blocked@example.com')); + expect(valid).to.equal(false); + }); + + it('throws an error if user portion of email was blocked', () => { + sandbox.stub(Blocker, 'watchBlockers').returns({ + on: (event, callback) => { + callback({ operation: 'add', blocker: { type: 'email', area: 'full', value: 'blocked@' } }); + }, + }); + const schema = requireAgain(pathToUserSchema).UserSchema; + const valid = schema.paths['auth.local.email'].options.validate.every(v => v.validator('blocked@example.com')); + expect(valid).to.equal(false); + }); + + it('does not throw an error if email is not blocked', () => { + sandbox.stub(Blocker, 'watchBlockers').returns({ + on: (event, callback) => { + callback({ operation: 'add', blocker: { type: 'email', area: 'full', value: '@example.com' } }); + callback({ operation: 'add', blocker: { type: 'email', area: 'full', value: 'blocked@' } }); + callback({ operation: 'add', blocker: { type: 'email', area: 'full', value: 'bad@test.com' } }); + }, + }); + const schema = requireAgain(pathToUserSchema).UserSchema; + const valid = schema.paths['auth.local.email'].options.validate.every(v => v.validator('good@test.com')); + expect(valid).to.equal(true); + }); + }); }); diff --git a/website/client/src/components/admin-panel/filters/formatDate.js b/website/client/src/components/admin/admin-panel/filters/formatDate.js similarity index 100% rename from website/client/src/components/admin-panel/filters/formatDate.js rename to website/client/src/components/admin/admin-panel/filters/formatDate.js diff --git a/website/client/src/components/admin-panel/index.vue b/website/client/src/components/admin/admin-panel/index.vue similarity index 97% rename from website/client/src/components/admin-panel/index.vue rename to website/client/src/components/admin/admin-panel/index.vue index e9c9436153..a39f7ffcad 100644 --- a/website/client/src/components/admin-panel/index.vue +++ b/website/client/src/components/admin/admin-panel/index.vue @@ -1,7 +1,7 @@ diff --git a/website/client/src/components/header/notifications/newMysteryItems.vue b/website/client/src/components/header/notifications/newMysteryItems.vue index 26aa1fced7..0c03aee34b 100644 --- a/website/client/src/components/header/notifications/newMysteryItems.vue +++ b/website/client/src/components/header/notifications/newMysteryItems.vue @@ -12,7 +12,8 @@ > + :image-name="mysteryClass" + /> diff --git a/website/client/src/components/news/newsContent.vue b/website/client/src/components/news/newsContent.vue index de9c1547ee..b94d6ceacc 100644 --- a/website/client/src/components/news/newsContent.vue +++ b/website/client/src/components/news/newsContent.vue @@ -81,9 +81,10 @@ import moment from 'moment'; import habiticaMarkdown from 'habitica-markdown'; import { mapState } from '@/libs/store'; import seasonalNPC from '@/mixins/seasonalNPC'; +import { userStateMixin } from '../../mixins/userState'; export default { - mixins: [seasonalNPC], + mixins: [seasonalNPC, userStateMixin], data () { return { posts: [], @@ -107,7 +108,7 @@ export default { if (lastPublishedPost) this.posts.push(lastPublishedPost); // If the user is authorized, show any draft - if (this.user && (this.user.permissions.news || this.user.permissions.fullAccess)) { + if (this.user && (this.hasPermission(this.user, 'news'))) { this.posts.unshift( ...postsFromServer .filter(p => !p.published || moment().isBefore(p.publishDate)), diff --git a/website/client/src/components/static/features.vue b/website/client/src/components/static/features.vue index 868d58ee71..4461ff42a0 100644 --- a/website/client/src/components/static/features.vue +++ b/website/client/src/components/static/features.vue @@ -7,10 +7,15 @@
- +

{{ $t('marketing1Lead1Title') }}

-

{{ $t('marketing1Lead1') }}

+

+ {{ $t('marketing1Lead1') }} +

@@ -18,12 +23,16 @@

{{ $t('marketing1Lead2Title') }}

-

{{ $t('marketing1Lead2') }}

+

+ {{ $t('marketing1Lead2') }} +

{{ $t('marketing1Lead3Title') }}

-

{{ $t('marketing1Lead3') }}

+

+ {{ $t('marketing1Lead3') }} +


@@ -35,19 +44,26 @@

{{ $t('marketing2Lead1Title') }}

-

{{ $t('marketing2Lead1') }}

+

+ {{ $t('marketing2Lead1') }} +

{{ $t('marketing2Lead2Title') }}

-

+

{{ $t('marketing2Lead3Title') }}

-

{{ $t('marketing2Lead3') }}

+

+ {{ $t('marketing2Lead3') }} +


@@ -60,12 +76,18 @@

{{ $t('marketing3Lead1Title') }}

-

+

{{ $t('marketing3Lead2Title') }}

-

+


@@ -80,7 +102,9 @@

{{ $t('marketing4Lead1Title') }}

-

{{ $t('marketing4Lead1') }}

+

+ {{ $t('marketing4Lead1') }} +

@@ -89,7 +113,9 @@

{{ $t('marketing4Lead2Title') }}

-

{{ $t('marketing4Lead2') }}

+

+ {{ $t('marketing4Lead2') }} +

diff --git a/website/client/src/pages/settings/components/yourBalance.vue b/website/client/src/pages/settings/components/yourBalance.vue index 7911fb2f62..4174ea24b7 100644 --- a/website/client/src/pages/settings/components/yourBalance.vue +++ b/website/client/src/pages/settings/components/yourBalance.vue @@ -11,7 +11,7 @@ class="balance-info" :currency-needed="currencyNeeded" :amount-needed="amountNeeded" - :neededCurrencyOnly="true" + :needed-currency-only="true" /> diff --git a/website/client/src/router/index.js b/website/client/src/router/index.js index 5c02b358d5..cc82cd6cae 100644 --- a/website/client/src/router/index.js +++ b/website/client/src/router/index.js @@ -19,16 +19,12 @@ const HallPage = () => import(/* webpackChunkName: "hall" */'@/components/hall/i const PatronsPage = () => import(/* webpackChunkName: "hall" */'@/components/hall/patrons'); const HeroesPage = () => import(/* webpackChunkName: "hall" */'@/components/hall/heroes'); -// Admin Panel -const AdminPanelPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin-panel'); -const AdminPanelUserPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin-panel/user-support'); -const AdminPanelSearchPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin-panel/search'); - -// Except for tasks that are always loaded all the other main level -// All the main level -// components are loaded in separate webpack chunks. -// See https://webpack.js.org/guides/code-splitting-async/ -// for docs +// Admin Pages +const AdminContainerPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/container'); +const AdminPanelPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/admin-panel'); +const AdminPanelUserPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/admin-panel/user-support'); +const AdminPanelSearchPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/admin-panel/search'); +const BlockerPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/blocker'); // Tasks const UserTasks = () => import(/* webpackChunkName: "userTasks" */'@/components/tasks/user'); @@ -184,32 +180,55 @@ const router = new VueRouter({ }, { - name: 'adminPanel', - path: '/admin-panel', - component: AdminPanelPage, + name: 'adminSection', + path: '/admin', + component: AdminContainerPage, meta: { privilegeNeeded: [ // any one of these is enough to give access 'userSupport', + 'accessControl', ], }, children: [ { - name: 'adminPanelSearch', - path: 'search/:userIdentifier', - component: AdminPanelSearchPage, + name: 'adminPanel', + path: 'panel', + component: AdminPanelPage, meta: { - privilegeNeeded: [ + privilegeNeeded: [ // any one of these is enough to give access 'userSupport', ], }, + children: [ + { + name: 'adminPanelSearch', + path: 'search/:userIdentifier', + component: AdminPanelSearchPage, + meta: { + privilegeNeeded: [ + 'userSupport', + ], + }, + }, + { + name: 'adminPanelUser', + path: ':userIdentifier', + component: AdminPanelUserPage, + meta: { + privilegeNeeded: [ + 'userSupport', + ], + }, + }, + ], }, { - name: 'adminPanelUser', - path: ':userIdentifier', - component: AdminPanelUserPage, + name: 'blockers', + path: 'blockers', + component: BlockerPage, meta: { - privilegeNeeded: [ - 'userSupport', + privilegeNeeded: [ // any one of these is enough to give access + 'accessControl', ], }, }, diff --git a/website/client/src/store/actions/blockers.js b/website/client/src/store/actions/blockers.js new file mode 100644 index 0000000000..4473474d6b --- /dev/null +++ b/website/client/src/store/actions/blockers.js @@ -0,0 +1,19 @@ +import axios from 'axios'; + +export async function getBlockers () { + const response = await axios.get('/api/v4/admin/blockers'); + return response.data.data; +} +export async function createBlocker (store, payload) { + const response = await axios.post('/api/v4/admin/blockers', payload.blocker); + return response.data.data; +} +export async function updateBlocker (store, payload) { + const response = await axios.put(`/api/v4/admin/blockers/${payload.blocker._id}`, payload.blocker); + return response.data.data; +} + +export async function deleteBlocker (store, payload) { + const response = await axios.delete(`/api/v4/admin/blockers/${payload.blockerId}`); + return response.data.data; +} diff --git a/website/client/src/store/actions/index.js b/website/client/src/store/actions/index.js index d9be79396d..2b994c718c 100644 --- a/website/client/src/store/actions/index.js +++ b/website/client/src/store/actions/index.js @@ -20,6 +20,7 @@ import * as worldState from './worldState'; import * as news from './news'; import * as analytics from './analytics'; import * as faq from './faq'; +import * as blockers from './blockers'; // Actions should be named as 'actionName' and can be accessed as 'namespace:actionName' // Example: fetch in user.js -> 'user:fetch' @@ -45,6 +46,7 @@ const actions = flattenAndNamespace({ news, analytics, faq, + blockers, }); export default actions; diff --git a/website/common/locales/en/admin.json b/website/common/locales/en/admin.json new file mode 100644 index 0000000000..9e21dcc161 --- /dev/null +++ b/website/common/locales/en/admin.json @@ -0,0 +1,7 @@ +{ + "adminPanel": "Admin Panel", + "siteBlockers": "Site Blockers", + "newsroom": "Newsroom", + "adminBlockerTypeDescription": "IP-Address - Block access for a specific IP-Address\n\nClient - Block access for a client based on the \"x-client\" header.\n\nE-Mail - Blocks e-mails from being used for signup.", + "adminBlockerAreaDescription": "A blocker can either apply to the full site, completely blocking any access. Or it can apply to purchases, which still allows the site to be accessed." +} diff --git a/website/common/locales/en/front.json b/website/common/locales/en/front.json index 249ed1edf1..62eb77db5a 100644 --- a/website/common/locales/en/front.json +++ b/website/common/locales/en/front.json @@ -116,6 +116,7 @@ "missingPassword": "Missing password.", "missingNewPassword": "Missing new password.", "invalidEmailDomain": "You cannot register with emails with the following domains: <%= domains %>", + "emailBlockedRegistration": "This E-Mail is blocked from registration. If you think this is a mistake, please contact us at admin@habitica.com.", "wrongPassword": "Password is incorrect. If you forgot your password, click \"Forgot Password.\"", "incorrectDeletePhrase": "Please type <%= magicWord %> in all capital letters to delete your account.", "incorrectResetPhrase": "Please type <%= magicWord %> in all capital letters to reset your account.", diff --git a/website/common/script/errors/apiErrorMessages.js b/website/common/script/errors/apiErrorMessages.js index ff129b4280..95198226a8 100644 --- a/website/common/script/errors/apiErrorMessages.js +++ b/website/common/script/errors/apiErrorMessages.js @@ -32,7 +32,8 @@ export default { postIdRequired: '"postId" must be a valid UUID.', noNewsPosterAccess: 'You don\'t have news poster access.', - ipAddressBlocked: 'Your access to Habitica has been blocked. This may be due to a breach of our Terms of Service or for other reasons. For details or to ask to be unblocked, please email admin@habitica.com or ask your parent or guardian to email them. Include your Habitica @Username or User Id in the email if you know it.', + ipAddressBlocked: 'Your connection to Habitica has been blocked. For additional information, or to request an appeal, email admin@habitica.com with your Habitica username or User ID.', + clientBlocked: 'This client or third-party tool has been blocked. For additional information, email admin@habitica.com with your Habitica username or User ID.', clientRateLimited: 'This User ID or IP address has been rate limited due to an excess amount of requests to the Habitica API v3. More info can be found in the response headers and at https://github.com/HabitRPG/habitica/wiki/API-Usage-Guidelines#rate-limiting .', invalidPlatform: 'Invalid platform specified', diff --git a/website/server/controllers/api-v4/admin.js b/website/server/controllers/api-v4/admin.js index bf1decf363..bd4aaedbd2 100644 --- a/website/server/controllers/api-v4/admin.js +++ b/website/server/controllers/api-v4/admin.js @@ -1,8 +1,11 @@ import validator from 'validator'; +import merge from 'lodash/merge'; +import { v4 as uuid } from 'uuid'; import { authWithHeaders } from '../../middlewares/auth'; import { ensurePermission } from '../../middlewares/ensureAccessRight'; import { model as User } from '../../models/user'; import { model as UserHistory } from '../../models/userHistory'; +import { model as Blocker } from '../../models/blocker'; import { NotFound, } from '../../libs/errors'; @@ -116,4 +119,73 @@ api.getUserHistory = { }, }; +api.getBlockers = { + method: 'GET', + url: '/admin/blockers', + middlewares: [authWithHeaders(), ensurePermission('accessControl')], + async handler (req, res) { + const blockers = await Blocker + .find({ disabled: false }) + .lean() + .exec(); + + res.respond(200, blockers); + }, +}; + +api.createBlocker = { + method: 'POST', + url: '/admin/blockers', + middlewares: [authWithHeaders(), ensurePermission('accessControl')], + async handler (req, res) { + const id = uuid(); + const blocker = await Blocker({ + _id: id, + ...Blocker.sanitize(req.body), + }).save(); + + res.respond(200, blocker); + }, +}; + +api.updateBlocker = { + method: 'PUT', + url: '/admin/blockers/:blockerId', + middlewares: [authWithHeaders(), ensurePermission('accessControl')], + async handler (req, res) { + req.checkParams('blockerId', res.t('blockerIdRequired')).notEmpty().isUUID(); + + const validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + const blocker = await Blocker.findById(req.params.blockerId).exec(); + if (!blocker) throw new NotFound(res.t('blockerNotFound')); + + merge(blocker, Blocker.sanitize(req.body)); + const savedBlocker = await blocker.save(); + + res.respond(200, savedBlocker); + }, +}; + +api.deleteBlocker = { + method: 'DELETE', + url: '/admin/blockers/:blockerId', + middlewares: [authWithHeaders(), ensurePermission('accessControl')], + async handler (req, res) { + req.checkParams('blockerId', res.t('blockerIdRequired')).notEmpty().isUUID(); + + const validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + const blocker = await Blocker.findById(req.params.blockerId).exec(); + if (!blocker) throw new NotFound(res.t('blockerNotFound')); + + blocker.disabled = true; + const savedBlocker = await blocker.save(); + + res.respond(200, savedBlocker); + }, +}; + export default api; diff --git a/website/server/middlewares/blocker.js b/website/server/middlewares/blocker.js new file mode 100644 index 0000000000..6388394865 --- /dev/null +++ b/website/server/middlewares/blocker.js @@ -0,0 +1,56 @@ +import { + Forbidden, +} from '../libs/errors'; +import { apiError } from '../libs/apiError'; +import { model as Blocker } from '../models/blocker'; + +// Middleware to block unwanted IP addresses and clients + +// NOTE: it's meant to be used behind a proxy (for example a load balancer) +// that uses the 'x-forwarded-for' header to forward the original IP addresses. + +const blockedIps = []; +const blockedClients = []; + +Blocker.watchBlockers({ + $or: [ + { type: 'ipaddress' }, + { type: 'client' }, + ], + area: 'full', +}, { + initial: true, +}).on('change', async change => { + const { operation, blocker } = change; + const checkedList = blocker.type === 'ipaddress' ? blockedIps : blockedClients; + if (operation === 'add') { + if (blocker.value && !checkedList.includes(blocker.value)) { + checkedList.push(blocker.value); + } + } else if (operation === 'delete') { + const index = checkedList.indexOf(blocker.value); + if (index !== -1) { + checkedList.splice(index, 1); + } + } +}); + +export default function ipBlocker (req, res, next) { + if (blockedIps.length === 0 && blockedClients.length === 0) return next(); + + const ipMatch = blockedIps.find(blockedIp => blockedIp === req.ip) !== undefined; + if (ipMatch === true) { + const error = new Forbidden(apiError('ipAddressBlocked')); + error.skipLogging = true; + return next(error); + } + + const clientMatch = blockedClients.find(blockedClient => blockedClient === req.headers['x-client']) !== undefined; + if (clientMatch === true) { + const error = new Forbidden(apiError('clientBlocked')); + error.skipLogging = true; + return next(error); + } + + return next(); +} diff --git a/website/server/middlewares/errorHandler.js b/website/server/middlewares/errorHandler.js index c3cdf11fab..9480752517 100644 --- a/website/server/middlewares/errorHandler.js +++ b/website/server/middlewares/errorHandler.js @@ -66,19 +66,21 @@ export default function errorHandler (err, req, res, next) { // eslint-disable-l responseErr = new InternalServerError(); } - // log the error - logger.error(err, { - method: req.method, - originalUrl: req.originalUrl, + if (!err.skipLogging) { + // log the error + logger.error(err, { + method: req.method, + originalUrl: req.originalUrl, - // don't send sensitive information that only adds noise - headers: omit(req.headers, ['x-api-key', 'cookie', 'password', 'confirmPassword']), - body: omit(req.body, ['password', 'confirmPassword']), - query: omit(req.query, ['password', 'confirmPassword']), + // don't send sensitive information that only adds noise + headers: omit(req.headers, ['x-api-key', 'cookie', 'password', 'confirmPassword']), + body: omit(req.body, ['password', 'confirmPassword']), + query: omit(req.query, ['password', 'confirmPassword']), - httpCode: responseErr.httpCode, - isHandledError: responseErr.httpCode < 500, - }); + httpCode: responseErr.httpCode, + isHandledError: responseErr.httpCode < 500, + }); + } const jsonRes = { success: false, diff --git a/website/server/middlewares/index.js b/website/server/middlewares/index.js index e43e38ba00..4cd0df9ace 100644 --- a/website/server/middlewares/index.js +++ b/website/server/middlewares/index.js @@ -23,7 +23,7 @@ import { forceSSL, forceHabitica, } from './redirects'; -import ipBlocker from './ipBlocker'; +import blocker from './blocker'; import v1 from './v1'; import v2 from './v2'; import appRoutes from './appRoutes'; @@ -81,7 +81,7 @@ export default function attachMiddlewares (app, server) { app.use(maintenanceMode); - app.use(ipBlocker); + app.use(blocker); app.use(cors); app.use(forceSSL); diff --git a/website/server/middlewares/ipBlocker.js b/website/server/middlewares/ipBlocker.js deleted file mode 100644 index c11f3e47d1..0000000000 --- a/website/server/middlewares/ipBlocker.js +++ /dev/null @@ -1,38 +0,0 @@ -import nconf from 'nconf'; -import { - Forbidden, -} from '../libs/errors'; -import { apiError } from '../libs/apiError'; - -// Middleware to block unwanted IP addresses - -// NOTE: it's meant to be used behind a proxy (for example a load balancer) -// that uses the 'x-forwarded-for' header to forward the original IP addresses. - -// A list of comma separated IPs to block -// It works fine as long as the list is short, -// if the list becomes too long for an env variable we'll switch to Redis. -const BLOCKED_IPS_RAW = nconf.get('BLOCKED_IPS'); - -const blockedIps = BLOCKED_IPS_RAW - ? BLOCKED_IPS_RAW - .trim() - .split(',') - .map(blockedIp => blockedIp.trim()) - .filter(blockedIp => Boolean(blockedIp)) - : []; - -export default function ipBlocker (req, res, next) { - // If there are no IPs to block, skip the middleware - if (blockedIps.length === 0) return next(); - - // Is the client IP, req.ip, blocked? - const match = blockedIps.find(blockedIp => blockedIp === req.ip) !== undefined; - - if (match === true) { - // Not translated because no user is loaded at this point - return next(new Forbidden(apiError('ipAddressBlocked'))); - } - - return next(); -} diff --git a/website/server/models/blocker.js b/website/server/models/blocker.js new file mode 100644 index 0000000000..51f1c13529 --- /dev/null +++ b/website/server/models/blocker.js @@ -0,0 +1,103 @@ +/* eslint-disable camelcase */ + +import mongoose from 'mongoose'; +import EventEmitter from 'events'; +import baseModel from '../libs/baseModel'; + +export const blockTypes = [ + 'ipaddress', + 'email', + 'client', +]; + +export const blockArea = [ + 'full', + 'payments', +]; + +export const schema = new mongoose.Schema({ + disabled: { + $type: Boolean, default: false, // If true, the block is disabled + }, + type: { + $type: String, enum: blockTypes, required: true, + }, + area: { + $type: String, enum: blockArea, default: 'full', // full or payment + }, + value: { + $type: String, required: true, // e.g. IP address + }, + blockSource: { + $type: String, enum: ['administrator', 'system', 'worker'], default: 'administrator', // who created the block + }, + reason: { + $type: String, required: false, // e.g. 'abusive behavior' + }, +}, { + strict: true, + minimize: false, // So empty objects are returned + typeKey: '$type', // So that we can use fields named `type` +}); + +schema.plugin(baseModel, { + timestamps: true, +}); + +schema.statics.watchBlockers = function watchBlockers (query, options) { + const emitter = new EventEmitter(); + const matchQuery = { + $match: {}, + }; + if (query) { + if (query.type) { + matchQuery.$match['fullDocument.type'] = query.type; + } + if (query.area) { + matchQuery.$match['fullDocument.area'] = query.area; + } + } + process.nextTick(() => { + this.watch([matchQuery], { + fullDocument: 'updateLookup', + }) + .on('change', change => { + if (!change.fullDocument) { + return; // Ignore changes that don't have a fullDocument + } + if (change.operationType === 'insert' || !change.fullDocument.disabled) { + emitter.emit('change', { + operation: 'add', + blocker: change.fullDocument, + }); + } else if (change.operationType === 'update' && change.fullDocument.disabled) { + emitter.emit('change', { + operation: 'delete', + blocker: change.fullDocument, + }); + } + }) + .on('error', error => { + emitter.emit('error', error); + }); + if (options.initial) { + const initialQuery = { + disabled: false, + ...query, + }; + this.find(initialQuery).then(docs => { + for (const doc of docs) { + emitter.emit('change', { + operation: 'add', + blocker: doc, + }); + } + }).catch(error => { + emitter.emit('error', error); + }); + } + }); + return emitter; +}; + +export const model = mongoose.model('Blocker', schema); diff --git a/website/server/models/user/schema.js b/website/server/models/user/schema.js index efffc176c0..477d522181 100644 --- a/website/server/models/user/schema.js +++ b/website/server/models/user/schema.js @@ -9,9 +9,31 @@ import { schema as SubscriptionPlanSchema } from '../subscriptionPlan'; import { schema as TagSchema } from '../tag'; import { schema as UserNotificationSchema } from '../userNotification'; import { schema as WebhookSchema } from '../webhook'; +import { model as Blocker } from '../blocker'; const RESTRICTED_EMAIL_DOMAINS = Object.freeze(['habitica.com', 'habitrpg.com']); +const BLOCKED_EMAILS = []; + +Blocker.watchBlockers({ + type: 'email', + area: 'full', +}, { + initial: true, +}).on('change', async change => { + const { operation, blocker: { value } } = change; + if (operation === 'add') { + if (value && !BLOCKED_EMAILS.includes(value)) { + BLOCKED_EMAILS.push(value); + } + } else if (operation === 'delete') { + const index = BLOCKED_EMAILS.indexOf(value); + if (index !== -1) { + BLOCKED_EMAILS.splice(index, 1); + } + } +}); + // User schema definition export const UserSchema = new Schema({ apiToken: { @@ -43,6 +65,12 @@ export const UserSchema = new Schema({ return RESTRICTED_EMAIL_DOMAINS.every(domain => !lowercaseEmail.endsWith(`@${domain}`)); }, message: shared.i18n.t('invalidEmailDomain', { domains: RESTRICTED_EMAIL_DOMAINS.join(', ') }), + }, { + validator (email) { + const lowercaseEmail = email.toLowerCase(); + return BLOCKED_EMAILS.every(block => lowercaseEmail.indexOf(block) === -1); + }, + message: shared.i18n.t('emailBlockedRegistration'), }], }, username: { @@ -196,6 +224,7 @@ export const UserSchema = new Schema({ userSupport: Boolean, // access User Support feature in Admin Panel challengeAdmin: Boolean, // Can manage and administrate challenges moderator: Boolean, // Can ban, flag users and manage social spaces + accessControl: Boolean, // Can manage IP and client blockers coupons: Boolean, // Can generate and request coupons }, balance: { $type: Number, default: 0 },