Add a global option to force accounts to require 2fa

Add a global option to force changing board settings to need 2FA
master
Thomas Lynch 4 months ago
parent 04a228ccd3
commit 6c34f33dec
  1. 4
      configs/template.js.example
  2. 19
      controllers/forms/boardsettings.js
  3. 23
      controllers/forms/globalsettings.js
  4. 2
      controllers/forms/twofactor.js
  5. 11
      lib/middleware/permission/sessionrefresh.js
  6. 18
      migrations/1.6.0.js
  7. 9
      models/forms/changeglobalsettings.js
  8. 2
      models/forms/login.js
  9. 2
      models/pages/twofactor.js
  10. 8
      test/setup.js
  11. 12
      views/custompages/faq.pug
  12. 18
      views/includes/globalmanagesettings.pug
  13. 6
      views/pages/globalmanagesettings.pug

@ -62,6 +62,10 @@ module.exports = {
cacheTime: 3600 //in seconds, idk whats a good value
},
//TODO: default these true and update instructions for how to disable
forceAccountTwofactor: false,
forceActionTwofactor: false,
//disable file posting over anonymizers globally, overrides any board setting.
disableAnonymizerFilePosting: false,

@ -8,14 +8,15 @@ const changeBoardSettings = require(__dirname+'/../../models/forms/changeboardse
, config = require(__dirname+'/../../lib/misc/config.js')
, paramConverter = require(__dirname+'/../../lib/middleware/input/paramconverter.js')
, i18n = require(__dirname+'/../../lib/locale/locale.js')
, { checkSchema, lengthBody, numberBody, minmaxBody, numberBodyVariable,
, doTwoFactor = require(__dirname+'/../../lib/misc/dotwofactor.js')
, { existsBody, checkSchema, lengthBody, numberBody, minmaxBody, numberBodyVariable,
inArrayBody, arrayInBody } = require(__dirname+'/../../lib/input/schema.js');
module.exports = {
paramConverter: paramConverter({
timeFields: ['delete_protection_age'],
trimFields: ['tags', 'announcement', 'description', 'name', 'custom_css', 'language'],
trimFields: ['twofactor', 'tags', 'announcement', 'description', 'name', 'custom_css', 'language'],
allowedArrays: ['countries'],
numberFields: ['lock_reset', 'captcha_reset', 'lock_mode', 'message_r9k_mode', 'file_r9k_mode', 'captcha_mode', 'tph_trigger', 'pph_trigger', 'pph_trigger_action',
'tph_trigger_action', 'bump_limit', 'reply_limit', 'max_files', 'thread_limit', 'max_thread_message_length', 'max_reply_message_length', 'min_thread_message_length',
@ -26,11 +27,23 @@ module.exports = {
const { __ } = res.locals;
const { globalLimits, rateLimitCost } = config.get
const { globalLimits, rateLimitCost, forceActionTwofactor } = config.get
, maxThread = (Math.min(globalLimits.fieldLength.message, res.locals.board.settings.maxThreadMessageLength) || globalLimits.fieldLength.message)
, maxReply = (Math.min(globalLimits.fieldLength.message, res.locals.board.settings.maxReplyMessageLength) || globalLimits.fieldLength.message);
const errors = await checkSchema([
{ result: existsBody(req.body.twofactor) ? lengthBody(req.body.twofactor, 0, 6) : false, expected: false, error: __('Invalid 2FA code') },
{ result: async () => {
if (res.locals.user.twofactor && forceActionTwofactor) {
//2fA (TOTP) validation
const delta = await doTwoFactor(res.locals.user.username, res.locals.user.twofactor, req.body.twofactor || '');
if (delta === null) {
return false;
}
} else {
return true; //Force twofactor not enabled
}
}, expected: true, error: __('Invalid 2FA Code') },
{ result: lengthBody(req.body.description, 0, globalLimits.fieldLength.description), expected: false, error: __('Board description must be %s characters or less', globalLimits.fieldLength.description) },
{ result: lengthBody(req.body.announcements, 0, 5000), expected: false, error: __('Board announcements must be 5000 characters or less') },
{ result: lengthBody(req.body.tags, 0, 2000), expected: false, error: __('Tags length must be 2000 characters or less') },

@ -7,6 +7,7 @@ const changeGlobalSettings = require(__dirname+'/../../models/forms/changeglobal
, { fontPaths } = require(__dirname+'/../../lib/misc/fonts.js')
, paramConverter = require(__dirname+'/../../lib/middleware/input/paramconverter.js')
, i18n = require(__dirname+'/../../lib/locale/locale.js')
, doTwoFactor = require(__dirname+'/../../lib/misc/dotwofactor.js')
, { checkSchema, lengthBody, numberBody, minmaxBody, numberBodyVariable,
inArrayBody, existsBody } = require(__dirname+'/../../lib/input/schema.js');
@ -14,7 +15,7 @@ module.exports = {
paramConverter: paramConverter({
timeFields: ['hot_threads_max_age', 'inactive_account_time', 'default_ban_duration', 'block_bypass_expire_after_time', 'dnsbl_cache_time', 'board_defaults_delete_protection_age'],
trimFields: ['captcha_options_grid_question', 'captcha_options_grid_trues', 'captcha_options_grid_falses', 'captcha_options_font', 'allowed_hosts', 'dnsbl_blacklists', 'other_mime_types',
trimFields: ['twofactor', 'captcha_options_grid_question', 'captcha_options_grid_trues', 'captcha_options_grid_falses', 'captcha_options_font', 'allowed_hosts', 'dnsbl_blacklists', 'other_mime_types',
'highlight_options_language_subset', 'global_limits_custom_css_filters', 'board_defaults_filters', 'filters', 'archive_links', 'ethereum_links', 'reverse_links', 'language', 'board_defaults_language'],
numberFields: ['inactive_account_action', 'abandoned_board_action', 'auth_level', 'captcha_options_text_wave', 'captcha_options_text_paint', 'captcha_options_text_noise',
'captcha_options_grid_noise', 'captcha_options_grid_edge', 'captcha_options_generate_limit', 'captcha_options_grid_size', 'captcha_options_grid_image_size',
@ -41,9 +42,26 @@ module.exports = {
const { __ } = res.locals;
const { globalLimits } = config.get;
const { globalLimits, forceActionTwofactor } = config.get;
const errors = await checkSchema([
{ result: existsBody(req.body.twofactor) ? lengthBody(req.body.twofactor, 0, 6) : false, expected: false, error: __('Invalid 2FA code') },
{ result: async () => {
if (res.locals.user.twofactor && forceActionTwofactor) {
//2fA (TOTP) validation
try {
const delta = await doTwoFactor(res.locals.user.username, res.locals.user.twofactor, req.body.twofactor || '');
if (delta === null) {
return false;
}
} catch (err) {
// console.warn(err);
return false;
}
} else {
return true; //Force twofactor not enabled
}
}, expected: true, error: __('Invalid 2FA Code') },
{ result: () => {
if (req.body.thumb_extension) {
return /\.[a-z0-9]+/i.test(req.body.thumb_extension);
@ -209,6 +227,7 @@ module.exports = {
{ result: lengthBody(req.body.webring_following, 0, 10000), expected: false, error: __('Webring following list must not exceed 10000 characters') },
{ result: lengthBody(req.body.webring_blacklist, 0, 10000), expected: false, error: __('Webring blacklist must not exceed 10000 characters') },
{ result: lengthBody(req.body.webring_logos, 0, 10000), expected: false, error: __('Webring logos list must not exceed 10000 characters') },
{ result: lengthBody(req.body.twofactor, 0, 6), expected: false, error: __('Invalid 2FA code') },
]);
if (errors.length > 0) {

@ -16,7 +16,7 @@ module.exports = {
const { __ } = res.locals;
const errors = await checkSchema([
{ result: res.locals.user.twofactor === false, expected: true, error: __('You already have 2FA setup') },
{ result: res.locals.user.twofactor === null, expected: false, error: __('You already have 2FA setup') },
{ result: existsBody(req.body.twofactor), expected: true, error: __('Missing 2FA code') },
{ result: lengthBody(req.body.twofactor, 6, 6), expected: false, error: __('2FA code must be 6 characters') },
]);

@ -2,7 +2,8 @@
const { Accounts } = require(__dirname+'/../../../db/')
, { DAY } = require(__dirname+'/../../converter/timeutils.js')
, cache = require(__dirname+'/../../redis/redis.js');
, cache = require(__dirname+'/../../redis/redis.js')
, config = require(__dirname+'/../../misc/config.js');
module.exports = async (req, res, next) => {
if (!res.locals) {
@ -29,6 +30,14 @@ module.exports = async (req, res, next) => {
cache.set(`users:${req.session.user}`, res.locals.user, 3600);
}
}
const { forceAccountTwofactor } = config.get;
//Note: not /forms/twofactor because req.path doesnt contain the mount point when called from a middleware
if (!['/twofactor.html', '/twofactor'].includes(req.path)
&& forceAccountTwofactor === true
&& res.locals.user
&& !res.locals.user.twofactor) {
return res.redirect('/twofactor.html');
}
}
next();
};

@ -0,0 +1,18 @@
'use strict';
module.exports = async(db, redis) => {
console.log('Updating globalsettings to add 2fa enforcement options');
await db.collection('globalsettings').updateOne({ _id: 'globalsettings' }, {
'$set': {
'forceAccountTwofactor': false,
'forceActionTwofactor': false, //TODO: potentially break this down to be more granular on what needs 2fa
},
});
console.log('Clearing globalsettings cache');
await redis.deletePattern('globalsettings');
console.log('Clearing boards cache');
await redis.deletePattern('board:*');
};

@ -119,6 +119,8 @@ module.exports = async (req, res) => {
boardSettings: numberSetting(req.body.rate_limit_cost_board_settings, oldSettings.rateLimitCost.boardSettings),
editPost: numberSetting(req.body.rate_limit_cost_edit_post, oldSettings.rateLimitCost.editPost),
},
forceAccountTwofactor: booleanSetting(req.body.force_account_twofactor, oldSettings.forceAccountTwofactor),
forceActionTwofactor: booleanSetting(req.body.force_action_twofactor, oldSettings.forceActionTwofactor),
inactiveAccountTime: numberSetting(req.body.inactive_account_time, oldSettings.inactiveAccountTime),
inactiveAccountAction: numberSetting(req.body.inactive_account_action, oldSettings.inactiveAccountAction),
abandonedBoardAction: numberSetting(req.body.abandoned_board_action, oldSettings.abandonedBoardAction),
@ -342,11 +344,16 @@ module.exports = async (req, res) => {
await Mongo.setConfig(newSettings);
//Toggling forced 2fa modes requires user cache to be cleared so new twofactor value can be fetched during refreshsession()
if (oldSettings.forceAccountTwofactor != newSettings.forceAccountTwofactor) {
promises.push(redis.deletePattern('users:*'));
}
//webring being disabled
if (oldSettings.enableWebring === true && newSettings.enableWebring === false) {
promises.push(Boards.db.deleteMany({ webring: true }));
promises.push(remove(`${uploadDirectory}/json/webring.json`));
redis.del('webringsites');
promises.push(redis.del('webringsites'));
}
//finish the promises in parallel e.g. removing files

@ -40,7 +40,7 @@ module.exports = async (req, res) => {
// bcrypt compare input to saved hash
const passwordMatch = await bcrypt.compare(password, account.passwordHash);
//2fA (TOTP) validation
const delta = await doTwoFactor(username, account.twofactor, req.body.twofactor || '');
const delta = await doTwoFactor(username, account.twofactor || '', req.body.twofactor || '');
//if password was correct and 2fa valid (if enabled)
if (passwordMatch === false
|| (account.twofactor && delta === null)) {

@ -16,7 +16,7 @@ module.exports = async (req, res, next) => {
// Ratelimit QR code generation
const username = res.locals.user.username;
const ratelimit = await Ratelimits.incrmentQuota(username, '2fa', 50);
const ratelimit = await Ratelimits.incrmentQuota(username, '2fa', 10);
if (ratelimit > 100) {
const { __ } = res.locals;
return dynamicResponse(req, res, 429, 'message', {

@ -58,11 +58,7 @@ module.exports = () => describe('login and create test board', () => {
redirect: 'manual',
};
const response1 = await fetch('http://localhost/forms/create', options);
expect([302, 409]).toContain(response1.status);
params.set('name', 'test2');
params.set('uri', 'test2');
const response2 = await fetch('http://localhost/forms/create', options);
expect([302, 409]).toContain(response2.status);
expect([302]).toContain(response1.status);
});
test('create /test2/ board', async () => {
@ -94,7 +90,7 @@ module.exports = () => describe('login and create test board', () => {
body: params,
redirect: 'manual',
});
expect([302, 409]).toContain(response.status);
expect([302]).toContain(response.status);
});
test('change global settings, disable antispam', async () => {

@ -29,6 +29,7 @@ block content
li: a(href='#make-a-board') How do I make my own board?
li: a(href='#antispam') What do the board settings for antispam do?
li: a(href='#archive-reverse-url-format') What is the archive/reverse image search link url format?
li: a(href='#twofactor-enforcement') How does 2FA enforcement work?
.table-container.flex-center.mv-5
.anchor#whats-an-imageboard
table
@ -289,6 +290,17 @@ block content
| where the url of the page/file should go for reverse image search or archive links. For example
span.mono https://tineye.com/search?url=#[span.bold %s]
| .
.table-container.flex-center.mv-5
.anchor#twofactor-enforcement
table
tr
th: a(href='#twofactor-enforcement') How does 2FA enforcement work?
tr
td
p There are two options included in global management settings that allow admins to increase security by enforcing the use of 2FA for all accounts and/or actions.
p Force Account 2FA: Forces all accounts to have 2FA, or else the user is redirected to the 2FA setup page.
p Force Action 2FA: If an account has 2FA enabled, changing board settings or deleting a board requires 2FA.
p The granularity of what actions require 2FA will be expanded and customisable in a future release.
.table-container.flex-center.mv-5
.anchor#contact
table

@ -135,10 +135,24 @@ ul.tabs.group
.row
.label #{__('Use Socks Proxy')}
label.postform-style.ph-5
input(type='checkbox', name='proxy_enabled', value='true' placeholder='socks5h://127.0.0.1:9050' checked=settings.proxy.enabled)
input(type='checkbox', name='proxy_enabled', value='true' checked=settings.proxy.enabled)
.row
.label #{__('Proxy Address')}
input(type='text', name='proxy_address', value=settings.proxy.address)
input(type='text' name='proxy_address' placeholder='socks5h://127.0.0.1:9050' value=settings.proxy.address)
.row
.label #{__('Force Account 2FA')}
label.postform-style.ph-5
input(type='checkbox' name='force_account_twofactor' value='true' checked=settings.forceAccountTwofactor)
.row
.label
| #{__('Force Action 2FA')}
|
small
| (
a(href='/faq.html#twofactor-enforcement') ?
| )
label.postform-style.ph-5
input(type='checkbox' name='force_action_twofactor' value='true' checked=settings.forceActionTwofactor)
.tab.tab-3
.col

@ -27,6 +27,10 @@ block content
.sm#tab-11
.tabbed-area
include ../includes/globalmanagesettings.pug
if settings.forceActionTwofactor === true
.row
.label #{__('2FA Code')}
input(type='text' name='twofactor' required)
input.row(type='submit', value=__('Save settings'))
hr(size=1)
h4.no-m-p #{__('Delete board')}:
@ -34,7 +38,7 @@ block content
form.form-post(action=`/forms/global/deleteboard`, enctype='application/x-www-form-urlencoded', method='POST')
input(type='hidden' name='_csrf' value=csrf)
.row
.label #{__('Board URI')}:
.label #{__('Board URI')}
input(type='text' name='uri' required)
.row
.label #{__("I'm sure")}

Loading…
Cancel
Save