Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions template/app/controllers/web/2fa/index.js

This file was deleted.

6 changes: 3 additions & 3 deletions template/app/controllers/web/admin/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@ async function update(ctx) {
body[config.passport.fields.givenName];
user[config.passport.fields.familyName] =
body[config.passport.fields.familyName];
user[config.passport.fields.twoFactorEnabled] =
body[config.passport.fields.twoFactorEnabled];
user[config.passport.fields.otpEnabled] =
body[config.passport.fields.otpEnabled];
user.email = body.email;
user.group = body.group;

if (boolean(!body[config.passport.fields.twoFactorEnabled]))
if (boolean(!body[config.passport.fields.otpEnabled]))
user[config.userFields.pendingRecovery] = false;

await user.save();
Expand Down
33 changes: 21 additions & 12 deletions template/app/controllers/web/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ async function homeOrDashboard(ctx) {
}

async function login(ctx, next) {
// eslint-disable-next-line complexity
await passport.authenticate('local', async (err, user, info) => {
if (err) throw err;

Expand All @@ -113,14 +114,14 @@ async function login(ctx, next) {
delete ctx.session.returnTo;
}

let greeting = 'Good morning';
if (moment().format('HH') >= 12 && moment().format('HH') <= 17)
greeting = 'Good afternoon';
else if (moment().format('HH') >= 17) greeting = 'Good evening';

if (user) {
await ctx.login(user);

let greeting = 'Good morning';
if (moment().format('HH') >= 12 && moment().format('HH') <= 17)
greeting = 'Good afternoon';
else if (moment().format('HH') >= 17) greeting = 'Good evening';

ctx.flash('custom', {
title: `${ctx.request.t('Hello')} ${ctx.state.emoji('wave')}`,
text: user[config.passport.fields.givenName]
Expand All @@ -136,14 +137,14 @@ async function login(ctx, next) {
const uri = authenticator.keyuri(
user.email,
'lad.sh',
user[config.passport.fields.twoFactorToken]
user[config.passport.fields.otpToken]
);

ctx.state.user.qrcode = await qrcode.toDataURL(uri);
await ctx.state.user.save();

if (user[config.passport.fields.twoFactorEnabled] && !ctx.session.otp)
redirectTo = `/${ctx.locale}/2fa/otp/login`;
if (user[config.passport.fields.otpEnabled] && !ctx.session.otp)
redirectTo = `/${ctx.locale}/otp/login`;

if (ctx.accepts('json')) {
ctx.body = { redirectTo };
Expand All @@ -154,6 +155,11 @@ async function login(ctx, next) {
return;
}

let greeting = 'Good morning';
if (moment().format('HH') >= 12 && moment().format('HH') <= 17)
greeting = 'Good afternoon';
else if (moment().format('HH') >= 17) greeting = 'Good evening';

ctx.flash('custom', {
title: `${ctx.request.t('Hello')} ${ctx.state.emoji('wave')}`,
text: user[config.passport.fields.givenName]
Expand Down Expand Up @@ -199,7 +205,7 @@ async function recoveryKey(ctx) {

ctx.state.redirectTo = redirectTo;

let recoveryKeys = ctx.state.user[config.userFields.twoFactorRecoveryKeys];
let recoveryKeys = ctx.state.user[config.userFields.otpRecoveryKeys];

// ensure recovery matches user list of keys
if (
Expand All @@ -216,13 +222,13 @@ async function recoveryKey(ctx) {
recoveryKeys = recoveryKeys.filter(
key => key !== ctx.request.body.recovery_passcode
);
ctx.state.user[config.userFields.twoFactorRecoveryKeys] = recoveryKeys;
ctx.state.user[config.userFields.otpRecoveryKeys] = recoveryKeys;
await ctx.state.user.save();

ctx.session.otp = 'totp-recovery';

// send the user a success message
const message = ctx.translate('TWO_FACTOR_RECOVERY_SUCCESS');
const message = ctx.translate('OTP_RECOVERY_SUCCESS');

if (ctx.accepts('html')) {
ctx.flash('success', message);
Expand All @@ -243,7 +249,10 @@ async function register(ctx) {

// register the user
const count = await Users.countDocuments({ group: 'admin' });
const query = { email: body.email, group: count === 0 ? 'admin' : 'user' };
const query = {
email: body.email,
group: count === 0 ? 'admin' : 'user'
};
query[config.userFields.hasVerifiedEmail] = false;
query[config.userFields.hasSetPassword] = true;
const user = await Users.register(query, body.password);
Expand Down
4 changes: 2 additions & 2 deletions template/app/controllers/web/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const admin = require('./admin');
const auth = require('./auth');
const myAccount = require('./my-account');
const support = require('./support');
const twoFactor = require('./2fa');
const otp = require('./otp');

function breadcrumbs(ctx, next) {
// return early if its not a pure path (e.g. ignore static assets)
Expand All @@ -28,4 +28,4 @@ function breadcrumbs(ctx, next) {
return next();
}

module.exports = { support, auth, admin, myAccount, breadcrumbs, twoFactor };
module.exports = { support, auth, admin, myAccount, breadcrumbs, otp };
77 changes: 3 additions & 74 deletions template/app/controllers/web/my-account.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
const Boom = require('@hapi/boom');
const cryptoRandomString = require('crypto-random-string');
const humanize = require('humanize-string');
const isSANB = require('is-string-and-not-blank');
const qrcode = require('qrcode');
const { authenticator } = require('otplib');
const { boolean } = require('boolean');

const config = require('../../../config');
Expand Down Expand Up @@ -78,86 +75,18 @@ async function resetAPIToken(ctx) {
else ctx.body = { reloadPage: true };
}

async function security(ctx) {
if (!ctx.state.user[config.passport.fields.twoFactorEnabled]) {
ctx.state.user[
config.passport.fields.twoFactorToken
] = authenticator.generateSecret();

// generate 2fa recovery keys list used for fallback
const recoveryKeys = new Array(16)
.fill()
.map(() => cryptoRandomString({ length: 10, characters: '1234567890' }));

ctx.state.user[config.userFields.twoFactorRecoveryKeys] = recoveryKeys;
ctx.state.user = await ctx.state.user.save();
ctx.state.twoFactorTokenURI = authenticator.keyuri(
ctx.state.user.email,
process.env.WEB_HOST,
ctx.state.user[config.passport.fields.twoFactorToken]
);
ctx.state.qrcode = await qrcode.toDataURL(ctx.state.twoFactorTokenURI);
}

await ctx.render('my-account/security');
}

async function recoveryKeys(ctx) {
const twoFactorRecoveryKeys =
ctx.state.user[config.userFields.twoFactorRecoveryKeys];
const otpRecoveryKeys = ctx.state.user[config.userFields.otpRecoveryKeys];

ctx.attachment('recovery-keys.txt');
ctx.body = twoFactorRecoveryKeys
ctx.body = otpRecoveryKeys
.toString()
.replace(/,/g, '\n')
.replace(/"/g, '');
}

async function setup2fa(ctx) {
if (ctx.method === 'DELETE') {
ctx.state.user[config.passport.fields.twoFactorEnabled] = false;
} else if (
ctx.method === 'POST' &&
ctx.state.user[config.passport.fields.twoFactorToken]
) {
const isValid = authenticator.verify({
token: ctx.request.body.token,
secret: ctx.state.user[config.passport.fields.twoFactorToken]
});

if (!isValid)
return ctx.throw(Boom.badRequest(ctx.translate('INVALID_OTP_PASSCODE')));

ctx.state.user[config.passport.fields.twoFactorEnabled] = true;
} else {
return ctx.throw(Boom.badRequest('Invalid method'));
}

await ctx.state.user.save();

ctx.session.otp = 'otp-setup';

ctx.flash('custom', {
title: ctx.request.t('Success'),
text: ctx.translate('REQUEST_OK'),
type: 'success',
toast: true,
showConfirmButton: false,
timer: 3000,
position: 'top'
});

if (ctx.accepts('json')) {
ctx.body = { reloadPage: true };
} else {
ctx.redirect('back');
}
}

module.exports = {
update,
recoveryKeys,
resetAPIToken,
security,
setup2fa
resetAPIToken
};
Original file line number Diff line number Diff line change
@@ -1,12 +1,131 @@
const cryptoRandomString = require('crypto-random-string');
const isSANB = require('is-string-and-not-blank');
const qrcode = require('qrcode');
const Boom = require('@hapi/boom');
const bull = require('../../../bull');
const config = require('../../../config');
const { authenticator } = require('otplib');
const { boolean } = require('boolean');
const bull = require('../../../../bull');
const isSANB = require('is-string-and-not-blank');
const config = require('../../../../config');
const { Inquiries } = require('../../../models');
const { Inquiries } = require('../../models');

async function keys(ctx) {
const { body } = ctx.request;

if (!isSANB(body.password))
throw Boom.badRequest(ctx.translate('INVALID_PASSWORD'));

const { user } = await ctx.state.user.authenticate(body.password);
if (!user) throw Boom.badRequest(ctx.translate('INVALID_PASSWORD'));

const redirectTo = `/${ctx.locale}/otp/setup`;
const message = ctx.translate('PASSWORD_CONFIRM_SUCCESS');
if (ctx.accepts('html')) {
ctx.flash('success', message);
ctx.redirect(redirectTo);
} else {
ctx.body = {
message,
redirectTo
};
}
}

async function renderKeys(ctx) {
ctx.state.user[
config.passport.fields.otpToken
] = authenticator.generateSecret();

// generate otp recovery keys list used for fallback
const recoveryKeys = new Array(16)
.fill()
.map(() => cryptoRandomString({ length: 10, characters: '1234567890' }));

ctx.state.user[config.userFields.otpRecoveryKeys] = recoveryKeys;
ctx.state.user = await ctx.state.user.save();

await ctx.render('otp/keys');
}

async function renderSetup(ctx) {
ctx.state.otpTokenURI = authenticator.keyuri(
ctx.state.user.email,
process.env.WEB_HOST,
ctx.state.user[config.passport.fields.otpToken]
);
ctx.state.qrcode = await qrcode.toDataURL(ctx.state.otpTokenURI);

await ctx.render('otp/setup');
}

async function disable(ctx) {
const { body } = ctx.request;

const redirectTo = `/${ctx.locale}/my-account/security`;

if (!isSANB(body.password))
throw Boom.badRequest(ctx.translate('INVALID_PASSWORD'));

const { user } = await ctx.state.user.authenticate(body.password);
if (!user) throw Boom.badRequest(ctx.translate('INVALID_PASSWORD'));

ctx.state.user[config.passport.fields.otpEnabled] = false;
await ctx.state.user.save();

ctx.flash('custom', {
title: ctx.request.t('Success'),
text: ctx.translate('REQUEST_OK'),
type: 'success',
toast: true,
showConfirmButton: false,
timer: 3000,
position: 'top'
});

if (ctx.accepts('html')) ctx.redirect(redirectTo);
else ctx.body = { redirectTo };
}

async function setup(ctx) {
const redirectTo = `/${ctx.locale}/my-account/security`;
if (ctx.method === 'DELETE') {
ctx.state.user[config.passport.fields.otpEnabled] = false;
} else if (
ctx.method === 'POST' &&
ctx.state.user[config.passport.fields.otpToken]
) {
const isValid = authenticator.verify({
token: ctx.request.body.token,
secret: ctx.state.user[config.passport.fields.otpToken]
});

if (!isValid)
return ctx.throw(Boom.badRequest(ctx.translate('INVALID_OTP_PASSCODE')));

ctx.state.user[config.passport.fields.otpEnabled] = true;
} else {
return ctx.throw(Boom.badRequest('Invalid method'));
}

await ctx.state.user.save();

ctx.session.otp = 'otp-setup';

ctx.flash('custom', {
title: ctx.request.t('Success'),
text: ctx.translate('REQUEST_OK'),
type: 'success',
toast: true,
showConfirmButton: false,
timer: 3000,
position: 'top'
});

if (ctx.accepts('html')) ctx.redirect(redirectTo);
else ctx.body = { redirectTo };
}

async function recover(ctx) {
let redirectTo = `/${ctx.locale}/2fa/recovery/verify`;
async function recovery(ctx) {
let redirectTo = `/${ctx.locale}/otp/recovery/verify`;

if (ctx.session && ctx.session.returnTo) {
redirectTo = ctx.session.returnTo;
Expand Down Expand Up @@ -158,6 +277,11 @@ async function verify(ctx) {
}

module.exports = {
recover,
disable,
keys,
recovery,
renderKeys,
renderSetup,
setup,
verify
};
Loading