Merge branch 'feature/2fa-totp' into 'develop'

Two Facor Authentication

See merge request fatchan/jschan!274
merge-requests/346/merge
Thomas Lynch 2 years ago
commit d51bef9d37
  1. 1
      README.md
  2. 3
      controllers/forms.js
  3. 3
      controllers/forms/changepassword.js
  4. 1
      controllers/forms/index.js
  5. 3
      controllers/forms/login.js
  6. 38
      controllers/forms/twofactor.js
  7. 3
      controllers/pages.js
  8. 13
      db/accounts.js
  9. 11
      gulp/res/css/style.css
  10. 3
      lib/middleware/permission/sessionrefresh.js
  11. 26
      lib/misc/dotwofactor.js
  12. 8
      lib/redis/redis.js
  13. 12
      migrations/0.10.0.js
  14. 16
      models/forms/changepassword.js
  15. 40
      models/forms/login.js
  16. 51
      models/forms/twofactor.js
  17. 1
      models/pages/index.js
  18. 54
      models/pages/twofactor.js
  19. 267
      package-lock.json
  20. 6
      package.json
  21. 8
      schedules/tasks/inactiveaccounts.js
  22. 1
      test/integration.test.js
  23. 1
      test/setup.js
  24. 220
      test/twofactor.js
  25. 2
      views/pages/account.pug
  26. 3
      views/pages/changepassword.pug
  27. 3
      views/pages/login.pug
  28. 26
      views/pages/twofactor.pug

