Merge branch 'develop' into 'master'

v0.10.0

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

@ -1,3 +1,7 @@
### 0.10.0
- Add account two factor authenticaiton (TOTP).
- Update dependencies.
### 0.9.4
- Ability to add internal notes to bans. These are only seen by staff and not shown to the banned user.
- Wordfilter auto bans will set an internal note with which wordfilter was triggered.

@ -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,
});
};

24565
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,25 +1,25 @@
{
"name": "jschan",
"version": "0.9.4",
"migrateVersion": "0.9.1",
"version": "0.10.0",
"migrateVersion": "0.10.0",
"description": "",
"main": "server.js",
"dependencies": {
"@fatchan/express-fileupload": "^1.4.2",
"@fatchan/gm": "^1.3.2",
"@socket.io/redis-adapter": "^7.2.0",
"bcrypt": "^5.0.1",
"bcrypt": "^5.1.0",
"big-bitfield": "^1.2.1",
"bull": "^3.29.3",
"cache-pug-templates": "^2.0.3",
"connect-redis": "^6.1.3",
"cookie-parser": "^1.4.6",
"csurf": "^1.11.0",
"del": "^6.0.0",
"del": "^6.1.1",
"dnsbl": "^3.2.0",
"express": "^4.18.1",
"express": "^4.18.2",
"express-session": "^1.17.3",
"file-type": "^16.5.3",
"file-type": "^16.5.4",
"fluent-ffmpeg": "^2.1.2",
"form-data": "^4.0.0",
"fs": "0.0.1-security",
@ -32,30 +32,32 @@
"gulp-real-favicon": "^0.3.2",
"gulp-replace": "^1.1.3",
"gulp-uglify-es": "^3.0.0",
"highlight.js": "^11.5.1",
"highlight.js": "^11.6.0",
"i18n-iso-countries": "^6.8.0",
"iconv-lite": "^0.6.3",
"imghash": "^0.0.9",
"ioredis": "^4.28.5",
"ip6addr": "^0.2.5",
"mongodb": "^4.6.0",
"mongodb": "^4.11.0",
"node-fetch": "^2.6.7",
"otpauth": "^9.0.1",
"path": "^0.12.7",
"pm2": "^5.2.0",
"pm2": "^5.2.2",
"pug": "^3.0.2",
"pug-runtime": "^3.0.1",
"qrcode": "^1.5.1",
"redlock": "^4.2.0",
"sanitize-html": "^2.7.0",
"sanitize-html": "^2.7.3",
"saslprep": "^1.0.3",
"semver": "^7.3.7",
"socket.io": "^4.5.0",
"socks-proxy-agent": "^6.2.0",
"semver": "^7.3.8",
"socket.io": "^4.5.3",
"socks-proxy-agent": "^6.2.1",
"uid-safe": "^2.1.5",
"unix-crypt-td-js": "^1.1.4"
},
"devDependencies": {
"eslint": "^8.15.0",
"eslint-plugin-jest": "^26.2.2",
"eslint": "^8.27.0",
"eslint-plugin-jest": "^26.9.0",
"jest": "^27.5.1",
"jest-cli": "^27.5.1",
"jest-junit": "^13.2.0"

@ -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