Skip to content

Commit

Permalink
feat: enable 2fa via otp option (#16)
Browse files Browse the repository at this point in the history
feat: enable 2fa via otp option
  • Loading branch information
niftylettuce authored Apr 5, 2020
2 parents 3fd78d9 + dad157a commit b171c9e
Show file tree
Hide file tree
Showing 27 changed files with 38,529 additions and 14 deletions.
1 change: 1 addition & 0 deletions .env.defaults
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ AUTH_GOOGLE_ENABLED=false
AUTH_GITHUB_ENABLED=false
AUTH_LINKEDIN_ENABLED=false
AUTH_INSTAGRAM_ENABLED=false
AUTH_OTP_ENABLED=false
AUTH_STRIPE_ENABLED=false
# your google client ID and secret from:
# https://console.developers.google.com
Expand Down
1 change: 1 addition & 0 deletions .env.schema
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ AUTH_GOOGLE_ENABLED=
AUTH_GITHUB_ENABLED=
AUTH_LINKEDIN_ENABLED=
AUTH_INSTAGRAM_ENABLED=
AUTH_OTP_ENABLED=
AUTH_STRIPE_ENABLED=
# your google client ID and secret from:
# https://console.developers.google.com
Expand Down
1 change: 1 addition & 0 deletions app/controllers/api/v1/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ async function create(ctx) {
const query = { email: body.email };
query[config.userFields.hasVerifiedEmail] = false;
query[config.userFields.hasSetPassword] = true;
query[config.userFields.pendingRecovery] = false;
const user = await Users.register(query, body.password);

// send a verification email
Expand Down
3 changes: 3 additions & 0 deletions app/controllers/web/2fa/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const recovery = require('./recovery');

module.exports = { recovery };
163 changes: 163 additions & 0 deletions app/controllers/web/2fa/recovery.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
const Boom = require('@hapi/boom');
const { boolean } = require('boolean');
const bull = require('../../../../bull');
const isSANB = require('is-string-and-not-blank');
const config = require('../../../../config');
const { Inquiries } = require('../../../models');

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

if (ctx.session && ctx.session.returnTo) {
redirectTo = ctx.session.returnTo;
delete ctx.session.returnTo;
}

ctx.state.redirectTo = redirectTo;

ctx.state.user[config.userFields.pendingRecovery] = true;
await ctx.state.user.save();

try {
ctx.state.user = await ctx.state.user.sendVerificationEmail(ctx);
} catch (err) {
// wrap with try/catch to prevent redirect looping
// (even though the koa redirect loop package will help here)
if (!err.isBoom) return ctx.throw(err);
ctx.logger.warn(err);
if (ctx.accepts('html')) {
ctx.flash('warning', err.message);
ctx.redirect('/login');
} else {
ctx.body = { message: err.message };
}

return;
}

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

// eslint-disable-next-line complexity
async function verify(ctx) {
let redirectTo = `/${ctx.locale}/login`;

if (ctx.session && ctx.session.returnTo) {
redirectTo = ctx.session.returnTo;
delete ctx.session.returnTo;
}

ctx.state.redirectTo = redirectTo;

// allow user to click a button to request a new email after 60 seconds
// after their last attempt to get a verification email
const resend = ctx.method === 'GET' && boolean(ctx.query.resend);

if (
!ctx.state.user[config.userFields.verificationPin] ||
!ctx.state.user[config.userFields.verificationPinExpiresAt] ||
ctx.state.user[config.userFields.verificationPinHasExpired] ||
resend
) {
try {
ctx.state.user = await ctx.state.user.sendVerificationEmail(ctx);
} catch (err) {
// wrap with try/catch to prevent redirect looping
// (even though the koa redirect loop package will help here)
if (!err.isBoom) return ctx.throw(err);
ctx.logger.warn(err);
if (ctx.accepts('html')) {
ctx.flash('warning', err.message);
ctx.redirect(redirectTo);
} else {
ctx.body = { message: err.message };
}

return;
}

const message = ctx.translate(
ctx.state.user[config.userFields.verificationPinHasExpired]
? 'EMAIL_VERIFICATION_EXPIRED'
: 'EMAIL_VERIFICATION_SENT'
);

if (!ctx.accepts('html')) {
ctx.body = { message };
return;
}

ctx.flash('success', message);
}

// if it's a GET request then render the page
if (ctx.method === 'GET' && !isSANB(ctx.query.pin))
return ctx.render('verify');

// if it's a POST request then ensure the user entered the 6 digit pin
// otherwise if it's a GET request then use the ctx.query.pin
let pin = '';
if (ctx.method === 'GET') pin = ctx.query.pin;
else pin = isSANB(ctx.request.body.pin) ? ctx.request.body.pin : '';

// convert to digits only
pin = pin.replace(/\D/g, '');

// ensure pin matches up
if (
!ctx.state.user[config.userFields.verificationPin] ||
pin !== ctx.state.user[config.userFields.verificationPin]
)
return ctx.throw(
Boom.badRequest(ctx.translate('INVALID_VERIFICATION_PIN'))
);

try {
const body = {};
body.email = ctx.state.user.email;
body.message = ctx.translate('SUPPORT_REQUEST_MESSAGE');
body.is_email_only = true;
const inquiry = await Inquiries.create({
...body,
ip: ctx.ip
});

ctx.logger.debug('created inquiry', inquiry);

const job = await bull.add('email', {
template: 'inquiry',
message: {
to: ctx.state.user.email,
cc: config.email.message.from
},
locals: {
locale: ctx.locale,
inquiry
}
});

ctx.logger.info('added job', bull.getMeta({ job }));

const message = ctx.translate('PENDING_RECOVERY_VERIFICATION_SUCCESS');
if (ctx.accepts('html')) {
ctx.flash('success', message);
ctx.redirect(redirectTo);
} else {
ctx.body = { message, redirectTo };
}
} catch (err) {
ctx.logger.error(err);
throw Boom.badRequest(ctx.translate('SUPPORT_REQUEST_ERROR'));
}

ctx.logout();
}

module.exports = {
recover,
verify
};
6 changes: 6 additions & 0 deletions app/controllers/web/admin/users.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const paginate = require('koa-ctx-paginate');
const { boolean } = require('boolean');

const { Users } = require('../../../models');
const config = require('../../../../config');
Expand Down Expand Up @@ -39,9 +40,14 @@ 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.email = body.email;
user.group = body.group;

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

await user.save();

if (user.id === ctx.state.user.id) await ctx.login(user);
Expand Down
113 changes: 109 additions & 4 deletions app/controllers/web/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ const moment = require('moment');
const sanitizeHtml = require('sanitize-html');
const validator = require('validator');
const { boolean } = require('boolean');
const { authenticator } = require('otplib');
const qrcode = require('qrcode');

const bull = require('../../../bull');
const Users = require('../../models/user');
Expand All @@ -20,6 +22,7 @@ const sanitize = str =>

function logout(ctx) {
if (!ctx.isAuthenticated()) return ctx.redirect(`/${ctx.locale}`);
if (ctx.session.otp && !ctx.session.otp_remember_me) delete ctx.session.otp;
ctx.logout();
ctx.flash('custom', {
title: ctx.request.t('Success'),
Expand Down Expand Up @@ -140,10 +143,45 @@ async function login(ctx, next) {
delete ctx.session.returnTo;
}

try {
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);
} catch (err_) {
throw err_;

ctx.flash('custom', {
title: `${ctx.request.t('Hello')} ${ctx.state.emoji('wave')}`,
text: user[config.userFields.givenName]
? `${greeting} ${user[config.userFields.givenName]}`
: greeting,
type: 'success',
toast: true,
showConfirmButton: false,
timer: 3000,
position: 'top'
});

const uri = authenticator.keyuri(
user.email,
'lad.sh',
user[config.passport.fields.twoFactorToken]
);

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 (ctx.accepts('json')) {
ctx.body = { redirectTo };
} else {
ctx.redirect(redirectTo);
}

return;
}

ctx.flash('custom', {
Expand All @@ -161,6 +199,67 @@ async function login(ctx, next) {
})(ctx, next);
}

async function loginOtp(ctx, next) {
await passport.authenticate('otp', (err, user) => {
if (err) throw err;
if (!user) throw Boom.unauthorized(ctx.translate('INVALID_OTP_PASSCODE'));

ctx.session.otp_remember_me = boolean(ctx.request.body.otp_remember_me);

ctx.session.otp = 'totp';
const redirectTo = `/${ctx.locale}/dashboard`;

if (ctx.accepts('json')) {
ctx.body = { redirectTo };
} else {
ctx.redirect(redirectTo);
}
})(ctx, next);
}

async function recoveryKey(ctx) {
let redirectTo = `/${ctx.locale}${config.passportCallbackOptions.successReturnToOrRedirect}`;

if (ctx.session && ctx.session.returnTo) {
redirectTo = ctx.session.returnTo;
delete ctx.session.returnTo;
}

ctx.state.redirectTo = redirectTo;

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

// ensure recovery matches user list of keys
if (
!isSANB(ctx.request.body.recovery_passcode) ||
!Array.isArray(recoveryKeys) ||
recoveryKeys.length === 0 ||
!recoveryKeys.includes(ctx.request.body.recovery_passcode)
)
return ctx.throw(
Boom.badRequest(ctx.translate('INVALID_RECOVERY_PASSCODE'))
);

// remove used passcode from recovery key list
recoveryKeys = recoveryKeys.filter(
key => key !== ctx.request.body.recovery_passcode
);
ctx.state.user[config.userFields.twoFactorRecoveryKeys] = 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');

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

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

Expand Down Expand Up @@ -266,7 +365,7 @@ async function forgotPassword(ctx) {
},
locals: {
user: _.pick(user, [
config.passport.fields.displayName,
config.userFields.displayName,
config.userFields.resetTokenExpiresAt
]),
link: `${config.urls.web}/reset-password/${
Expand Down Expand Up @@ -343,6 +442,10 @@ async function verify(ctx) {

ctx.state.redirectTo = redirectTo;

// set has verified to true
ctx.state.user[config.userFields.hasVerifiedEmail] = true;
await ctx.state.user.save();

if (ctx.state.user[config.userFields.hasVerifiedEmail]) {
const message = ctx.translate('EMAIL_ALREADY_VERIFIED');
if (ctx.accepts('html')) {
Expand Down Expand Up @@ -438,8 +541,10 @@ module.exports = {
registerOrLogin,
homeOrDomains,
login,
loginOtp,
register,
forgotPassword,
recoveryKey,
resetPassword,
catchError,
verify,
Expand Down
4 changes: 3 additions & 1 deletion app/controllers/web/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const auth = require('./auth');
const help = require('./help');
const myAccount = require('./my-account');
const faq = require('./faq');
const twoFactor = require('./2fa');

function breadcrumbs(ctx, next) {
// return early if its not a pure path (e.g. ignore static assets)
Expand Down Expand Up @@ -39,5 +40,6 @@ module.exports = {
breadcrumbs,
help,
myAccount,
faq
faq,
twoFactor
};
Loading

0 comments on commit b171c9e

Please sign in to comment.