@ -16,6 +16,7 @@ Join the IRC: [open in client](ircs://irc.fatpeople.lol:6697/general) OR: [webch
- [x] [API documentation](http://fatchan.gitgud.site/jschan-docs/)
- [x] Built-in webring (compatible w/ [lynxchan](https://gitlab.com/alogware/LynxChanAddon-Webring) & [infinity](https://gitlab.com/Tenicu/infinityaddon-webring))
- [x] [Tegaki](https://github.com/desuwa/tegaki) applet with drawing and replays
- [x] Two factor authentication (TOTP) for accounts
- [x] Manage everything from the web panel
- [x] Detailed accounts permissions system
- [x] Works properly with anonymizer networks (Tor, Lokinet, etc)

@ -21,7 +21,7 @@ const express = require('express')
, blockBypass = require(__dirname+'/../lib/middleware/captcha/blockbypass.js')
, fileMiddlewares = require(__dirname+'/../lib/middleware/file/filemiddlewares.js')
//controllers
, { deleteBoardController, editBansController, appealController, globalActionController,
, { deleteBoardController, editBansController, appealController, globalActionController, twofactorController,
actionController, addCustomPageController, deleteCustomPageController, addNewsController,
editNewsController, deleteNewsController, uploadBannersController, deleteBannersController, addFlagsController,
deleteFlagsController, boardSettingsController, transferController, addAssetsController, deleteAssetsController,
@ -111,6 +111,7 @@ router.post('/create', geoIp, processIp, useSession, sessionRefresh, isLoggedIn,
//accounts
router.post('/login', useSession, loginController.paramConverter, loginController.controller);
router.post('/twofactor', useSession, sessionRefresh, csrf, calcPerms, isLoggedIn, twofactorController.paramConverter, twofactorController.controller);
router.post('/logout', useSession, logoutForm);
router.post('/register', geoIp, processIp, useSession, sessionRefresh, calcPerms, verifyCaptcha, registerController.paramConverter, registerController.controller);
router.post('/changepassword', geoIp, processIp, useSession, sessionRefresh, verifyCaptcha, changePasswordController.paramConverter, changePasswordController.controller);

@ -8,7 +8,7 @@ const changePassword = require(__dirname+'/../../models/forms/changepassword.js'
module.exports = {
paramConverter: paramConverter({
trimFields: ['username', 'password', 'newpassword', 'newpasswordconfirm'],
trimFields: ['username', 'password', 'twofactor', 'newpassword', 'newpasswordconfirm'],
}),
controller: async (req, res, next) => {
@ -23,6 +23,7 @@ module.exports = {
{ result: existsBody(req.body.newpasswordconfirm), expected: true, error: 'Missing new password confirmation' },
{ result: lengthBody(req.body.newpasswordconfirm, 0, 100), expected: false, error: 'New password confirmation must be 100 characters or less' },
{ result: (req.body.newpassword === req.body.newpasswordconfirm), expected: true, error: 'New password and password confirmation must match' },
{ result: lengthBody(req.body.twofactor, 0, 6), expected: false, error: 'Invalid 2FA code' },
]);
if (errors.length > 0) {

@ -25,6 +25,7 @@ module.exports = {
deleteAccountController: require(__dirname+'/deleteaccount.js'),
editAccountController: require(__dirname+'/editaccount.js'),
loginController: require(__dirname+'/login.js'),
twofactorController: require(__dirname+'/twofactor.js'),
registerController: require(__dirname+'/register.js'),
changePasswordController: require(__dirname+'/changepassword.js'),
deleteSessionsController: require(__dirname+'/deletesessions.js'),

@ -8,7 +8,7 @@ const loginAccount = require(__dirname+'/../../models/forms/login.js')
module.exports = {
paramConverter: paramConverter({
trimFields: ['username', 'password'],
trimFields: ['username', 'password', 'twofactor'],
}),
controller: async (req, res, next) => {
@ -18,6 +18,7 @@ module.exports = {
{ result: existsBody(req.body.password), expected: true, error: 'Missing password' },
{ result: lengthBody(req.body.username, 0, 50), expected: false, error: 'Username must be 1-50 characters' },
{ result: lengthBody(req.body.password, 0, 100), expected: false, error: 'Password must be 1-100 characters' },
{ result: lengthBody(req.body.twofactor, 0, 6), expected: false, error: 'Invalid 2FA code' },
]);
if (errors.length > 0) {

@ -0,0 +1,38 @@
'use strict';
const verifyTwofactor = require(__dirname+'/../../models/forms/twofactor.js')
, dynamicResponse = require(__dirname+'/../../lib/misc/dynamic.js')
, paramConverter = require(__dirname+'/../../lib/middleware/input/paramconverter.js')
, { checkSchema, lengthBody, existsBody } = require(__dirname+'/../../lib/input/schema.js');
module.exports = {
paramConverter: paramConverter({
trimFields: ['twofactor'],
}),
controller: async (req, res, next) => {
const errors = await checkSchema([
{ result: res.locals.user.twofactor === false, expected: true, 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' },
]);
if (errors.length > 0) {
return dynamicResponse(req, res, 400, 'message', {
'title': 'Bad request',
'errors': errors,
'redirect': '/twofactor.html'
});
}
try {
await verifyTwofactor(req, res, next);
} catch (err) {
return next(err);
}
},
};

@ -21,7 +21,7 @@ const express = require('express')
manageBoard, manageThread, manageLogs, manageCatalog, manageCustomPages, manageStaff, editStaff } = require(__dirname+'/../models/pages/manage/')
, { globalManageSettings, globalManageReports, globalManageBans, globalManageBoards, editNews, editAccount, editRole,
globalManageRecent, globalManageAccounts, globalManageNews, globalManageLogs, globalManageRoles } = require(__dirname+'/../models/pages/globalmanage/')
, { changePassword, blockBypass, home, register, login, create, myPermissions, sessions,
, { changePassword, blockBypass, home, register, login, create, myPermissions, sessions, setupTwoFactor,
board, catalog, banners, boardSettings, globalSettings, randombanner, news, captchaPage, overboard, overboardCatalog,
captcha, thread, modlog, modloglist, account, boardlist, customPage, csrfPage } = require(__dirname+'/../models/pages/')
, threadParamConverter = paramConverter({ processThreadIdParam: true })
@ -121,6 +121,7 @@ router.get('/bypass_minimal.html', setMinimal, blockBypass); //block bypass page
//accounts
router.get('/account.html', useSession, sessionRefresh, isLoggedIn, calcPerms, csrf, account); //page showing boards you are mod/owner of, links to password rese, logout, etc
router.get('/mypermissions.html', useSession, sessionRefresh, isLoggedIn, calcPerms, myPermissions);
router.get('/twofactor.html', useSession, sessionRefresh, isLoggedIn, calcPerms, csrf, setupTwoFactor);
router.get('/sessions.html', useSession, sessionRefresh, isLoggedIn, calcPerms, csrf, sessions);
router.get('/login.html', login);
router.get('/register.html', register);

@ -46,7 +46,8 @@ module.exports = {
passwordHash,
'permissions': Mongo.Binary(permissions.array),
'ownedBoards': [],
'staffBoards': []
'staffBoards': [],
'twofactor': null,
});
cache.del(`users:${username}`);
return res;
@ -99,6 +100,16 @@ module.exports = {
});
},
updateTwofactor: (username, secret) => {
return db.updateOne({
'_id': username
}, {
'$set': {
'twofactor': secret
}
});
},
getInactive: (duration=(MONTH*3)) => {
return db.find({
'permissions': {

@ -141,6 +141,13 @@ pre {
font-family: monospace;
max-width: calc(100vw - 50px);
}
.twofactor { /* importants to try and prevent themes overriding */
background: white!important;
color: black!important;
line-height: 1.2em!important;
font-family: monospace!important;
margin: 10px auto!important;
}
.edit {
background: transparent!important;
border-color: transparent!important;
@ -1567,7 +1574,9 @@ row.wrap.sb .col {
}
@media only screen and (max-width: 600px) {
.twofactor {
font-size: 50%;
}
.ct-r2 .catalog-thumb.small {
max-width: 32px;
max-height: 32px;

@ -21,6 +21,9 @@ module.exports = async (req, res, next) => {
'permissions': account.permissions.toString('base64'),
'staffBoards': account.staffBoards,
'ownedBoards': account.ownedBoards,
/* For security, only storing a boolean used for checks,
we dont need/want to store the twofactor secret in session */
'twofactor': account.twofactor != null,
};
req.session.expires = new Date(Date.now() + (3 * DAY));
cache.set(`users:${req.session.user}`, res.locals.user, 3600);

@ -0,0 +1,26 @@
const OTPAuth = require('otpauth')
, redis = require(__dirname+'/../redis/redis.js');
module.exports = async (username, totpSecret, userInput) => {
if (!userInput) {
return null;
}
const totp = new OTPAuth.TOTP({
secret: totpSecret,
algorithm: 'SHA256',
});
let delta = totp.validate({
token: userInput,
algorithm: 'SHA256',
window: 1,
});
if (delta !== null) {
const key = `twofactor_success:${username}`;
const uses = await redis.incr(key);
redis.expire(key, 30);
if (uses && uses > 1) {
return null;
}
}
return delta;
};

@ -62,6 +62,14 @@ module.exports = {
}
},
incr: (key) => {
return sharedClient.incr(key);
},
expire: (key, ttl) => {
return sharedClient.expire(key, ttl);
},
//set a value on key if not exist
setnx: (key, value) => {
return sharedClient.setnx(key, JSON.stringify(value));

@ -0,0 +1,12 @@
'use strict';
module.exports = async(db, redis) => {
console.log('setting 2fa/totp property on accounts');
await db.collection('accounts').updateMany({}, {
'$set': {
'twofactor': null,
}
});
console.log('Clearing globalsettings cache');
await redis.deletePattern('globalsettings');
};

@ -3,6 +3,7 @@
const bcrypt = require('bcrypt')
, dynamicResponse = require(__dirname+'/../../lib/misc/dynamic.js')
, redis = require(__dirname+'/../../lib/redis/redis.js')
, doTwoFactor = require(__dirname+'/../../lib/misc/dotwofactor.js')
, { Accounts } = require(__dirname+'/../../db/');
module.exports = async (req, res) => {
@ -18,7 +19,7 @@ module.exports = async (req, res) => {
if (!account) {
return dynamicResponse(req, res, 403, 'message', {
'title': 'Forbidden',
'message': 'Incorrect username or password',
'message': 'Incorrect account credentials',
'redirect': '/changepassword.html'
});
}
@ -30,11 +31,22 @@ module.exports = async (req, res) => {
if (passwordMatch === false) {
return dynamicResponse(req, res, 403, 'message', {
'title': 'Forbidden',
'message': 'Incorrect username or password',
'message': 'Incorrect account credentials',
'redirect': '/changepassword.html'
});
}
if (account.twofactor) {
const delta = await doTwoFactor(username, account.twofactor, req.body.twofactor);
if (delta === null) {
return dynamicResponse(req, res, 403, 'message', {
'title': 'Forbidden',
'message': 'Incorrect account credentials',
'redirect': '/changepassword.html'
});
}
}
//change the password
await Promise.all([
Accounts.changePassword(username, newPassword),

@ -2,7 +2,8 @@
const bcrypt = require('bcrypt')
, dynamicResponse = require(__dirname+'/../../lib/misc/dynamic.js')
, { Accounts } = require(__dirname+'/../../db/');
, { Accounts } = require(__dirname+'/../../db/')
, doTwoFactor = require(__dirname+'/../../lib/misc/dotwofactor.js');
module.exports = async (req, res) => {
@ -22,7 +23,7 @@ module.exports = async (req, res) => {
if (!account) {
return dynamicResponse(req, res, 403, 'message', {
'title': 'Forbidden',
'message': 'Incorrect username or password',
'message': 'Incorrect login credentials',
'redirect': failRedirect
});
}
@ -31,21 +32,30 @@ module.exports = async (req, res) => {
const passwordMatch = await bcrypt.compare(password, account.passwordHash);
//if hashes matched
if (passwordMatch === true) {
// add the account to the session and authenticate if password was correct
req.session.user = account._id;
//successful login
await Accounts.updateLastActiveDate(username);
return res.redirect(goto);
if (passwordMatch === false) {
return dynamicResponse(req, res, 403, 'message', {
'title': 'Forbidden',
'message': 'Incorrect login credentials',
'redirect': failRedirect
});
}
if (account.twofactor) {
const delta = await doTwoFactor(username, account.twofactor, req.body.twofactor);
if (delta === null) {
return dynamicResponse(req, res, 403, 'message', {
'title': 'Forbidden',
'message': 'Incorrect login credentials', //better to not tell them, i think
'redirect': failRedirect
});
}
}
return dynamicResponse(req, res, 403, 'message', {
'title': 'Forbidden',
'message': 'Incorrect username or password',
'redirect': failRedirect
});
// add the account to the session and authenticate if password was correct
req.session.user = account._id;
//successful login
await Accounts.updateLastActiveDate(username);
return res.redirect(goto);
};

@ -0,0 +1,51 @@
'use strict';
const redis = require(__dirname+'/../../lib/redis/redis.js')
, dynamicResponse = require(__dirname+'/../../lib/misc/dynamic.js')
, { Accounts } = require(__dirname+'/../../db/')
, doTwoFactor = require(__dirname+'/../../lib/misc/dotwofactor.js');
module.exports = async (req, res) => {
const username = res.locals.user.username.toLowerCase();
// Get the temporary secret from redis and check it exists
const tempSecret = await redis.get(`twofactor_tempsecret:${username}`);
if (!tempSecret || !username) {
return dynamicResponse(req, res, 403, 'message', {
'title': 'Forbidden',
'message': '2FA QR code expired, try again',
'redirect': '/twofactor.html',
});
}
// Validate totp
const delta = await doTwoFactor(username, tempSecret, req.body.twofactor);
// Check if code was valid
if (delta === null) {
return dynamicResponse(req, res, 403, 'message', {
'title': 'Forbidden',
'message': 'Incorrect 2FA code',
'redirect': '/twofactor.html',
});
}
redis.del(`twofactor_tempsecret:${username}`);
// Successfully enabled 2FA
await Accounts.updateTwofactor(username, tempSecret);
// Logout all sessions, 2FA now required
await Promise.all([
req.session.destroy(),
redis.del(`users:${username}`),
redis.deletePattern(`sess:*:${username}`),
]);
return dynamicResponse(req, res, 200, 'message', {
'title': 'Success',
'message': 'Two factor authentication enabled successfully',
'redirect': '/login.html',
});
};

@ -6,6 +6,7 @@ module.exports = {
register: require(__dirname+'/register.js'),
account: require(__dirname+'/account.js'),
sessions: require(__dirname+'/sessions.js'),
setupTwoFactor: require(__dirname+'/twofactor.js'),
myPermissions: require(__dirname+'/mypermissions.js'),
home: require(__dirname+'/home.js'),
login: require(__dirname+'/login.js'),

@ -0,0 +1,54 @@
'use strict';
const redis = require(__dirname+'/../../lib/redis/redis.js')
, { Ratelimits } = require(__dirname+'/../../db/')
, dynamicResponse = require(__dirname+'/../../lib/misc/dynamic.js')
, config = require(__dirname+'/../../lib/misc/config.js')
, OTPAuth = require('otpauth')
, QRCode = require('qrcode');
module.exports = async (req, res, next) => {
if (res.locals.user.twofactor) {
// User already has 2fa enabled
return res.redirect('/account.html');
}
// Ratelimit QR code generation
const username = res.locals.user.username;
const ratelimit = await Ratelimits.incrmentQuota(username, '2fa', 50);
if (ratelimit > 100) {
return dynamicResponse(req, res, 429, 'message', {
'title': 'Ratelimited',
'message': 'Please wait before generating another 2FA QR code.',
});
}
const { meta } = config.get;
let qrCodeText = ''
, secretBase32 = '';
try {
const totp = new OTPAuth.TOTP({
issuer: meta.url || 'jschan',
label: meta.siteName || 'jschan',
algorithm: 'SHA256',
});
const secret = totp.secret;
secretBase32 = secret.base32;
await redis.set(`twofactor_tempsecret:${username}`, secretBase32, 300); //store validation secret temporarily in redis
const qrCodeURL = totp.toString();
qrCodeText = await QRCode.toString(qrCodeURL, { type: 'utf8' });
} catch (err) {
return next(err);
}
res
.set('Cache-Control', 'no-cache')
.render('twofactor', {
csrf: req.csrfToken(),
qrCodeText,
secretBase32,
});
};

267
package-lock.json generated

@ -1,12 +1,12 @@
{
"name": "jschan",
"version": "0.9.4",
"version": "0.10.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "jschan",
"version": "0.9.4",
"version": "0.10.0",
"license": "AGPL-3.0-only",
"dependencies": {
"@fatchan/express-fileupload": "^1.4.2",
@ -44,10 +44,12 @@
"ip6addr": "^0.2.5",
"mongodb": "^4.6.0",
"node-fetch": "^2.6.7",
"otpauth": "^9.0.1",
"path": "^0.12.7",
"pm2": "^5.2.0",
"pug": "^3.0.2",
"pug-runtime": "^3.0.1",
"qrcode": "^1.5.1",
"redlock": "^4.2.0",
"sanitize-html": "^2.7.0",
"saslprep": "^1.0.3",
@ -3032,7 +3034,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"engines": {
"node": ">=6"
}
@ -4079,6 +4080,11 @@
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.2.tgz",
"integrity": "sha512-QV6PMaHTCNmKSeP6QoXhVTw9snc9VD8MulTT0Bd99Pacp4SS1cjcrYPgBPmibqKVtMJJfqC6XvOXgPMEEPH/fg=="
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@ -4269,6 +4275,11 @@
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"node_modules/encode-utf8": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/encode-utf8/-/encode-utf8-1.0.3.tgz",
"integrity": "sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw=="
},
"node_modules/encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
@ -5289,7 +5300,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
@ -8291,6 +8301,14 @@
"verror": "1.10.0"
}
},
"node_modules/jssha": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.0.tgz",
"integrity": "sha512-w9OtT4ALL+fbbwG3gw7erAO0jvS5nfvrukGPMWIAoea359B26ALXGpzy4YJSp9yGnpUvuvOw1nSjSoHDfWSr1w==",
"engines": {
"node": "*"
}
},
"node_modules/jstransformer": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz",
@ -8529,7 +8547,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"dependencies": {
"p-locate": "^4.1.0"
},
@ -9812,6 +9829,17 @@
"node": ">=0.10.0"
}
},
"node_modules/otpauth": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.0.1.tgz",
"integrity": "sha512-gohKMtdAMAkIjyCZo90vgcg5z30b7dItMvq5Yftuzzv/WK7y93vwDW9qPZm9QeBefN1aQXj5z8gJ5Mp1EXLA8w==",
"dependencies": {
"jssha": "~3.3.0"
},
"funding": {
"url": "https://github.com/hectorm/otpauth?sponsor=1"
}
},
"node_modules/over": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/over/-/over-0.0.5.tgz",
@ -9832,7 +9860,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"dependencies": {
"p-try": "^2.0.0"
},
@ -9847,7 +9874,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"dependencies": {
"p-limit": "^2.2.0"
},
@ -9884,7 +9910,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"engines": {
"node": ">=6"
}
@ -10066,7 +10091,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"engines": {
"node": ">=8"
}
@ -10566,6 +10590,14 @@
"node": ">=8.0"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/posix-character-classes": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz",
@ -10929,6 +10961,102 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.1.tgz",
"integrity": "sha512-nS8NJ1Z3md8uTjKtP+SGGhfqmTCs5flU/xR623oI0JX+Wepz9R8UrRVCTBTJm3qGw3rH6jJ6MUHjkDx15cxSSg==",
"dependencies": {
"dijkstrajs": "^1.0.1",
"encode-utf8": "^1.0.3",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/qrcode/node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
},
"node_modules/qrcode/node_modules/which-module": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz",
"integrity": "sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q=="
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz",
@ -16463,8 +16591,7 @@
"camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="
},
"caniuse-lite": {
"version": "1.0.30001343",
@ -17269,6 +17396,11 @@
"integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==",
"dev": true
},
"dijkstrajs": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.2.tgz",
"integrity": "sha512-QV6PMaHTCNmKSeP6QoXhVTw9snc9VD8MulTT0Bd99Pacp4SS1cjcrYPgBPmibqKVtMJJfqC6XvOXgPMEEPH/fg=="
},
"dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@ -17420,6 +17552,11 @@
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"encode-utf8": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/encode-utf8/-/encode-utf8-1.0.3.tgz",
"integrity": "sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw=="
},
"encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
@ -18227,7 +18364,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"requires": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
@ -20517,6 +20653,11 @@
"verror": "1.10.0"
}
},
"jssha": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.0.tgz",
"integrity": "sha512-w9OtT4ALL+fbbwG3gw7erAO0jvS5nfvrukGPMWIAoea359B26ALXGpzy4YJSp9yGnpUvuvOw1nSjSoHDfWSr1w=="
},
"jstransformer": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz",
@ -20702,7 +20843,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"requires": {
"p-locate": "^4.1.0"
}
@ -21715,6 +21855,14 @@
"lcid": "^1.0.0"
}
},
"otpauth": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.0.1.tgz",
"integrity": "sha512-gohKMtdAMAkIjyCZo90vgcg5z30b7dItMvq5Yftuzzv/WK7y93vwDW9qPZm9QeBefN1aQXj5z8gJ5Mp1EXLA8w==",
"requires": {
"jssha": "~3.3.0"
}
},
"over": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/over/-/over-0.0.5.tgz",
@ -21729,7 +21877,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"requires": {
"p-try": "^2.0.0"
}
@ -21738,7 +21885,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"requires": {
"p-limit": "^2.2.0"
}
@ -21762,8 +21908,7 @@
"p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="
},
"pac-proxy-agent": {
"version": "5.0.0",
@ -21906,8 +22051,7 @@
"path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="
},
"path-is-absolute": {
"version": "1.0.1",
@ -22274,6 +22418,11 @@
}
}
},
"pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="
},
"posix-character-classes": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz",
@ -22588,6 +22737,86 @@
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
},
"qrcode": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.1.tgz",
"integrity": "sha512-nS8NJ1Z3md8uTjKtP+SGGhfqmTCs5flU/xR623oI0JX+Wepz9R8UrRVCTBTJm3qGw3rH6jJ6MUHjkDx15cxSSg==",
"requires": {
"dijkstrajs": "^1.0.1",
"encode-utf8": "^1.0.3",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"dependencies": {
"cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"requires": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="
},
"require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
},
"which-module": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz",
"integrity": "sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q=="
},
"wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"requires": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
}
},
"y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
},
"yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"requires": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
}
},
"yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"requires": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
}
}
}
},
"qs": {
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz",

@ -1,7 +1,7 @@
{
"name": "jschan",
"version": "0.9.4",
"migrateVersion": "0.9.1",
"version": "0.10.0",
"migrateVersion": "0.10.0",
"description": "",
"main": "server.js",
"dependencies": {
@ -40,10 +40,12 @@
"ip6addr": "^0.2.5",
"mongodb": "^4.6.0",
"node-fetch": "^2.6.7",
"otpauth": "^9.0.1",
"path": "^0.12.7",
"pm2": "^5.2.0",
"pug": "^3.0.2",
"pug-runtime": "^3.0.1",
"qrcode": "^1.5.1",
"redlock": "^4.2.0",
"sanitize-html": "^2.7.0",
"saslprep": "^1.0.3",

@ -72,11 +72,15 @@ module.exports = {
if (config.get.inactiveAccountAction === 2) {
debugLogs && console.log(`Deleting ${inactiveAccounts.length} inactive accounts`);
const inactiveUsernames = inactiveAccounts.map(acc => acc._id);
accountsPromise = Accounts.deleteMany(inactiveUsernames);
if (inactiveUsernames.length > 0) {
accountsPromise = Accounts.deleteMany(inactiveUsernames);
}
} else{
debugLogs && console.log(`Removing staff positions from ${inactiveWithBoards.length} inactive accounts`);
const inactiveUsernames = inactiveWithBoards.map(acc => acc._id);
accountsPromise = Accounts.clearStaffAndOwnedBoards(inactiveUsernames);
if (inactiveUsernames.length > 0) {
accountsPromise = Accounts.clearStaffAndOwnedBoards(inactiveUsernames);
}
}
//execute promises

@ -1,5 +1,6 @@
describe('run integration tests', () => {
require('./setup.js')();
require('./twofactor.js')();
require('./posting.js')();
require('./global.js')();
require('./actions.js')();

@ -93,6 +93,7 @@ module.exports = () => describe('login and create test board', () => {
hot_threads_limit: '5',
hot_threads_threshold: '10',
hot_threads_max_age: '2629800000',
captcha_options_font: 'default',
captcha_options_type: 'text',
captcha_options_generate_limit: '250',
captcha_options_text_font: 'default',

@ -0,0 +1,220 @@
const fetch = require('node-fetch')
, OTPAuth = require('otpauth')
, redis = require(__dirname+'/../lib/redis/redis.js');
module.exports = () => describe('test two factor authentication', () => {
let sessionCookie
, csrfToken
, twofactorSecret
, twofactorToken;
test('login as admin without 2fa', async () => {
const params = new URLSearchParams();
params.append('username', 'admin');
params.append('password', process.env.TEST_ADMIN_PASSWORD);
const response = await fetch('http://localhost/forms/login', {
headers: {
'x-using-xhr': 'true',
},
method: 'POST',
body: params,
redirect: 'manual',
});
const rawHeaders = response.headers.raw();
expect(rawHeaders['set-cookie']).toBeDefined();
expect(rawHeaders['set-cookie'][0]).toMatch(/^connect\.sid/);
sessionCookie = rawHeaders['set-cookie'][0];
csrfToken = await fetch('http://localhost/csrf.json', { headers: { 'cookie': sessionCookie }})
.then(res => res.json())
.then(json => json.token);
});
test('check if twofactor setup link is on account page', async () => {
const response = await fetch('http://localhost/account.html', {
headers: {
'cookie': sessionCookie,
},
});
const text = await response.text();
expect(text).toContain('<a href="/twofactor.html"');
});
test('load twofactor page and fetch secret', async () => {
const response = await fetch('http://localhost/twofactor.html', {
headers: {
'cookie': sessionCookie,
},
});
const text = await response.text();
const indexOfSecret = text.indexOf('<span class="code">');
twofactorSecret = text.substring(indexOfSecret+19, indexOfSecret+19+32);
twofactorToken = OTPAuth.TOTP.generate({
algorithm: 'SHA256',
secret: OTPAuth.Secret.fromBase32(twofactorSecret)
});
expect(twofactorToken).toBeDefined();
});
test('submit 2fa form to enable 2fa', async () => {
const params = new URLSearchParams();
params.append('_csrf', csrfToken);
params.append('twofactor', twofactorToken);
const response = await fetch('http://localhost/forms/twofactor', {
headers: {
'x-using-xhr': 'true',
'cookie': sessionCookie,
},
method: 'POST',
body: params,
redirect: 'manual',
});
expect(response.status).toBe(200);
});
test('submit 2fa form again (should be rejected)', async () => {
const params = new URLSearchParams();
params.append('_csrf', csrfToken);
params.append('twofactor', twofactorToken);
const response = await fetch('http://localhost/forms/twofactor', {
headers: {
'x-using-xhr': 'true',
'cookie': sessionCookie,
},
method: 'POST',
body: params,
redirect: 'manual',
});
expect(response.status).toBe(403);
});
test('login as admin with missing 2fa', async () => {
const params = new URLSearchParams();
params.append('username', 'admin');
params.append('password', process.env.TEST_ADMIN_PASSWORD);
params.append('twofactor', '');
const response = await fetch('http://localhost/forms/login', {
headers: {
'x-using-xhr': 'true',
'cookie': sessionCookie,
},
method: 'POST',
body: params,
redirect: 'manual',
});
expect(response.status).toBe(403);
});
test('login as admin with incorrect 2fa', async () => {
const params = new URLSearchParams();
params.append('username', 'admin');
params.append('password', process.env.TEST_ADMIN_PASSWORD);
params.append('twofactor', '000000');
const response = await fetch('http://localhost/forms/login', {
headers: {
'x-using-xhr': 'true',
'cookie': sessionCookie,
},
method: 'POST',
body: params,
redirect: 'manual',
});
expect(response.status).toBe(403);
});
test('login as admin with correct 2fa', async () => {
await redis.deletePattern('*'); //delete used 2fa code before trying to login again (same token at this point)
const params = new URLSearchParams();
params.append('username', 'admin');
params.append('password', process.env.TEST_ADMIN_PASSWORD);
params.append('twofactor', twofactorToken);
const response = await fetch('http://localhost/forms/login', {
headers: {
'x-using-xhr': 'true',
'cookie': sessionCookie,
},
method: 'POST',
body: params,
redirect: 'manual',
});
const rawHeaders = response.headers.raw();
expect(rawHeaders['set-cookie']).toBeDefined();
expect(rawHeaders['set-cookie'][0]).toMatch(/^connect\.sid/);
});
test('login as admin again with correct 2fa (rejected for reuse)', async () => {
const params = new URLSearchParams();
params.append('username', 'admin');
params.append('password', process.env.TEST_ADMIN_PASSWORD);
params.append('twofactor', twofactorToken);
const response = await fetch('http://localhost/forms/login', {
headers: {
'x-using-xhr': 'true',
'cookie': sessionCookie,
},
method: 'POST',
body: params,
redirect: 'manual',
});
expect(response.status).toBe(403);
});
test('change admin password with missing 2fa', async () => {
const params = new URLSearchParams();
params.append('username', 'admin');
params.append('password', process.env.TEST_ADMIN_PASSWORD);
params.append('newpassword', process.env.TEST_ADMIN_PASSWORD);
params.append('newpasswordconfirm', process.env.TEST_ADMIN_PASSWORD);
params.append('twofactor', '');
const response = await fetch('http://localhost/forms/changepassword', {
headers: {
'x-using-xhr': 'true',
'cookie': sessionCookie,
},
method: 'POST',
body: params,
redirect: 'manual',
});
expect(response.status).toBe(403);
});
test('change admin password with incorrect 2fa', async () => {
const params = new URLSearchParams();
params.append('username', 'admin');
params.append('password', process.env.TEST_ADMIN_PASSWORD);
params.append('newpassword', process.env.TEST_ADMIN_PASSWORD);
params.append('newpasswordconfirm', process.env.TEST_ADMIN_PASSWORD);
params.append('twofactor', '000000');
const response = await fetch('http://localhost/forms/changepassword', {
headers: {
'x-using-xhr': 'true',
'cookie': sessionCookie,
},
method: 'POST',
body: params,
redirect: 'manual',
});
expect(response.status).toBe(403);
});
test('change admin password with correct 2fa', async () => {
await redis.deletePattern('*'); //delete used 2fa code before trying to login again (same token at this point)
const params = new URLSearchParams();
params.append('username', 'admin');
params.append('password', process.env.TEST_ADMIN_PASSWORD);
params.append('newpassword', process.env.TEST_ADMIN_PASSWORD);
params.append('newpasswordconfirm', process.env.TEST_ADMIN_PASSWORD);
params.append('twofactor', twofactorToken);
const response = await fetch('http://localhost/forms/changepassword', {
headers: {
'x-using-xhr': 'true',
'cookie': sessionCookie,
},
method: 'POST',
body: params,
redirect: 'manual',
});
expect(response.status).toBe(200);
});
});

@ -10,6 +10,8 @@ block content
hr(size=1)
h4.no-m-p General:
ul
if !user.twofactor
li.bold: a(href='/twofactor.html') Setup 2FA (TOTP)
if permissions.get(Permissions.CREATE_BOARD)
li: a(href='/create.html') Create a board
if permissions.get(Permissions.CREATE_ACCOUNT)

@ -19,6 +19,9 @@ block content
.row
.label Confirm New Password
input(type='password', name='newpasswordconfirm', maxlength='100' required)
.row
.label 2FA Code
input(type='number' name='twofactor' placeholder='if enabled')
if captchaOptions.type === 'text'
include ../includes/captchasidelabel.pug
else

@ -14,6 +14,9 @@ block content
.row
.label Password
input(type='password', name='password', maxlength='100' required)
.row
.label 2FA Code
input(type='number' name='twofactor' placeholder='if enabled')
input(type='submit', value='Submit')
p: a(href='/register.html') Register
p: a(href='/changepassword.html') Change Password

@ -0,0 +1,26 @@
extends ../layout.pug
block head
title Setup 2FA
block content
.board-header
h1.board-title Two Factor Authentication Setup
.form-wrapper.flex-center.mv-10
form.form-post.nogrow(action=`/forms/twofactor` method='POST' enctype='application/x-www-form-urlencoded')
input(type='hidden' name='_csrf' value=csrf)
h4.mv-5 Scan the QR Code in an authenticator app, and submit the code:
.row
span.code.hljs.twofactor.noselect #{qrCodeText}
.row
h4.no-m-p No camera? Use this secret in your authenticator app instead:
.row
span.code #{secretBase32}
.row
h4.mv-5.ban Enabling 2FA will invalidate all your existing sessions and you will have to login again.
.row
.label 2FA Code
input(type='number' name='twofactor' placeholder='6 digits')
.row
input(type='submit', value='Submit')
Loading…
Cancel
Save