lad
Version:
Lad is the best Node.js framework. Made by a former Express TC and Koa team member.
695 lines (585 loc) • 19.5 kB
JavaScript
const Boom = require('@hapi/boom');
const _ = require('lodash');
const cryptoRandomString = require('crypto-random-string');
const isSANB = require('is-string-and-not-blank');
const dayjs = require('dayjs');
const qrcode = require('qrcode');
const sanitizeHtml = require('sanitize-html');
const validator = require('validator');
const { authenticator } = require('otplib');
const { boolean } = require('boolean');
const Users = require('../../models/user');
const passport = require('../../../helpers/passport');
const email = require('../../../helpers/email');
const sendVerificationEmail = require('../../../helpers/send-verification-email');
const config = require('../../../config');
const { Inquiries } = require('../../models');
const options = { length: 10, type: 'numeric' };
const sanitize = (string) =>
sanitizeHtml(string, {
allowedTags: [],
allowedAttributes: []
});
function logout(ctx) {
if (!ctx.isAuthenticated()) return ctx.redirect(ctx.state.l());
if (ctx.session.otp && !ctx.session.otp_remember_me) delete ctx.session.otp;
ctx.logout();
ctx.flash('custom', {
title: ctx.request.t('Success'),
text: ctx.translate('REQUEST_OK'),
type: 'success',
toast: true,
showConfirmButton: false,
timer: 3000,
position: 'top'
});
ctx.redirect(ctx.state.l());
}
function parseReturnOrRedirectTo(ctx, next) {
// if the user passed `?return_to` and it is not blank
// then set it as the returnTo value for when we log in
if (isSANB(ctx.query.return_to)) {
ctx.session.returnTo = ctx.query.return_to;
} else if (isSANB(ctx.query.redirect_to)) {
// in case people had a typo, we should support redirect_to as well
ctx.session.returnTo = ctx.query.redirect_to;
}
// prevents lad being used as a open redirect
if (
ctx.session.returnTo &&
ctx.session.returnTo.includes('://') &&
ctx.session.returnTo.indexOf(config.urls.web) !== 0
) {
ctx.logger.warn(
`Prevented abuse with returnTo hijacking to ${ctx.session.returnTo}`
);
ctx.session.returnTo = null;
}
return next();
}
async function registerOrLogin(ctx) {
if (ctx.isAuthenticated()) {
let redirectTo = ctx.state.l(
config.passportCallbackOptions.successReturnToOrRedirect
);
if (ctx.session && ctx.session.returnTo) {
redirectTo = ctx.session.returnTo;
delete ctx.session.returnTo;
}
ctx.flash('success', ctx.translate('ALREADY_SIGNED_IN'));
if (ctx.accepts('html')) ctx.redirect(redirectTo);
else ctx.body = { redirectTo };
return;
}
ctx.state.verb =
ctx.pathWithoutLocale === '/register' ? 'sign up' : 'sign in';
return ctx.render('register-or-login');
}
async function homeOrDashboard(ctx) {
// If the user is logged in then take them to their dashboard
if (ctx.isAuthenticated())
return ctx.redirect(
ctx.state.l(config.passportCallbackOptions.successReturnToOrRedirect)
);
// Manually set page title since we don't define Home route in config/meta
ctx.state.meta = {
title: sanitize(
ctx.request.t(
`The Best Node.js Framework | <span class="notranslate">${config.appName}</span>`
)
),
description: sanitize(ctx.request.t(config.pkg.description))
};
return ctx.render('home');
}
async function login(ctx, next) {
if (ctx.isAuthenticated()) {
let redirectTo = ctx.state.l(
config.passportCallbackOptions.successReturnToOrRedirect
);
if (ctx.session && ctx.session.returnTo) {
redirectTo = ctx.session.returnTo;
delete ctx.session.returnTo;
}
ctx.flash('success', ctx.translate('ALREADY_SIGNED_IN'));
if (ctx.accepts('html')) ctx.redirect(redirectTo);
else ctx.body = { redirectTo };
return;
}
await passport.authenticate('local', async (err, user, info) => {
if (err) throw err;
if (!user) {
if (info) throw info;
throw ctx.translateError('UNKNOWN_ERROR');
}
// redirect user to their last locale they were using
if (
user &&
isSANB(user[config.lastLocaleField]) &&
user[config.lastLocaleField] !== ctx.locale
) {
ctx.state.locale = user[config.lastLocaleField];
ctx.request.locale = ctx.state.locale;
ctx.locale = ctx.request.locale;
}
let redirectTo = ctx.state.l(
config.passportCallbackOptions.successReturnToOrRedirect
);
if (ctx.session && ctx.session.returnTo) {
redirectTo = ctx.session.returnTo;
delete ctx.session.returnTo;
}
let greeting = 'Good morning';
if (dayjs().format('HH') >= 12 && dayjs().format('HH') <= 17)
greeting = 'Good afternoon';
else if (dayjs().format('HH') >= 17) greeting = 'Good evening';
if (user) {
await ctx.login(user);
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.otpToken]
);
ctx.state.user.qrcode = await qrcode.toDataURL(uri);
ctx.state.user = await ctx.state.user.save();
if (user[config.passport.fields.otpEnabled] && !ctx.session.otp)
redirectTo = ctx.state.l(config.loginOtpRoute);
if (ctx.accepts('html')) {
ctx.redirect(redirectTo);
} else {
ctx.body = { redirectTo };
}
return;
}
ctx.flash('custom', {
title: `${ctx.translate('HELLO')} ${ctx.state.emoji('wave')}`,
text: ctx.translate('SIGNED_IN'),
type: 'success',
toast: true,
showConfirmButton: false,
timer: 3000,
position: 'top'
});
if (ctx.accepts('html')) ctx.redirect(redirectTo);
else ctx.body = { redirectTo };
})(ctx, next);
}
async function loginOtp(ctx, next) {
await passport.authenticate('otp', (err, user) => {
if (err) throw err;
if (!user)
throw Boom.unauthorized(ctx.translateError('INVALID_OTP_PASSCODE'));
ctx.session.otp_remember_me = boolean(ctx.request.body.otp_remember_me);
ctx.session.otp = 'totp';
const redirectTo = ctx.state.l('/dashboard');
if (ctx.accepts('json')) {
ctx.body = { redirectTo };
} else {
ctx.redirect(redirectTo);
}
})(ctx, next);
}
async function recoveryKey(ctx) {
let redirectTo = ctx.state.l(
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.otpRecoveryKeys];
// ensure recovery matches user list of keys
if (
!isSANB(ctx.request.body.recovery_key) ||
!Array.isArray(recoveryKeys) ||
recoveryKeys.length === 0 ||
!recoveryKeys.includes(ctx.request.body.recovery_key)
)
return ctx.throw(
Boom.badRequest(ctx.translateError('INVALID_RECOVERY_KEY'))
);
// remove used key from recovery key list
recoveryKeys = recoveryKeys.filter(
(key) => key !== ctx.request.body.recovery_key
);
const emptyRecoveryKeys = recoveryKeys.length === 0;
const type = emptyRecoveryKeys ? 'warning' : 'success';
redirectTo = emptyRecoveryKeys
? ctx.state.l('/my-account/security')
: redirectTo;
// handle case if the user runs out of keys
if (emptyRecoveryKeys) {
recoveryKeys = await Promise.all(
new Array(10).fill().map(() => cryptoRandomString.async(options))
);
}
ctx.state.user[config.userFields.otpRecoveryKeys] = recoveryKeys;
ctx.state.user = await ctx.state.user.save();
ctx.session.otp = 'totp-recovery';
const message = ctx.translate(
type === 'warning' ? 'OTP_RECOVERY_RESET' : 'OTP_RECOVERY_SUCCESS'
);
if (ctx.accepts('html')) {
ctx.flash(type, message);
ctx.redirect(redirectTo);
} else {
ctx.body = {
...(emptyRecoveryKeys
? {
swal: {
title: ctx.translate('EMPTY_RECOVERY_KEYS'),
type,
text: message
}
}
: { message }),
redirectTo
};
}
}
async function register(ctx) {
const { body } = ctx.request;
if (!_.isString(body.email) || !validator.isEmail(body.email))
throw Boom.badRequest(ctx.translateError('INVALID_EMAIL'));
if (!isSANB(body.password))
throw Boom.badRequest(ctx.translateError('INVALID_PASSWORD'));
// register the user
const count = await Users.countDocuments({ group: 'admin' });
const query = {
email: body.email,
group: count === 0 ? 'admin' : 'user',
locale: ctx.locale
};
query[config.userFields.hasVerifiedEmail] = false;
query[config.userFields.hasSetPassword] = true;
query[config.lastLocaleField] = ctx.locale;
const user = await Users.register(query, body.password);
await ctx.login(user);
let redirectTo = ctx.state.l(
config.passportCallbackOptions.successReturnToOrRedirect
);
if (ctx.session && ctx.session.returnTo) {
redirectTo = ctx.session.returnTo;
delete ctx.session.returnTo;
}
ctx.flash('custom', {
title: `${ctx.request.t('Thank you')} ${ctx.state.emoji('pray')}`,
text: ctx.translate('REGISTERED'),
type: 'success',
toast: true,
showConfirmButton: false,
timer: 3000,
position: 'top'
});
if (ctx.accepts('html')) ctx.redirect(redirectTo);
else ctx.body = { redirectTo };
}
async function forgotPassword(ctx) {
const { body } = ctx.request;
if (!_.isString(body.email) || !validator.isEmail(body.email))
throw Boom.badRequest(ctx.translateError('INVALID_EMAIL'));
// lookup the user
let user = await Users.findOne({ email: body.email });
// to prevent people from being able to find out valid email accounts
// we always say "a password reset request has been sent to your email"
// and if the email didn't exist in our system then we simply don't send it
if (!user) {
if (ctx.accepts('html')) {
ctx.flash('success', ctx.translate('PASSWORD_RESET_SENT'));
ctx.redirect('back');
} else {
ctx.body = {
message: ctx.translate('PASSWORD_RESET_SENT')
};
}
return;
}
// if we've already sent a reset password request in the past half hour
if (
user[config.userFields.resetTokenExpiresAt] &&
user[config.userFields.resetToken] &&
dayjs(user[config.userFields.resetTokenExpiresAt]).isAfter(
dayjs().subtract(30, 'minutes')
)
)
throw Boom.badRequest(
ctx.translateError(
'PASSWORD_RESET_LIMIT',
dayjs(user[config.userFields.resetTokenExpiresAt]).fromNow()
)
);
// set the reset token and expiry
user[config.userFields.resetTokenExpiresAt] = dayjs()
.add(30, 'minutes')
.toDate();
user[config.userFields.resetToken] = await cryptoRandomString.async({
length: 32
});
user = await user.save();
// queue password reset email
try {
await email({
template: 'reset-password',
message: {
to: user[config.userFields.fullEmail]
},
locals: {
user: _.pick(user, [
config.userFields.displayName,
config.userFields.resetTokenExpiresAt
]),
link: `${config.urls.web}/reset-password/${
user[config.userFields.resetToken]
}`
}
});
if (ctx.accepts('html')) {
ctx.flash('success', ctx.translate('PASSWORD_RESET_SENT'));
ctx.redirect('back');
} else {
ctx.body = {
message: ctx.translate('PASSWORD_RESET_SENT')
};
}
} catch (err) {
ctx.logger.error(err);
// reset if there was an error
try {
user[config.userFields.resetToken] = null;
user[config.userFields.resetTokenExpiresAt] = null;
user = await user.save();
} catch (err) {
ctx.logger.error(err);
}
throw Boom.badRequest(ctx.translateError('EMAIL_FAILED_TO_SEND'));
}
}
async function resetPassword(ctx) {
const { body } = ctx.request;
if (!_.isString(body.email) || !validator.isEmail(body.email))
throw Boom.badRequest(ctx.translateError('INVALID_EMAIL'));
if (!isSANB(body.password))
throw Boom.badRequest(ctx.translateError('INVALID_PASSWORD'));
if (!isSANB(ctx.params.token))
throw Boom.badRequest(ctx.translateError('INVALID_RESET_TOKEN'));
// lookup the user that has this token and if it matches the email passed
const query = { email: body.email };
query[config.userFields.resetToken] = ctx.params.token;
// ensure that the reset token expires at value is in the future (hasn't expired)
query[config.userFields.resetTokenExpiresAt] = { $gte: new Date() };
let user = await Users.findOne(query);
if (!user)
throw Boom.badRequest(ctx.translateError('INVALID_RESET_PASSWORD'));
user[config.userFields.resetToken] = null;
user[config.userFields.resetTokenExpiresAt] = null;
await user.setPassword(body.password);
user = await user.save();
await ctx.login(user);
const message = ctx.translate('RESET_PASSWORD');
const redirectTo = ctx.state.l();
if (ctx.accepts('html')) {
ctx.flash('success', message);
ctx.redirect(redirectTo);
} else {
ctx.body = {
message,
redirectTo
};
}
}
async function changeEmail(ctx) {
const { body } = ctx.request;
if (!isSANB(body.password))
throw Boom.badRequest(ctx.translateError('INVALID_PASSWORD'));
if (!isSANB(ctx.params.token))
throw Boom.badRequest(ctx.translateError('INVALID_RESET_TOKEN'));
// lookup the user that has this token and if it matches the email passed
const query = { email: body.email };
query[config.userFields.changeEmailToken] = ctx.params.token;
// ensure that the reset token expires at value is in the future (hasn't expired)
query[config.userFields.changeEmailTokenExpiresAt] = { $gte: new Date() };
const user = await Users.findOne(query);
try {
if (!user) throw Boom.badRequest(ctx.translateError('INVALID_SET_EMAIL'));
const auth = await user.authenticate(body.password);
if (!auth.user)
throw Boom.badRequest(ctx.translateError('INVALID_PASSWORD'));
const newEmail = user[config.userFields.changeEmailNewAddress];
user[config.passportLocalMongoose.usernameField] = newEmail;
await user.save();
// reset change email info
user[config.userFields.changeEmailToken] = null;
user[config.userFields.changeEmailTokenExpiresAt] = null;
user[config.userFields.changeEmailNewAddress] = null;
await user.save();
} catch (err) {
ctx.throw(err);
}
const message = ctx.translate('CHANGE_EMAIL');
const redirectTo = ctx.state.l();
if (ctx.accepts('html')) {
ctx.flash('success', message);
ctx.redirect(redirectTo);
} else {
ctx.body = {
message,
redirectTo
};
}
}
async function catchError(ctx, next) {
try {
await next();
} catch (err) {
ctx.logger.error(err);
if (ctx.params.provider === 'google' && err.message === 'Consent required')
return ctx.redirect('/auth/google/consent');
ctx.flash('error', err.message);
ctx.redirect('/login');
}
}
// eslint-disable-next-line complexity
async function verify(ctx) {
let redirectTo = ctx.state.l(
config.passportCallbackOptions.successReturnToOrRedirect
);
if (ctx.session && ctx.session.returnTo) {
redirectTo = ctx.session.returnTo;
delete ctx.session.returnTo;
}
ctx.state.redirectTo = redirectTo;
if (
ctx.state.user[config.userFields.hasVerifiedEmail] &&
!ctx.state.user[config.userFields.pendingRecovery]
) {
const message = ctx.translate('EMAIL_ALREADY_VERIFIED');
if (ctx.accepts('html')) {
ctx.flash('success', message);
ctx.redirect(redirectTo);
} else {
ctx.body = { message, redirectTo };
}
return;
}
// 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 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.error(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.translateError('INVALID_VERIFICATION_PIN'))
);
// set has verified to true
ctx.state.user[config.userFields.hasVerifiedEmail] = true;
ctx.state.user = await ctx.state.user.save();
const pendingRecovery = ctx.state.user[config.userFields.pendingRecovery];
if (pendingRecovery) {
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);
try {
await email({
template: 'recovery',
message: {
to: ctx.state.user.email,
cc: config.email.message.from
},
locals: {
locale: ctx.locale,
inquiry
}
});
} catch (err) {
ctx.logger.error(err);
throw Boom.badRequest(ctx.translateError('EMAIL_FAILED_TO_SEND'));
}
}
const message = pendingRecovery
? ctx.translate('PENDING_RECOVERY_VERIFICATION_SUCCESS')
: ctx.translate('EMAIL_VERIFICATION_SUCCESS');
redirectTo = pendingRecovery ? '/logout' : redirectTo;
if (ctx.accepts('html')) {
ctx.flash('success', message);
ctx.redirect(redirectTo);
} else {
ctx.body = { message, redirectTo };
}
}
module.exports = {
logout,
registerOrLogin,
homeOrDashboard,
login,
loginOtp,
register,
forgotPassword,
resetPassword,
recoveryKey,
changeEmail,
catchError,
verify,
parseReturnOrRedirectTo
};