Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion template/app/controllers/web/2fa/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const otp = require('./otp');
const recovery = require('./recovery');

module.exports = { recovery };
module.exports = { otp, recovery };
130 changes: 130 additions & 0 deletions template/app/controllers/web/2fa/otp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
const cryptoRandomString = require('crypto-random-string');
const isSANB = require('is-string-and-not-blank');
const qrcode = require('qrcode');
const Boom = require('@hapi/boom');
const { authenticator } = require('otplib');
const config = require('../../../../config');

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}/2fa/otp/verify`;
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.twoFactorToken
] = authenticator.generateSecret();

// generate 2fa recovery keys list used for fallback
const recoveryKeys = new Array(16)
.fill()
.map(() => cryptoRandomString({ length: 10, characters: '1234567890' }));

ctx.state.user[config.userFields.twoFactorRecoveryKeys] = recoveryKeys;
ctx.state.user = await ctx.state.user.save();

await ctx.render('2fa/otp/keys');
}

async function renderVerify(ctx) {
ctx.state.twoFactorTokenURI = authenticator.keyuri(
ctx.state.user.email,
process.env.WEB_HOST,
ctx.state.user[config.passport.fields.twoFactorToken]
);
ctx.state.qrcode = await qrcode.toDataURL(ctx.state.twoFactorTokenURI);

await ctx.render('2fa/otp/verify');
}

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.twoFactorEnabled] = 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 verify(ctx) {
const redirectTo = `/${ctx.locale}/my-account/security`;
if (ctx.method === 'DELETE') {
ctx.state.user[config.passport.fields.twoFactorEnabled] = false;
} else if (
ctx.method === 'POST' &&
ctx.state.user[config.passport.fields.twoFactorToken]
) {
const isValid = authenticator.verify({
token: ctx.request.body.token,
secret: ctx.state.user[config.passport.fields.twoFactorToken]
});

if (!isValid)
return ctx.throw(Boom.badRequest(ctx.translate('INVALID_OTP_PASSCODE')));

ctx.state.user[config.passport.fields.twoFactorEnabled] = 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 };
}

module.exports = {
disable,
keys,
renderKeys,
renderVerify,
verify
};
27 changes: 0 additions & 27 deletions template/app/controllers/web/my-account.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
const Boom = require('@hapi/boom');
const cryptoRandomString = require('crypto-random-string');
const humanize = require('humanize-string');
const isSANB = require('is-string-and-not-blank');
const qrcode = require('qrcode');
const { authenticator } = require('otplib');
const { boolean } = require('boolean');

Expand Down Expand Up @@ -78,30 +76,6 @@ async function resetAPIToken(ctx) {
else ctx.body = { reloadPage: true };
}

async function security(ctx) {
if (!ctx.state.user[config.passport.fields.twoFactorEnabled]) {
ctx.state.user[
config.passport.fields.twoFactorToken
] = authenticator.generateSecret();

// generate 2fa recovery keys list used for fallback
const recoveryKeys = new Array(16)
.fill()
.map(() => cryptoRandomString({ length: 10, characters: '1234567890' }));

ctx.state.user[config.userFields.twoFactorRecoveryKeys] = recoveryKeys;
ctx.state.user = await ctx.state.user.save();
ctx.state.twoFactorTokenURI = authenticator.keyuri(
ctx.state.user.email,
process.env.WEB_HOST,
ctx.state.user[config.passport.fields.twoFactorToken]
);
ctx.state.qrcode = await qrcode.toDataURL(ctx.state.twoFactorTokenURI);
}

await ctx.render('my-account/security');
}

async function recoveryKeys(ctx) {
const twoFactorRecoveryKeys =
ctx.state.user[config.userFields.twoFactorRecoveryKeys];
Expand Down Expand Up @@ -158,6 +132,5 @@ module.exports = {
update,
recoveryKeys,
resetAPIToken,
security,
setup2fa
};
41 changes: 41 additions & 0 deletions template/app/views/2fa/otp/keys.pug
Original file line number Diff line number Diff line change
@@ -0,0 +1,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 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="two-factor-recovery-keys")= t('Recovery keys')
textarea(rows='4').form-control#two-factor-recovery-keys
= user[config.userFields.twoFactorRecoveryKeys].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="#two-factor-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('/2fa/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')
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@

extends ../layout
extends ../../layout

block body
.container.py-3
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@

extends ../layout
extends ../../layout

block body
.container.py-3
Expand Down
108 changes: 108 additions & 0 deletions template/app/views/2fa/otp/verify.pug
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@

extends ../../layout

block body
#authenticator-apps-modal(tabindex='-1', role='dialog').modal.fade
.modal-dialog(role='document')
.modal-content
.modal-header.d-block.text-center
h6.modal-title.d-inline-block.ml-4= t('Authentication 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')
.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('/2fa/otp/verify'), method='POST').ajax-form.confirm-prompt
input(type="hidden", name="_csrf", value=ctx.csrf)
label(for='two-factor-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='two-factor-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='two-factor-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='#two-factor-copy')= t('Can’t scan the QR code? Follow alternative steps')
#two-factor-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.twoFactorToken]).form-control#two-factor-token
.input-group-append
button(type='button', data-toggle="clipboard", data-clipboard-target="#two-factor-token").btn.btn-primary
i.fa.fa-clipboard
= ' '
= t('Copy')
hr
button(type='submit').btn.btn-lg.btn-block.btn-primary= t('Enable OTP')
Loading