diff --git a/template/app/controllers/web/auth.js b/template/app/controllers/web/auth.js
index f9eb9248..e2e9a314 100644
--- a/template/app/controllers/web/auth.js
+++ b/template/app/controllers/web/auth.js
@@ -144,12 +144,12 @@ async function login(ctx, next) {
await ctx.state.user.save();
if (user[config.passport.fields.otpEnabled] && !ctx.session.otp)
- redirectTo = `/${ctx.locale}/otp/login`;
+ redirectTo = ctx.state.l(config.loginOtpRoute);
- if (ctx.accepts('json')) {
- ctx.body = { redirectTo };
- } else {
+ if (ctx.accepts('html')) {
ctx.redirect(redirectTo);
+ } else {
+ ctx.body = { redirectTo };
}
return;
@@ -187,10 +187,10 @@ async function loginOtp(ctx, next) {
ctx.session.otp = 'totp';
const redirectTo = `/${ctx.locale}/dashboard`;
- if (ctx.accepts('json')) {
- ctx.body = { redirectTo };
- } else {
+ if (ctx.accepts('html')) {
ctx.redirect(redirectTo);
+ } else {
+ ctx.body = { redirectTo };
}
})(ctx, next);
}
@@ -222,19 +222,43 @@ async function recoveryKey(ctx) {
recoveryKeys = recoveryKeys.filter(
key => key !== ctx.request.body.recovery_passcode
);
+
+ const emptyRecoveryKeys = recoveryKeys.length === 0;
+ const type = emptyRecoveryKeys ? 'warning' : 'success';
+ redirectTo = emptyRecoveryKeys
+ ? `/${ctx.locale}/my-account/security`
+ : redirectTo;
+
+ // handle case if the user runs out of keys
+ if (emptyRecoveryKeys) {
+ const opts = { length: 10, characters: '1234567890' };
+ recoveryKeys = new Array(10).fill().map(() => cryptoRandomString(opts));
+ }
+
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('OTP_RECOVERY_SUCCESS');
-
+ const message = ctx.translate(
+ type === 'warning' ? 'OTP_RECOVERY_RESET' : 'OTP_RECOVERY_SUCCESS'
+ );
if (ctx.accepts('html')) {
- ctx.flash('success', message);
+ ctx.flash(type, message);
ctx.redirect(redirectTo);
} else {
- ctx.body = { message, redirectTo };
+ ctx.body = {
+ ...(emptyRecoveryKeys
+ ? {
+ swal: {
+ title: ctx.translate('EMPTY_RECOVERY_KEYS'),
+ type,
+ text: message
+ }
+ }
+ : { message }),
+ redirectTo
+ };
}
}
diff --git a/template/app/controllers/web/otp.js b/template/app/controllers/web/otp.js
deleted file mode 100644
index 702b887b..00000000
--- a/template/app/controllers/web/otp.js
+++ /dev/null
@@ -1,287 +0,0 @@
-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 { 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 recovery(ctx) {
- let redirectTo = `/${ctx.locale}/otp/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 = {
- disable,
- keys,
- recovery,
- renderKeys,
- renderSetup,
- setup,
- verify
-};
diff --git a/template/app/controllers/web/otp/disable.js b/template/app/controllers/web/otp/disable.js
new file mode 100644
index 00000000..20e9fba2
--- /dev/null
+++ b/template/app/controllers/web/otp/disable.js
@@ -0,0 +1,36 @@
+const Boom = require('@hapi/boom');
+const isSANB = require('is-string-and-not-blank');
+
+const config = require('../../../../config');
+
+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;
+ ctx.state.user[config.passport.fields.otpToken] = null;
+ ctx.state.user[config.userFields.otpRecoveryKeys] = null;
+ 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 };
+}
+
+module.exports = disable;
diff --git a/template/app/controllers/web/otp/index.js b/template/app/controllers/web/otp/index.js
new file mode 100644
index 00000000..ae9d0d82
--- /dev/null
+++ b/template/app/controllers/web/otp/index.js
@@ -0,0 +1,7 @@
+const disable = require('./disable');
+const recovery = require('./recovery');
+const setup = require('./setup');
+const keys = require('./keys');
+const verify = require('./verify');
+
+module.exports = { disable, recovery, keys, setup, verify };
diff --git a/template/app/controllers/web/otp/keys.js b/template/app/controllers/web/otp/keys.js
new file mode 100644
index 00000000..328fad14
--- /dev/null
+++ b/template/app/controllers/web/otp/keys.js
@@ -0,0 +1,7 @@
+async function keys(ctx) {
+ // this is like a migration, it will automatically add token + keys if needed
+ await ctx.state.user.save();
+ await ctx.render('otp/keys');
+}
+
+module.exports = keys;
diff --git a/template/app/controllers/web/otp/recovery.js b/template/app/controllers/web/otp/recovery.js
new file mode 100644
index 00000000..aebfb93d
--- /dev/null
+++ b/template/app/controllers/web/otp/recovery.js
@@ -0,0 +1,40 @@
+const config = require('../../../../config');
+
+async function recovery(ctx) {
+ let redirectTo = `/${ctx.locale}/otp/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 };
+ }
+}
+
+module.exports = recovery;
diff --git a/template/app/controllers/web/otp/setup.js b/template/app/controllers/web/otp/setup.js
new file mode 100644
index 00000000..820dd566
--- /dev/null
+++ b/template/app/controllers/web/otp/setup.js
@@ -0,0 +1,79 @@
+const Boom = require('@hapi/boom');
+const isSANB = require('is-string-and-not-blank');
+const qrcode = require('qrcode');
+const { authenticator } = require('otplib');
+
+const config = require('../../../../config');
+
+const opts = { width: 500, margin: 0 };
+
+async function setup(ctx) {
+ const { body } = ctx.request;
+
+ if (ctx.method === 'DELETE') {
+ 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'
+ });
+ ctx.redirect(ctx.state.l('/my-account/security'));
+ return;
+ }
+
+ if (isSANB(body.token)) {
+ const isValid = authenticator.verify({
+ token: ctx.request.body.token,
+ secret: ctx.state.user[config.passport.fields.otpToken]
+ });
+
+ if (!isValid) {
+ ctx.flash('error', ctx.translate('INVALID_OTP_PASSCODE'));
+ 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, opts);
+ return ctx.render('otp/setup');
+ }
+
+ ctx.state.user[config.passport.fields.otpEnabled] = true;
+ await ctx.state.user.save();
+ ctx.session.otp = 'totp-setup';
+ 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('/my-account/security'));
+ return;
+ }
+
+ if (ctx.state.user[config.userFields.hasSetPassword]) {
+ 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.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, opts);
+ return ctx.render('otp/setup');
+}
+
+module.exports = setup;
diff --git a/template/app/controllers/web/otp/verify.js b/template/app/controllers/web/otp/verify.js
new file mode 100644
index 00000000..b945c5ca
--- /dev/null
+++ b/template/app/controllers/web/otp/verify.js
@@ -0,0 +1,124 @@
+const Boom = require('@hapi/boom');
+const isSANB = require('is-string-and-not-blank');
+const { boolean } = require('boolean');
+
+const { Inquiries } = require('../../../models');
+const bull = require('../../../../bull');
+const config = require('../../../../config');
+
+// 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('otp/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 = verify;
diff --git a/template/app/models/user.js b/template/app/models/user.js
index 7df07cd1..49304601 100644
--- a/template/app/models/user.js
+++ b/template/app/models/user.js
@@ -7,6 +7,7 @@ const mongoose = require('mongoose');
const mongooseCommonPlugin = require('mongoose-common-plugin');
const passportLocalMongoose = require('passport-local-mongoose');
const validator = require('validator');
+const { authenticator } = require('otplib');
const { boolean } = require('boolean');
const { select } = require('mongoose-json-select');
@@ -18,6 +19,7 @@ const i18n = require('../../helpers/i18n');
const logger = require('../../helpers/logger');
const bull = require('../../bull');
+const opts = { length: 10, characters: '1234567890' };
const storeIPAddress = new StoreIPAddress({
logger,
...config.storeIPAddress
@@ -193,14 +195,23 @@ User.pre('validate', function(next) {
// if otp authentication values no longer valid
// then disable it completely
if (
- !this[fields.otpEnabled] ||
- !Array.isArray(
- this[config.userFields.otpRecoveryKeys] ||
- this[config.userFields.otpRecoveryKeys].length === 0
- )
- ) {
+ !Array.isArray(this[config.userFields.otpRecoveryKeys]) ||
+ !this[config.userFields.otpRecoveryKeys] ||
+ this[config.userFields.otpRecoveryKeys].length === 0 ||
+ !this[config.passport.fields.otpToken]
+ )
this[fields.otpEnabled] = false;
- }
+
+ if (
+ !Array.isArray(this[config.userFields.otpRecoveryKeys]) ||
+ this[config.userFields.otpRecoveryKeys].length === 0
+ )
+ this[config.userFields.otpRecoveryKeys] = new Array(10)
+ .fill()
+ .map(() => cryptoRandomString(opts));
+
+ if (!this[config.passport.fields.otpToken])
+ this[config.passport.fields.otpToken] = authenticator.generateSecret();
next();
});
diff --git a/template/app/views/my-account/security.pug b/template/app/views/my-account/security.pug
index e1c19c02..040cf6c0 100644
--- a/template/app/views/my-account/security.pug
+++ b/template/app/views/my-account/security.pug
@@ -36,7 +36,7 @@ block body
else
h5= t('OTP')
p= t('OTP or one-time password allows you to add Two-Factor Authentication to your account using a device or authenticator app.')
- a.btn.btn-primary.btn-lg(href=l('/otp/keys'), role="button", data-toggle='modal-anchor', data-target='#modal-sign-up')= t('Enable OTP')
+ a.btn.btn-primary.btn-lg(href=l('/otp/setup'), role="button", data-toggle='modal-anchor', data-target='#modal-sign-up')= t('Enable OTP')
.card.card-bg-light
h4.card-header= t('API Credentials')
.card-body
diff --git a/template/app/views/otp/keys.pug b/template/app/views/otp/keys.pug
index 9e1abfc4..a370b25d 100644
--- a/template/app/views/otp/keys.pug
+++ b/template/app/views/otp/keys.pug
@@ -4,38 +4,35 @@ extends ../layout
block body
.container.py-3
.row
- .col-xs-12.col-sm-12.col-md-6.offset-md-3.col-lg-6.offset-lg-3
- .card
- .card-body
- .text-center
- h1.card-title.h4= t('OTP Recovery Keys')
- p= t('Recovery keys allow you to login to your account when you have lost access to your Two-Factor Authentication device or authenticator app.')
- hr
- p.text-muted.font-weight-bold= t('Backup your recovery keys before continuing')
- .container
- .row
- label(for="otp-recovery-keys")= t('Recovery keys')
- textarea(rows='4').form-control#otp-recovery-keys
- = user[config.userFields.otpRecoveryKeys].join('\n')
- .row.mt-3.mb-3
- .col-sm
- form(action=l('/my-account/recovery-keys'), method='POST')
- input(type="hidden", name="_csrf", value=ctx.csrf)
- button(type='submit').btn.btn-primary.btn-block
- i.fa.fa-download
- = ' '
- = t('Download')
- .col-sm.offset-sm-1
- button(type='button', data-toggle="clipboard", data-clipboard-target="#otp-recovery-keys").btn.btn-secondary.btn-block
- i.fa.fa-clipboard
- = ' '
- = t('Copy')
- form.ajax-form.confirm-prompt(action=ctx.path, method="POST", autocomplete="off")
- hr
- .row
- form(action=l('/otp/keys'), method='POST').col-md-12
- input(type="hidden", name="_csrf", value=ctx.csrf)
- .form-group.floating-label
- input#input-password.form-control(type="password", autocomplete="off", name="password" required)
- label(for="input-password")= t('Confirm Password')
- button.btn.btn-primary.btn-md.btn-block.mt-2(type="submit")= t('Continue')
+ .col-xs-12.col-sm-12.col-md-6.offset-md-3.col-lg-6.offset-lg-3.text-center
+ h1.my-3.py-3= t('Setup OTP')
+ .alert.alert-warning(role='alert')
+ i.fa.fa-exclamation-triangle
+ = ' '
+ = t('Download your emergency recovery keys below.')
+ textarea(rows='5').form-control.text-monospace.text-center.rounded-bottom-0.border-dark#otp-recovery-keys
+ each key, i in user[config.userFields.otpRecoveryKeys]
+ = key
+ if i !== user[config.userFields.otpRecoveryKeys].length - 1
+ if i % 2
+ = '\n'
+ else
+ = ' '
+ form(action=l('/my-account/recovery-keys'), method='POST')
+ input(type="hidden", name="_csrf", value=ctx.csrf)
+ .d-flex.btn-group(role='group')
+ button(type='submit').btn.btn-dark.rounded-top-0
+ i.fa.fa-file-download
+ = ' '
+ = t('Download')
+ button(type='button', data-toggle="clipboard", data-clipboard-text=user[config.userFields.otpRecoveryKeys].join('\r\n')).btn.btn-dark.rounded-top-0
+ i.fa.fa-clipboard
+ = ' '
+ = t('Copy')
+ form(action=ctx.path, method="POST", autocomplete="off", class=user[config.userFields.otpRecoveryKeys] ? '' : 'confirm-prompt')
+ input(type="hidden", name="_csrf", value=ctx.csrf)
+ if user[config.userFields.hasSetPassword]
+ .form-group.floating-label.mt-4
+ input#input-password.form-control.form-control-lg(type="password", autocomplete="off", name="password" required)
+ label(for="input-password")= t('Confirm Password')
+ button.btn.btn-primary.btn-lg.btn-block.mt-2(type="submit")= t('Continue')
diff --git a/template/app/views/otp/login.pug b/template/app/views/otp/login.pug
index 6e7267bb..7471cf49 100644
--- a/template/app/views/otp/login.pug
+++ b/template/app/views/otp/login.pug
@@ -2,53 +2,41 @@
extends ../layout
block body
- .container.py-3
- .row
- .col-xs-12.col-sm-12.col-md-6.offset-md-3.col-lg-6.offset-lg-3
- .card
- .card-body
- .text-center
- h1.card-title.h4= t('OTP Passcode')
- p= t('Please enter the passcode from your authenticator application.')
- form(action=l('/otp/login'), method="POST", autocomplete="off").ajax-form
- input(type="hidden", name="_csrf", value=ctx.csrf)
- .form-group.floating-label
- input#input-text.form-control.form-control-lg(type="text", autocomplete="off", name="passcode", placeholder=t(''))
- label(for="input-passcode")= t('Passcode')
- .form-check
- input(type='checkbox', name='otp_remember_me', value='true', checked=ctx.session.otp_remember_me)
- label Don't ask me again in this browser for OTP
- button.btn.btn-primary.btn-block.btn-lg(type="submit")= t('Submit Passcode')
- .card-footer.text-center
- b= t('Having trouble?')
- div
- hr
- ul.list-inline
- li
- a.card-link(href=l('/otp/recovery/keys'))= t('Use recovery key')
- li
- a.card-link(href=l('/otp/recovery'), data-toggle='modal-anchor', data-target='#modal-domain')= t('Unable to use authentication app or recovery keys?')
-
- #modal-domain.modal.fade(tabindex='-1', role='dialog', aria-labelledby='modal-domain-title', aria-hidden='true')
+ #modal-recovery.modal.fade(tabindex='-1', role='dialog', aria-labelledby='modal-domain-title', aria-hidden='true')
.modal-dialog(role='document')
.modal-content
.modal-header.text-center.d-block
- h4.modal-title.d-inline-block.ml-4#modal-domain-title= t('Account Recovery')
+ h4.modal-title.d-inline-block.ml-4#modal-recovery-title= t('Account Recovery')
button(type='button', data-dismiss='modal', aria-label='Close').close
span(aria-hidden='true') ×
.modal-body
form.ajax-form(action=l('/otp/recovery'), method="POST")
- p= t("If you can't access a trusted device or recovery codes you can request a reset of your security settings. For security reasons this can take 3-5 business days.")
- .container
- .row.row-cols-1.row-cols-lg
- .col
- b= t('Step 1')
- p= t('Verify your email address.')
- .col
- b= t('Step 2')
- p= t('An admin will reach out to this email for additional information.')
- .col
- b= t('Step 3')
- p= t('After successful verification, admins can remove 2-factor-auth for you to get back into your account.')
input(type="hidden", name="_csrf", value=ctx.csrf)
- button.btn.btn-success.btn-block.btn-lg(type="submit")= t('I understand, get started')
+ p= t("If you can't access a trusted device or recovery keys you can request a reset of your security settings. For security reasons this can take 3-5 business days. Here is an overview of the steps involved:")
+ ol
+ li= t('Verify access to your email address with a verification code.')
+ li= t('Wait for an admin to follow-up with additional information.')
+ li= t('Two-Factor Authentication will be removed from your account.')
+ button.btn.btn-primary.btn-block.btn-lg(type="submit")= t('Continue')
+ .container.py-3
+ h1.my-3.py-3.text-center= t('Two-Factor Check')
+ .row
+ .col-sm-12.col-md-8.offset-md-2.col-lg-6.offset-lg-3
+ form(action=ctx.path, method="POST", autocomplete="off").ajax-form
+ input(type="hidden", name="_csrf", value=ctx.csrf)
+ .form-group.floating-label
+ input#input-text.form-control.form-control-lg(type="text", autocomplete="off", name="passcode", placeholder=' ')
+ label(for="input-passcode")= t('Passcode')
+ .form-group.form-check
+ input.form-check-input(type='checkbox', name='otp_remember_me', value='true', checked=ctx.session.otp_remember_me)#otp-remember-me
+ label.form-check-label(for='otp-remember-me')= t("Don't ask me again in this browser")
+ button.btn.btn-primary.btn-block.btn-lg(type="submit")= t('Continue')
+ .alert.alert-light.border.mt-3.text-center
+ = t('Having trouble?')
+ = ' '
+ a(href=l('/otp/recovery/keys'))= t('Use a recovery key')
+ p.mb-0.text-center
+ small.text-muted
+ = t('Lose your recovery keys?')
+ = ' '
+ a(href='#', data-toggle='modal-anchor', data-target='#modal-recovery').text-danger= t('Request account recovery')
diff --git a/template/app/views/otp/setup.pug b/template/app/views/otp/setup.pug
index 8ff79706..376aae18 100644
--- a/template/app/views/otp/setup.pug
+++ b/template/app/views/otp/setup.pug
@@ -3,106 +3,113 @@ extends ../layout
block body
#authenticator-apps-modal(tabindex='-1', role='dialog').modal.fade
- .modal-dialog(role='document')
+ .modal-dialog.modal-lg(role='document')
.modal-content
- .modal-header.d-block.text-center
- h6.modal-title.d-inline-block.ml-4= t('Authentication Apps')
+ .modal-header
+ h1.h4.modal-title= t('Authenticator Apps')
button(type='button', data-dismiss='modal', aria-label='Close').close
span(aria-hidden='true') ×
- .modal-body.text-center
- = t('Recommendations are listed below:')
- .flex-wrap.flex-fill.text-center
- = t('Free and Open-Source Software:')
- ul.list-group.text-center.mb-3
- li.list-group-item.border-0
- a(href='https://freeotp.github.io/', rel='noopener', target='_blank') FreeOTP
- ul.list-inline
- li.list-inline-item
- a(href='https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
- i.fab.fa-google-play
- = ' '
- = t('Google Play')
- li.list-inline-item
- a(href='https://itunes.apple.com/us/app/freeotp-authenticator/id872559395?mt=8', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
- i.fab.fa-app-store-ios
- = ' '
- = t('App Store')
- li.list-group-item.border-0
- a(href='https://f-droid.org/en/packages/org.shadowice.flocke.andotp', rel='noopener', target='_blank') andOTP
- ul.list-inline
- li.list-inline-item
- a(href='https://f-droid.org/repo/org.shadowice.flocke.andotp_28.apk', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
- i.fab.fa-google-play
- = ' '
- = t('Google Play')
- = t('Closed-Source Software:')
- ul.list-group.text-center
- li.list-group-item.border-0
- a(href='https://authy.com/', rel='noopener', target='_blank') Authy
- ul.list-inline
- li.list-inline-item
- a(href='https://play.google.com/store/apps/details?id=com.authy.authy', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
- i.fab.fa-google-play
- = ' '
- = t('Google Play')
- li.list-inline-item
- a(href='https://itunes.apple.com/us/app/authy/id494168017', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
- i.fab.fa-app-store-ios
- = ' '
- = t('App Store')
- li.list-group-item.border-0.mb-4
- a(href='https://support.google.com/accounts/answer/1066447', rel='noopener', target='_blank') Google Authenticator
- ul.list-inline
- li.list-inline-item
- a(href='https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
- i.fab.fa-google-play
- = ' '
- = t('Google Play')
- li.list-inline-item
- a(href='http://appstore.com/googleauthenticator', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
- i.fab.fa-app-store-ios
- = ' '
- = t('App Store')
+ .modal-body
+ .alert.alert-info= t('Our recommended apps are listed below. If you have feedback on this list, then please let us know.')
+ table.table
+ thead
+ tr
+ th(scope='col')= t('App')
+ th(scope='col')= t('Links')
+ th(scope='col').text-center= t('Open-Source')
+ tbody
+ tr
+ td.align-middle: a(href='https://freeotp.github.io/', rel='noopener', target='_blank') FreeOTP
+ td.align-middle
+ ul.list-inline.mb-0
+ li.list-inline-item.mb-1.mb-md-0
+ a(href='https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
+ i.fab.fa-google-play
+ = ' '
+ = t('Google Play')
+ li.list-inline-item.mb-1.mb-md-0
+ a(href='https://itunes.apple.com/us/app/freeotp-authenticator/id872559395?mt=8', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
+ i.fab.fa-app-store-ios
+ = ' '
+ = t('App Store')
+ li.list-inline-item
+ a(href='https://f-droid.org/en/packages/org.fedorahosted.freeotp/', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
+ i.fab.fa-android
+ = ' '
+ = t('F-Droid')
+ td.align-middle.text-center: i.fa.fa-check-circle.text-success
+ tr
+ td.align-middle: a(href='https://github.com/andOTP/andOTP', rel='noopener', target='_blank') andOTP
+ td.align-middle
+ ul.list-inline.mb-0
+ li.list-inline-item.mb-1.mb-md-0
+ a(href='https://f-droid.org/repo/org.shadowice.flocke.andotp_28.apk', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
+ i.fab.fa-google-play
+ = ' '
+ = t('Google Play')
+ li.list-inline-item
+ a(href='https://f-droid.org/en/packages/org.shadowice.flocke.andotp/', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
+ i.fab.fa-android
+ = ' '
+ = t('F-Droid')
+ td.align-middle.text-center: i.fa.fa-check-circle.text-success
+ tr
+ td.align-middle: a(href='https://authy.com/', rel='noopener', target='_blank') Authy
+ td.align-middle
+ ul.list-inline.mb-0
+ li.list-inline-item.mb-1.mb-md-0
+ a(href='https://play.google.com/store/apps/details?id=com.authy.authy', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
+ i.fab.fa-google-play
+ = ' '
+ = t('Google Play')
+ li.list-inline-item
+ a(href='https://itunes.apple.com/us/app/authy/id494168017', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
+ i.fab.fa-app-store-ios
+ = ' '
+ = t('App Store')
+ td.align-middle.text-center: i.fa.fa-times-circle.text-danger
+ tr
+ td.align-middle: a(href='https://support.google.com/accounts/answer/1066447', rel='noopener', target='_blank') Google Authenticator
+ td.align-middle
+ ul.list-inline.mb-0
+ li.list-inline-item.mb-1.mb-md-0
+ a(href='https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
+ i.fab.fa-google-play
+ = ' '
+ = t('Google Play')
+ li.list-inline-item
+ a(href='https://apps.apple.com/us/app/google-authenticator/id388497605', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
+ i.fab.fa-app-store-ios
+ = ' '
+ = t('App Store')
+ td.align-middle.text-center: i.fa.fa-times-circle.text-danger
.container.py-3
.row
.col-xs-12.col-sm-12.col-md-6.offset-md-3.col-lg-6.offset-lg-3
- .card
- .card-body
- .text-center
- h1.card-title.h4= t('Enable OTP')
- p= t('Follow the below steps to enable two-factor authentication on your account.')
- hr
- .container
- form(action=l('/otp/setup'), method='POST').ajax-form.confirm-prompt
- input(type="hidden", name="_csrf", value=ctx.csrf)
- label(for='otp-step-one')
- b= t('Step 1: ')
- = t('Install an')
- = ' '
- a.card-link(href='#' data-toggle='modal-anchor', data-target='#authenticator-apps-modal').text-primary= t('authentication app')
- = ' '
- = t('on your device.')
- label(for='otp-step-two')
- b= t('Step 2: ')
- = t('Scan this QR code using the app:')
- img(src=qrcode, width=250, height=250, alt="").mx-auto.d-block
- hr
- label(for='otp-step-three')
- b= t('Step 3: ')
- = t('Enter the token generated from the app:')
- .form-group.floating-label
- input(type='text', name='token', required, placeholder=' ').form-control.form-control-lg#input-token
- label(for='input-token') Verification Token
- a.card-link.text-primary(href='#' data-toggle='collapse' data-target='#otp-copy')= t('Can’t scan the QR code? Follow alternative steps')
- #otp-copy.collapse
- hr
- p.text-secondary= t('Manually configure your authenticator app using this code:')
- .input-group.input-group-sm.floating-label.form-group
- input(type='text', readonly, value=user[config.passport.fields.otpToken]).form-control#otp-token
- .input-group-append
- button(type='button', data-toggle="clipboard", data-clipboard-target="#otp-token").btn.btn-primary
- i.fa.fa-clipboard
- = ' '
- = t('Copy')
- hr
- button(type='submit').btn.btn-lg.btn-block.btn-primary= t('Enable OTP')
+ h1.my-3.py-3.text-center= t('Setup OTP')
+ form(action=ctx.path, method='POST').confirm-prompt
+ input(type="hidden", name="_csrf", value=ctx.csrf)
+ .form-group
+ label(for='otp-step-one')
+ != t('Step 1: Install and open an authenticator app.')
+ .form-group
+ label(for='otp-step-two')
+ != t('Step 2: Scan this QR code and enter its generated token:')
+ img(src=qrcode, width=250, height=250, alt="").d-block.my-3
+ .form-group.floating-label
+ input(type='text', name='token', required, placeholder=' ').form-control.form-control-lg#input-token
+ label(for='input-token') Verification Token
+ .form-group
+ a.btn.btn-link.text-center.text-primary(href='#' data-toggle='collapse' data-target='#otp-copy')
+ = t('Can’t scan the QR code? Configure with this code')
+ = ' '
+ i.fa.fa-angle-down
+ #otp-copy.collapse
+ .input-group.input-group-lg.floating-label.form-group
+ input(type='text', readonly, value=user[config.passport.fields.otpToken]).form-control#otp-token
+ .input-group-append
+ button(type='button', data-toggle="clipboard", data-clipboard-target="#otp-token").btn.btn-dark
+ i.fa.fa-clipboard
+ = ' '
+ = t('Copy')
+ button(type='submit').btn.btn-lg.btn-block.btn-primary= t('Complete Setup')
diff --git a/template/config/phrases.js b/template/config/phrases.js
index 4f9317a1..9dfac257 100644
--- a/template/config/phrases.js
+++ b/template/config/phrases.js
@@ -37,11 +37,13 @@ module.exports = {
'Please log in with two-factor authentication to view the page you requested.',
LOGIN_REQUIRED: 'Please log in to view the page you requested.',
LOGOUT_REQUIRED: 'Please log out to view the page you requested.',
- PASSWORD_CONFIRM_SUCCESS: 'Password successfully confirmed.',
PASSWORD_RESET_LIMIT:
'You can only request a password reset every 30 minutes. Please try again %s.',
PASSWORD_RESET_SENT:
'We have sent you an email with a link to reset your password.',
+ EMPTY_RECOVERY_KEYS: 'Empty Recovery Keys',
+ OTP_RECOVERY_RESET:
+ 'You have run out of recovery keys. Please download the newly generated recovery keys before continuing.',
OTP_RECOVERY_SUCCESS:
'Recovery passcode successful. This passcode will no longer be valid.',
REGISTERED: 'You have successfully registered.',
diff --git a/template/helpers/logger.js b/template/helpers/logger.js
index 2e516b94..e97313e3 100644
--- a/template/helpers/logger.js
+++ b/template/helpers/logger.js
@@ -1,7 +1,7 @@
const Axe = require('axe');
-const config = require('../config');
+const loggerConfig = require('../config/logger');
-const logger = new Axe(config.logger);
+const logger = new Axe(loggerConfig);
module.exports = logger;
diff --git a/template/routes/web/otp.js b/template/routes/web/otp.js
index e193a986..45655178 100644
--- a/template/routes/web/otp.js
+++ b/template/routes/web/otp.js
@@ -10,13 +10,11 @@ router.use(policies.ensureLoggedIn);
router
.get('/login', render('otp/login'))
.post('/login', web.auth.loginOtp)
- .get('/keys', web.otp.renderKeys)
- .post('/keys', web.otp.keys)
- .get('/setup', web.otp.renderSetup)
+ .get('/setup', render('otp/keys'))
.post('/setup', web.otp.setup)
.post('/disable', web.otp.disable)
.post('/recovery', web.otp.recovery)
- .get('/recovery/verify', render('otp/verify'))
+ .get('/recovery/verify', web.otp.verify)
.post('/recovery/verify', web.otp.verify)
.get('/recovery/keys', render('otp/recovery'))
.post('/recovery/keys', web.auth.recoveryKey);
diff --git a/test/snapshots/test.js.md b/test/snapshots/test.js.md
index ffe84519..df9a6ae8 100644
--- a/test/snapshots/test.js.md
+++ b/test/snapshots/test.js.md
@@ -36,7 +36,12 @@ Generated by [AVA](https://ava.li).
'app/controllers/web/auth.js',
'app/controllers/web/index.js',
'app/controllers/web/my-account.js',
- 'app/controllers/web/otp.js',
+ 'app/controllers/web/otp/disable.js',
+ 'app/controllers/web/otp/index.js',
+ 'app/controllers/web/otp/keys.js',
+ 'app/controllers/web/otp/recovery.js',
+ 'app/controllers/web/otp/setup.js',
+ 'app/controllers/web/otp/verify.js',
'app/controllers/web/support.js',
'app/models/index.js',
'app/models/inquiry.js',
diff --git a/test/snapshots/test.js.snap b/test/snapshots/test.js.snap
index 42aead1d..468047fa 100644
Binary files a/test/snapshots/test.js.snap and b/test/snapshots/test.js.snap differ