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