mirror of https://gitgud.io/fatchan/jschan.git
v0.10.0 See merge request fatchan/jschan!275merge-requests/346/merge v0.10.0
commit
f26632f2a3
29 changed files with 14123 additions and 11029 deletions
@ -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); |
||||
} |
||||
|
||||
}, |
||||
|
||||
}; |
@ -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; |
||||
}; |
@ -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'); |
||||
}; |
@ -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', |
||||
}); |
||||
|
||||
}; |
@ -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, |
||||
}); |
||||
|
||||
}; |
File diff suppressed because it is too large
Load Diff
@ -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); |
||||
});
|
||||
|
||||
}); |
@ -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…
Reference in new issue