Initial commit of 2FA for accounts, TOTP-based

merge-requests/341/head
Thomas Lynch 2 years ago
parent d3507e6ae3
commit 4d86406483
  1. 1
      README.md
  2. 3
      controllers/forms.js
  3. 1
      controllers/forms/index.js
  4. 3
      controllers/forms/login.js
  5. 38
      controllers/forms/twofactor.js
  6. 3
      controllers/pages.js
  7. 13
      db/accounts.js
  8. 11
      gulp/res/css/style.css
  9. 1
      lib/middleware/permission/sessionrefresh.js
  10. 12
      migrations/0.10.0.js
  11. 21
      models/forms/changepassword.js
  12. 45
      models/forms/login.js
  13. 55
      models/forms/twofactor.js
  14. 1
      models/pages/index.js
  15. 43
      models/pages/twofactor.js
  16. 264
      package-lock.json
  17. 6
      package.json
  18. 2
      views/pages/account.pug
  19. 3
      views/pages/changepassword.pug
  20. 3
      views/pages/login.pug
  21. 20
      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);

@ -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: existsBody(res.locals.user.twofactor), 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' },
]);
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,7 @@ module.exports = async (req, res, next) => {
'permissions': account.permissions.toString('base64'),
'staffBoards': account.staffBoards,
'ownedBoards': account.ownedBoards,
'twofactor': account.twofactor,
};
req.session.expires = new Date(Date.now() + (3 * DAY));
cache.set(`users:${req.session.user}`, res.locals.user, 3600);

@ -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')
, speakeasy = require('speakeasy')
, { 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,27 @@ 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 verified = speakeasy.totp.verify({
secret: account.twofactor,
encoding: 'base32',
token: req.body.twofactor,
window: 6
});
if (verified === false) {
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/')
, speakeasy = require('speakeasy');
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,35 @@ 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 verified = speakeasy.totp.verify({
secret: account.twofactor,
encoding: 'base32',
token: req.body.twofactor,
window: 6
});
if (verified === false) {
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,55 @@
'use strict';
const redis = require(__dirname+'/../../lib/redis/redis.js')
, dynamicResponse = require(__dirname+'/../../lib/misc/dynamic.js')
, { Accounts } = require(__dirname+'/../../db/')
, speakeasy = require('speakeasy');
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:${username}`);
if (!tempSecret) {
return dynamicResponse(req, res, 403, 'message', {
'title': 'Forbidden',
'message': '2FA QR code expired, try again',
'redirect': '/twofactor.html',
});
}
// bcrypt compare input to saved hash
const verified = await speakeasy.totp.verify({
secret: tempSecret,
encoding: 'base32',
token: req.body.twofactor,
});
//if hashes matched
if (verified === false) {
return dynamicResponse(req, res, 403, 'message', {
'title': 'Forbidden',
'message': 'Incorrect 2FA code',
'redirect': '/twofactor.html',
});
}
redis.del(`twofactor:${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,43 @@
'use strict';
const redis = require(__dirname+'/../../lib/redis/redis.js')
, { Ratelimits } = require(__dirname+'/../../db/')
, dynamicResponse = require(__dirname+'/../../lib/misc/dynamic.js')
, speakeasy = require('speakeasy')
, 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 (false && ratelimit > 100) {
return dynamicResponse(req, res, 429, 'message', {
'title': 'Ratelimited',
'message': 'Please wait before generating another 2FA QR code.',
});
}
let qrCodeText = '';
try {
const secret = speakeasy.generateSecret();
const secretBase32 = secret.base32;
await redis.set(`twofactor:${username}`, secretBase32, 300); //store validation secret temporarily in redis
qrCodeText = await QRCode.toString(secret.otpauth_url, { type: 'utf8' });
} catch (err) {
return next(err);
}
res
.set('Cache-Control', 'private, max-age=5')
.render('twofactor', {
csrf: req.csrfToken(),
qrCodeText,
});
};

264
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",
@ -48,12 +48,14 @@
"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",
"semver": "^7.3.7",
"socket.io": "^4.5.0",
"socks-proxy-agent": "^6.2.0",
"speakeasy": "^2.0.0",
"uid-safe": "^2.1.5",
"unix-crypt-td-js": "^1.1.4"
},
@ -2598,6 +2600,11 @@
"node": ">=0.10.0"
}
},
"node_modules/base32.js": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz",
"integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ=="
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -3032,7 +3039,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 +4085,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 +4280,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 +5305,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"
@ -8529,7 +8544,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"
},
@ -9832,7 +9846,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 +9860,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 +9896,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 +10077,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 +10576,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 +10947,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",
@ -12397,6 +12511,17 @@
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.11.tgz",
"integrity": "sha512-Ctl2BrFiM0X3MANYgj3CkygxhRmr9mi6xhejbdO960nF6EDJApTYpn0BQnDKlnNBULKiCN1n3w9EBkHK8ZWg+g=="
},
"node_modules/speakeasy": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz",
"integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==",
"dependencies": {
"base32.js": "0.0.1"
},
"engines": {
"node": ">= 0.10.0"
}
},
"node_modules/split-string": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
@ -16147,6 +16272,11 @@
}
}
},
"base32.js": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz",
"integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ=="
},
"base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -16463,8 +16593,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 +17398,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 +17554,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 +18366,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"
@ -20702,7 +20840,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"
}
@ -21729,7 +21866,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 +21874,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 +21897,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 +22040,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 +22407,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 +22726,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",
@ -23712,6 +23930,14 @@
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.11.tgz",
"integrity": "sha512-Ctl2BrFiM0X3MANYgj3CkygxhRmr9mi6xhejbdO960nF6EDJApTYpn0BQnDKlnNBULKiCN1n3w9EBkHK8ZWg+g=="
},
"speakeasy": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz",
"integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==",
"requires": {
"base32.js": "0.0.1"
}
},
"split-string": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.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": {
@ -44,12 +44,14 @@
"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",
"semver": "^7.3.7",
"socket.io": "^4.5.0",
"socks-proxy-agent": "^6.2.0",
"speakeasy": "^2.0.0",
"uid-safe": "^2.1.5",
"unix-crypt-td-js": "^1.1.4"
},

@ -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='6 digits')
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 (optional)
input(type='number' name='twofactor' placeholder='6 digits')
input(type='submit', value='Submit')
p: a(href='/register.html') Register
p: a(href='/changepassword.html') Change Password

@ -0,0 +1,20 @@
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 #{qrCodeText}
.row
.label 2FA Code
input(type='number' name='twofactor' placeholder='6 digits')
.row
input(type='submit', value='Submit')
Loading…
Cancel
Save