Skip to content

Commit b4f61b9

Browse files
committed
chore: initial otp revamp
1 parent 59d07ec commit b4f61b9

File tree

14 files changed

+331
-180
lines changed

14 files changed

+331
-180
lines changed

app/controllers/web/2fa/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
const otp = require('./otp');
12
const recovery = require('./recovery');
23

3-
module.exports = { recovery };
4+
module.exports = { otp, recovery };

app/controllers/web/2fa/otp.js

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
const cryptoRandomString = require('crypto-random-string');
2+
const isSANB = require('is-string-and-not-blank');
3+
const qrcode = require('qrcode');
4+
const Boom = require('@hapi/boom');
5+
const { authenticator } = require('otplib');
6+
const config = require('../../../../config');
7+
8+
async function keys(ctx) {
9+
const { body } = ctx.request;
10+
11+
if (!isSANB(body.password))
12+
throw Boom.badRequest(ctx.translate('INVALID_PASSWORD'));
13+
14+
const { user } = await ctx.state.user.authenticate(body.password);
15+
if (!user) throw Boom.badRequest(ctx.translate('INVALID_PASSWORD'));
16+
17+
const redirectTo = `/${ctx.locale}/2fa/otp/verify`;
18+
const message = ctx.translate('PASSWORD_CONFIRM_SUCCESS');
19+
if (ctx.accepts('html')) {
20+
ctx.flash('success', message);
21+
ctx.redirect(redirectTo);
22+
} else {
23+
ctx.body = {
24+
message,
25+
redirectTo
26+
};
27+
}
28+
}
29+
30+
async function renderKeys(ctx) {
31+
ctx.state.user[
32+
config.passport.fields.twoFactorToken
33+
] = authenticator.generateSecret();
34+
35+
// generate 2fa recovery keys list used for fallback
36+
const recoveryKeys = new Array(16)
37+
.fill()
38+
.map(() => cryptoRandomString({ length: 10, characters: '1234567890' }));
39+
40+
ctx.state.user[config.userFields.twoFactorRecoveryKeys] = recoveryKeys;
41+
ctx.state.user = await ctx.state.user.save();
42+
43+
await ctx.render('2fa/otp/keys');
44+
}
45+
46+
async function renderVerify(ctx) {
47+
ctx.state.twoFactorTokenURI = authenticator.keyuri(
48+
ctx.state.user.email,
49+
process.env.WEB_HOST,
50+
ctx.state.user[config.passport.fields.twoFactorToken]
51+
);
52+
ctx.state.qrcode = await qrcode.toDataURL(ctx.state.twoFactorTokenURI);
53+
54+
await ctx.render('2fa/otp/verify');
55+
}
56+
57+
async function disable(ctx) {
58+
const { body } = ctx.request;
59+
60+
const redirectTo = `/${ctx.locale}/my-account/security`;
61+
62+
if (!isSANB(body.password))
63+
throw Boom.badRequest(ctx.translate('INVALID_PASSWORD'));
64+
65+
const { user } = await ctx.state.user.authenticate(body.password);
66+
if (!user) throw Boom.badRequest(ctx.translate('INVALID_PASSWORD'));
67+
68+
ctx.state.user[config.passport.fields.twoFactorEnabled] = false;
69+
await ctx.state.user.save();
70+
71+
ctx.flash('custom', {
72+
title: ctx.request.t('Success'),
73+
text: ctx.translate('REQUEST_OK'),
74+
type: 'success',
75+
toast: true,
76+
showConfirmButton: false,
77+
timer: 3000,
78+
position: 'top'
79+
});
80+
81+
if (ctx.accepts('html')) ctx.redirect(redirectTo);
82+
else ctx.body = { redirectTo };
83+
}
84+
85+
async function verify(ctx) {
86+
const redirectTo = `/${ctx.locale}/my-account/security`;
87+
if (ctx.method === 'DELETE') {
88+
ctx.state.user[config.passport.fields.twoFactorEnabled] = false;
89+
} else if (
90+
ctx.method === 'POST' &&
91+
ctx.state.user[config.passport.fields.twoFactorToken]
92+
) {
93+
const isValid = authenticator.verify({
94+
token: ctx.request.body.token,
95+
secret: ctx.state.user[config.passport.fields.twoFactorToken]
96+
});
97+
98+
if (!isValid)
99+
return ctx.throw(Boom.badRequest(ctx.translate('INVALID_OTP_PASSCODE')));
100+
101+
ctx.state.user[config.passport.fields.twoFactorEnabled] = true;
102+
} else {
103+
return ctx.throw(Boom.badRequest('Invalid method'));
104+
}
105+
106+
await ctx.state.user.save();
107+
108+
ctx.session.otp = 'otp-setup';
109+
110+
ctx.flash('custom', {
111+
title: ctx.request.t('Success'),
112+
text: ctx.translate('REQUEST_OK'),
113+
type: 'success',
114+
toast: true,
115+
showConfirmButton: false,
116+
timer: 3000,
117+
position: 'top'
118+
});
119+
120+
if (ctx.accepts('html')) ctx.redirect(redirectTo);
121+
else ctx.body = { redirectTo };
122+
}
123+
124+
module.exports = {
125+
disable,
126+
keys,
127+
renderKeys,
128+
renderVerify,
129+
verify
130+
};

