From 04f759e41f9b12e02f2eaa1424319dd5ec2804fe Mon Sep 17 00:00:00 2001 From: Shaun Warman Date: Thu, 7 May 2020 01:37:44 -0500 Subject: [PATCH 1/2] chore: refactor and polish otp --- template/app/controllers/web/auth.js | 48 +++- template/app/controllers/web/otp.js | 287 ------------------- template/app/controllers/web/otp/disable.js | 36 +++ template/app/controllers/web/otp/index.js | 7 + template/app/controllers/web/otp/keys.js | 7 + template/app/controllers/web/otp/recovery.js | 40 +++ template/app/controllers/web/otp/setup.js | 79 +++++ template/app/controllers/web/otp/verify.js | 124 ++++++++ template/app/models/user.js | 25 +- template/app/views/my-account/security.pug | 2 +- template/app/views/otp/keys.pug | 67 +++-- template/app/views/otp/login.pug | 72 ++--- template/app/views/otp/setup.pug | 201 ++++++------- template/config/phrases.js | 4 +- template/helpers/logger.js | 4 +- template/routes/web/otp.js | 6 +- 16 files changed, 521 insertions(+), 488 deletions(-) delete mode 100644 template/app/controllers/web/otp.js create mode 100644 template/app/controllers/web/otp/disable.js create mode 100644 template/app/controllers/web/otp/index.js create mode 100644 template/app/controllers/web/otp/keys.js create mode 100644 template/app/controllers/web/otp/recovery.js create mode 100644 template/app/controllers/web/otp/setup.js create mode 100644 template/app/controllers/web/otp/verify.js 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); From 1e7f51efc2e9c3f32e4886a6dfd52a74947c2159 Mon Sep 17 00:00:00 2001 From: Shaun Warman Date: Thu, 7 May 2020 02:14:54 -0500 Subject: [PATCH 2/2] fix: update snapshots --- test/snapshots/test.js.md | 7 ++++++- test/snapshots/test.js.snap | Bin 2515 -> 2582 bytes 2 files changed, 6 insertions(+), 1 deletion(-) 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 42aead1debbab3b28f0c7ecc13073e2e4c340d27..468047fa1c2e2583c2d5443f8a24aad6430e585d 100644 GIT binary patch literal 2582 zcmV+x3hDJhRzV^#$X zX7+X!D2O7|7X%cw0;O6__=ljz#E=++nly%>i7_#nnCKsBP&D|5CZLIaXLp~O=b4?` zR+0~G@AvsVZ}U9QJkQ)tLP&;OdU4~WV-F7J4cnrJ%eh&T%@;%4} zkAokAe}HYhgd729!S}!)!4TSUJ9q%R41N!KmJ@OVxC?Ob4EQm41FTv>$TXM(Pk>jz zU%~b)AxD7^z6maazk#hQQ4Xksi{N$eFEG+a$Zenwz72i>-Ug$q&_)n|=fP$078qVl z$U!g<&VyIMpFm$fAyYsH=fHQt@4(O+^b2rs0sIvF8;lPSVt^;XkHDY7;95fVgL!Zs zyb9g`eS?IIfm7f?a1p!$egpmkwyY!MCNK-mf^UG!;7!oC9=YHosDTUMHSl+^ZHSO# zpaCv{*TLIh!v;cTKpC6^FM(fycfk6MgiL^YKpi{{z7Kv4dWR7M_ku^jx4>)QEikl+ zkOP2%N5OZ%FTp>-Fec-Ea0(1&dIvH+2Ru)2WHRD!{r&wUlO@^KR-lQcxuzfDVy&}A_8RkZXR&^0vCW5Q;Wy{xn-?NH=Pow1JBAyN7 zR@q@56|of}wk99;g8O=znx2uLi?XwE>26TYwfZW>SH>;(&?s+St)NeY`|{jx*i;>> zRdK(3Pp?@#->BLmxLO349XWhx=IGrsBG4}aD~`_`xb=>ieAN`8H6qljyOz|-0rB%K zy6YD3M0}6gHXd#PUcquqT9@p#!rq={*Ty;$21UZwMG|aQmh^R3DnAJ4os_U%By3D3 zBwI2h%xjY70xcGFvuZgVIUBAbC)uKnB4=}IPT*H0d3e#$59MTYHVJ!2nq6(=bi-hQ z<4Y-oQLs;A@0G82VLaQoD2RjS6YKBAp2=rGxJh z+FD5q=a=MME1ZGQsabS^7bbR3gjxDBcIj&x|9No8=BdhOG9(G&xXc?~@>gAZ1!^UAL88;|T zqLrZ+S>Vf3E~N$X+LzS4GO1;?x#T+&3d&mROzrd*dR7w)P!Ke6R9aipBPJ zBbo0@SzWGY)pSGAZc3SqU01`+%9}+bprNT#V1G)-xNA8M^$KIMcuPvhB2n354s zG7omCd|Va}brJW-;;mgoWviU&B2LQU`@4u!vUpn;ajz_Xpo=&yi-)_2`(*KhDa&4n z?LTGx_LP|?-fxn2BxPI0q^l~c?T(b~p>wVuji&_1Oyq_3MWS^N$k*c&?w#>A-7*ax zzMy<3*E~RR{8OlR#ls`1M!brx?`0n4;|(RtCDP#$BNA7dB5%p#fmP|#lW_u8s?(=dux9xgdZ5<@O)Hs zJ6y7CU%d+2(HDZYVvS8ZQoI^{F%j{^y}^k-LbQdB(PGIes?^t|xSMRHTk&*Gw!)+S(E9xlkn;<%k3aRt1 zQHusnIEjnKLiAE-3+>BRq2gC_L=xwYO*PhMWH?ixBMz|L_v)R)-PB(uyF~WV_5BPmME57gY8w=r~CeJGtwQ*3lSfTaJ9m7;D z;8YQQp7;I0poK(%;puBvTu3V`XIOdWmAfYH3R_}e>5nu>_IHHYKzm6^!mMgM9!l%4q1F7I74zEW5zC+sZ-uk}1L9A@^^6_>06!r9UH||9 literal 2515 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