app/controllers/web/my-account.js

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
const Boom = require('@hapi/boom');
2-
const cryptoRandomString = require('crypto-random-string');
32
const humanize = require('humanize-string');
43
const isSANB = require('is-string-and-not-blank');
5-
const qrcode = require('qrcode');
64
const { authenticator } = require('otplib');
75
const { boolean } = require('boolean');
86

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

81-
async function security(ctx) {
82-
if (!ctx.state.user[config.passport.fields.twoFactorEnabled]) {
83-
ctx.state.user[
84-
config.passport.fields.twoFactorToken
85-
] = authenticator.generateSecret();
86-
87-
// generate 2fa recovery keys list used for fallback
88-
const recoveryKeys = new Array(16)
89-
.fill()
90-
.map(() => cryptoRandomString({ length: 10, characters: '1234567890' }));
91-
92-
ctx.state.user[config.userFields.twoFactorRecoveryKeys] = recoveryKeys;
93-
ctx.state.user = await ctx.state.user.save();
94-
ctx.state.twoFactorTokenURI = authenticator.keyuri(
95-
ctx.state.user.email,
96-
process.env.WEB_HOST,
97-
ctx.state.user[config.passport.fields.twoFactorToken]
98-
);
99-
ctx.state.qrcode = await qrcode.toDataURL(ctx.state.twoFactorTokenURI);
100-
}
101-
102-
await ctx.render('my-account/security');
103-
}
104-
10579
async function recoveryKeys(ctx) {
10680
const twoFactorRecoveryKeys =
10781
ctx.state.user[config.userFields.twoFactorRecoveryKeys];
@@ -158,6 +132,5 @@ module.exports = {
158132
update,
159133
recoveryKeys,
160134
resetAPIToken,
161-
security,
162135
setup2fa
163136
};

app/views/2fa/otp/keys.pug

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
2+
extends ../../layout
3+
4+
block body
5+
.container.py-3
6+
.row
7+
.col-xs-12.col-sm-12.col-md-6.offset-md-3.col-lg-6.offset-lg-3
8+
.card
9+
.card-body
10+
.text-center
11+
h1.card-title.h4= t('OTP Recovery Keys')
12+
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.')
13+
hr
14+
p.text-muted.font-weight-bold= t('Backup your recovery keys before continuing')
15+
.container
16+
.row
17+
label(for="two-factor-recovery-keys")= t('Recovery keys')
18+
textarea(rows='4').form-control#two-factor-recovery-keys
19+
= user[config.userFields.twoFactorRecoveryKeys].join('\n')
20+
.row.mt-3.mb-3
21+
.col-sm
22+
form(action=l('/my-account/recovery-keys'), method='POST')
23+
input(type="hidden", name="_csrf", value=ctx.csrf)
24+
button(type='submit').btn.btn-primary.btn-block
25+
i.fa.fa-download
26+
= ' '
27+
= t('Download')
28+
.col-sm.offset-sm-1
29+
button(type='button', data-toggle="clipboard", data-clipboard-target="#two-factor-recovery-keys").btn.btn-secondary.btn-block
30+
i.fa.fa-clipboard
31+
= ' '
32+
= t('Copy')
33+
form.ajax-form.confirm-prompt(action=ctx.path, method="POST", autocomplete="off")
34+
hr
35+
.row
36+
form(action=l('/2fa/otp/keys'), method='POST').col-md-12
37+
input(type="hidden", name="_csrf", value=ctx.csrf)
38+
.form-group.floating-label
39+
input#input-password.form-control(type="password", autocomplete="off", name="password" required)
40+
label(for="input-password")= t('Confirm Password')
41+
button.btn.btn-primary.btn-md.btn-block.mt-2(type="submit")= t('Continue')

app/views/2fa/otp-login.pug renamed to app/views/2fa/otp/login.pug

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11

2-
extends ../layout
2+
extends ../../layout
33

44
block body
55
.container.py-3

app/views/2fa/otp-recovery.pug renamed to app/views/2fa/otp/recovery.pug

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11

2-
extends ../layout
2+
extends ../../layout
33

44
block body
55
.container.py-3

app/views/2fa/otp/verify.pug

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
2+
extends ../../layout
3+
4+
block body
5+
#authenticator-apps-modal(tabindex='-1', role='dialog').modal.fade
6+
.modal-dialog(role='document')
7+
.modal-content
8+
.modal-header.d-block.text-center
9+
h6.modal-title.d-inline-block.ml-4= t('Authentication Apps')
10+
button(type='button', data-dismiss='modal', aria-label='Close').close
11+
span(aria-hidden='true') ×
12+
.modal-body.text-center
13+
= t('Recommendations are listed below:')
14+
.flex-wrap.flex-fill.text-center
15+
= t('Free and Open-Source Software:')
16+
ul.list-group.text-center.mb-3
17+
li.list-group-item.border-0
18+
a(href='https://freeotp.github.io/', rel='noopener', target='_blank') FreeOTP
19+
ul.list-inline
20+
li.list-inline-item
21+
a(href='https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
22+
i.fab.fa-google-play
23+
= ' '
24+
= t('Google Play')
25+
li.list-inline-item
26+
a(href='https://itunes.apple.com/us/app/freeotp-authenticator/id872559395?mt=8', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
27+
i.fab.fa-app-store-ios
28+
= ' '
29+
= t('App Store')
30+
li.list-group-item.border-0
31+
a(href='https://f-droid.org/en/packages/org.shadowice.flocke.andotp', rel='noopener', target='_blank') andOTP
32+
ul.list-inline
33+
li.list-inline-item
34+
a(href='https://f-droid.org/repo/org.shadowice.flocke.andotp_28.apk', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
35+
i.fab.fa-google-play
36+
= ' '
37+
= t('Google Play')
38+
= t('Closed-Source Software:')
39+
ul.list-group.text-center
40+
li.list-group-item.border-0
41+
a(href='https://authy.com/', rel='noopener', target='_blank') Authy
42+
ul.list-inline
43+
li.list-inline-item
44+
a(href='https://play.google.com/store/apps/details?id=com.authy.authy', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
45+
i.fab.fa-google-play
46+
= ' '
47+
= t('Google Play')
48+
li.list-inline-item
49+
a(href='https://itunes.apple.com/us/app/authy/id494168017', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
50+
i.fab.fa-app-store-ios
51+
= ' '
52+
= t('App Store')
53+
li.list-group-item.border-0.mb-4
54+
a(href='https://support.google.com/accounts/answer/1066447', rel='noopener', target='_blank') Google Authenticator
55+
ul.list-inline
56+
li.list-inline-item
57+
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
58+
i.fab.fa-google-play
59+
= ' '
60+
= t('Google Play')
61+
li.list-inline-item
62+
a(href='http://appstore.com/googleauthenticator', rel='noopener', target='_blank').btn.btn-sm.btn-outline-secondary
63+
i.fab.fa-app-store-ios
64+
= ' '
65+
= t('App Store')
66+
.container.py-3
67+
.row
68+
.col-xs-12.col-sm-12.col-md-6.offset-md-3.col-lg-6.offset-lg-3
69+
.card
70+
.card-body
71+
.text-center
72+
h1.card-title.h4= t('Enable OTP')
73+
p= t('Follow the below steps to enable two-factor authentication on your account.')
74+
hr
75+
.container
76+
form(action=l('/2fa/otp/verify'), method='POST').ajax-form.confirm-prompt
77+
input(type="hidden", name="_csrf", value=ctx.csrf)
78+
label(for='two-factor-step-one')
79+
b= t('Step 1: ')
80+
= t('Install an')
81+
= ' '
82+
a.card-link(href='#' data-toggle='modal-anchor', data-target='#authenticator-apps-modal').text-primary= t('authentication app')
83+
= ' '
84+
= t('on your device.')
85+
label(for='two-factor-step-two')
86+
b= t('Step 2: ')
87+
= t('Scan this QR code using the app:')
88+
img(src=qrcode, width=250, height=250, alt="").mx-auto.d-block
89+
hr
90+
label(for='two-factor-step-three')
91+
b= t('Step 3: ')
92+
= t('Enter the token generated from the app:')
93+
.form-group.floating-label
94+
input(type='text', name='token', required, placeholder=' ').form-control.form-control-lg#input-token
95+
label(for='input-token') Verification Token
96+
a.card-link.text-primary(href='#' data-toggle='collapse' data-target='#two-factor-copy')= t('Can’t scan the QR code? Follow alternative steps')
97+
#two-factor-copy.collapse
98+
hr
99+
p.text-secondary= t('Scan the following QR code in your authenticator app.')
100+
.input-group.input-group-sm.floating-label.form-group
101+
input(type='text', readonly, value=user[config.passport.fields.twoFactorToken]).form-control#two-factor-token
102+
.input-group-append
103+
button(type='button', data-toggle="clipboard", data-clipboard-target="#two-factor-token").btn.btn-primary
104+
i.fa.fa-clipboard
105+
= ' '
106+
= t('Copy')
107+
hr
108+
button(type='submit').btn.btn-lg.btn-block.btn-primary= t('Enable OTP')

0 commit comments

Comments
 (0)