Skip to content

Commit 29a4b48

Browse files
dblythymtrezza
andauthored
feat: Add MFA to Dashboard (#1624)
* Update CloudCode.react.js * Allow Writing Cloud Code * Add MFA to Dashboard * add inquirer * add changelog * Update index.js * Update package.json * Revert "Update CloudCode.react.js" This reverts commit e9d3ea7. * Revert "Allow Writing Cloud Code" This reverts commit 2a5c050. * Update index.js * Update README.md * Update index.js * hide otp field by default * change to one-time * change to otp * fix package-lock * add readme * Update Authentication.js * change to SHA256 * Update CHANGELOG.md * Update README.md * use OTPAuth secrets * Update index.js * Update index.js * add cli helper * change to SHA1 * add digits option * refactoring mfa flow * more simplification * fixed unsafe instructions * fixed password copy to clipboard * add newline before CLI questions * style * refactored readme * removed RASS * replaced URL with secret * added url and secret to output Co-authored-by: Manuel <[email protected]>
1 parent fe52082 commit 29a4b48

File tree

12 files changed

+811
-166
lines changed

12 files changed

+811
-166
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
[Full Changelog](https://github.com/parse-community/parse-dashboard/compare/2.2.0...master)
55

66
## New Features
7+
- Add multi-factor authentication to dashboard login. To use one-time password, run `parse-dashboard --createMFA` or `parse-dashboard --createUser`. (Daniel Blyth) [#1624](https://github.com/parse-community/parse-dashboard/pull/1624)
8+
79
## Improvements
810
- CI now pushes docker images to Docker Hub (Corey Baker) [#1781](https://github.com/parse-community/parse-dashboard/pull/1781)
911
- Add CI check to add changelog entry (Manuel Trezza) [#1764](https://github.com/parse-community/parse-dashboard/pull/1764)

Parse-Dashboard/Authentication.js

+31-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ var bcrypt = require('bcryptjs');
33
var csrf = require('csurf');
44
var passport = require('passport');
55
var LocalStrategy = require('passport-local').Strategy;
6+
const OTPAuth = require('otpauth')
67

78
/**
89
* Constructor for Authentication class
@@ -21,14 +22,22 @@ function initialize(app, options) {
2122
options = options || {};
2223
var self = this;
2324
passport.use('local', new LocalStrategy(
24-
function(username, password, cb) {
25+
{passReqToCallback:true},
26+
function(req, username, password, cb) {
2527
var match = self.authenticate({
2628
name: username,
27-
pass: password
29+
pass: password,
30+
otpCode: req.body.otpCode
2831
});
2932
if (!match.matchingUsername) {
3033
return cb(null, false, { message: 'Invalid username or password' });
3134
}
35+
if (match.otpMissing) {
36+
return cb(null, false, { message: 'Please enter your one-time password.' });
37+
}
38+
if (!match.otpValid) {
39+
return cb(null, false, { message: 'Invalid one-time password.' });
40+
}
3241
cb(null, match.matchingUsername);
3342
})
3443
);
@@ -82,6 +91,8 @@ function authenticate(userToTest, usernameOnly) {
8291
let appsUserHasAccessTo = null;
8392
let matchingUsername = null;
8493
let isReadOnly = false;
94+
let otpMissing = false;
95+
let otpValid = true;
8596

8697
//they provided auth
8798
let isAuthenticated = userToTest &&
@@ -91,6 +102,22 @@ function authenticate(userToTest, usernameOnly) {
91102
this.validUsers.find(user => {
92103
let isAuthenticated = false;
93104
let usernameMatches = userToTest.name == user.user;
105+
if (usernameMatches && user.mfa && !usernameOnly) {
106+
if (!userToTest.otpCode) {
107+
otpMissing = true;
108+
} else {
109+
const totp = new OTPAuth.TOTP({
110+
algorithm: user.mfaAlgorithm || 'SHA1',
111+
secret: OTPAuth.Secret.fromBase32(user.mfa)
112+
});
113+
const valid = totp.validate({
114+
token: userToTest.otpCode
115+
});
116+
if (valid === null) {
117+
otpValid = false;
118+
}
119+
}
120+
}
94121
let passwordMatches = this.useEncryptedPasswords && !usernameOnly ? bcrypt.compareSync(userToTest.pass, user.pass) : userToTest.pass == user.pass;
95122
if (usernameMatches && (usernameOnly || passwordMatches)) {
96123
isAuthenticated = true;
@@ -99,13 +126,14 @@ function authenticate(userToTest, usernameOnly) {
99126
appsUserHasAccessTo = user.apps || null;
100127
isReadOnly = !!user.readOnly; // make it true/false
101128
}
102-
103129
return isAuthenticated;
104130
}) ? true : false;
105131

106132
return {
107133
isAuthenticated,
108134
matchingUsername,
135+
otpMissing,
136+
otpValid,
109137
appsUserHasAccessTo,
110138
isReadOnly,
111139
};

Parse-Dashboard/CLI/mfa.js

+225
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
const crypto = require('crypto');
2+
const inquirer = require('inquirer');
3+
const OTPAuth = require('otpauth');
4+
const { copy } = require('./utils.js');
5+
const phrases = {
6+
enterPassword: 'Enter a password:',
7+
enterUsername: 'Enter a username:',
8+
enterAppName: 'Enter the app name:',
9+
}
10+
const getAlgorithm = async () => {
11+
let { algorithm } = await inquirer.prompt([
12+
{
13+
type: 'list',
14+
name: 'algorithm',
15+
message: 'Which hashing algorithm do you want to use?',
16+
default: 'SHA1',
17+
choices: [
18+
'SHA1',
19+
'SHA224',
20+
'SHA256',
21+
'SHA384',
22+
'SHA512',
23+
'SHA3-224',
24+
'SHA3-256',
25+
'SHA3-384',
26+
'SHA3-512',
27+
'Other'
28+
]
29+
}
30+
]);
31+
if (algorithm === 'Other') {
32+
const result = await inquirer.prompt([
33+
{
34+
type: 'input',
35+
name: 'algorithm',
36+
message: 'Enter the hashing algorithm you want to use:'
37+
}
38+
]);
39+
algorithm = result.algorithm;
40+
}
41+
const { digits, period } = await inquirer.prompt([
42+
{
43+
type: 'number',
44+
name: 'digits',
45+
default: 6,
46+
message: 'Enter the number of digits the one-time password should have:'
47+
},
48+
{
49+
type: 'number',
50+
name: 'period',
51+
default: 30,
52+
message: 'Enter how long the one-time password should be valid (in seconds):'
53+
}
54+
])
55+
return { algorithm, digits, period};
56+
};
57+
const generateSecret = ({ app, username, algorithm, digits, period }) => {
58+
const secret = new OTPAuth.Secret();
59+
const totp = new OTPAuth.TOTP({
60+
issuer: app,
61+
label: username,
62+
algorithm,
63+
digits,
64+
period,
65+
secret
66+
});
67+
const url = totp.toString();
68+
return { secret: secret.base32, url };
69+
};
70+
const showQR = text => {
71+
const QRCode = require('qrcode');
72+
QRCode.toString(text, { type: 'terminal' }, (err, url) => {
73+
console.log(
74+
'\n------------------------------------------------------------------------------' +
75+
`\n\n${url}`
76+
);
77+
});
78+
};
79+
80+
const showInstructions = ({ app, username, passwordCopied, secret, url, encrypt, config }) => {
81+
let orderCounter = 0;
82+
const getOrder = () => {
83+
orderCounter++;
84+
return orderCounter;
85+
}
86+
console.log(
87+
'------------------------------------------------------------------------------' +
88+
'\n\nFollow these steps to complete the set-up:'
89+
);
90+
91+
console.log(
92+
`\n${getOrder()}. Add the following settings for user "${username}" ${app ? `in app "${app}" ` : '' }to the Parse Dashboard configuration.` +
93+
`\n\n ${JSON.stringify(config)}`
94+
);
95+
96+
if (passwordCopied) {
97+
console.log(
98+
`\n${getOrder()}. Securely store the generated login password that has been copied to your clipboard.`
99+
);
100+
}
101+
102+
if (secret) {
103+
console.log(
104+
`\n${getOrder()}. Open the authenticator app to scan the QR code above or enter this secret code:` +
105+
`\n\n ${secret}` +
106+
'\n\n If the secret code generates incorrect one-time passwords, try this alternative:' +
107+
`\n\n ${url}` +
108+
`\n\n${getOrder()}. Destroy any records of the QR code and the secret code to secure the account.`
109+
);
110+
}
111+
112+
if (encrypt) {
113+
console.log(
114+
`\n${getOrder()}. Make sure that "useEncryptedPasswords" is set to "true" in your dashboard configuration.` +
115+
'\n You chose to generate an encrypted password for this user.' +
116+
'\n Any existing users with non-encrypted passwords will require newly created, encrypted passwords.'
117+
);
118+
}
119+
console.log(
120+
'\n------------------------------------------------------------------------------\n'
121+
);
122+
}
123+
124+
module.exports = {
125+
async createUser() {
126+
const data = {};
127+
128+
console.log('');
129+
const { username, password } = await inquirer.prompt([
130+
{
131+
type: 'input',
132+
name: 'username',
133+
message: phrases.enterUsername
134+
},
135+
{
136+
type: 'confirm',
137+
name: 'password',
138+
message: 'Do you want to auto-generate a password?'
139+
}
140+
]);
141+
data.user = username;
142+
if (!password) {
143+
const { password } = await inquirer.prompt([
144+
{
145+
type: 'password',
146+
name: 'password',
147+
message: phrases.enterPassword
148+
}
149+
]);
150+
data.pass = password;
151+
} else {
152+
const password = crypto.randomBytes(20).toString('base64');
153+
data.pass = password;
154+
}
155+
const { mfa, encrypt } = await inquirer.prompt([
156+
{
157+
type: 'confirm',
158+
name: 'encrypt',
159+
message: 'Should the password be encrypted? (strongly recommended, otherwise it is stored in clear-text)'
160+
},
161+
{
162+
type: 'confirm',
163+
name: 'mfa',
164+
message: 'Do you want to enable multi-factor authentication?'
165+
}
166+
]);
167+
if (encrypt) {
168+
// Copy the raw password to clipboard
169+
copy(data.pass);
170+
171+
// Encrypt password
172+
const bcrypt = require('bcryptjs');
173+
const salt = bcrypt.genSaltSync(10);
174+
data.pass = bcrypt.hashSync(data.pass, salt);
175+
}
176+
if (mfa) {
177+
const { app } = await inquirer.prompt([
178+
{
179+
type: 'input',
180+
name: 'app',
181+
message: phrases.enterAppName
182+
}
183+
]);
184+
const { algorithm, digits, period } = await getAlgorithm();
185+
const { secret, url } = generateSecret({ app, username, algorithm, digits, period });
186+
data.mfa = secret;
187+
data.app = app;
188+
data.url = url;
189+
if (algorithm !== 'SHA1') {
190+
data.mfaAlgorithm = algorithm;
191+
}
192+
showQR(data.url);
193+
}
194+
195+
const config = { mfa: data.mfa, user: data.user, pass: data.pass };
196+
showInstructions({ app: data.app, username, passwordCopied: true, secret: data.mfa, url: data.url, encrypt, config });
197+
},
198+
async createMFA() {
199+
console.log('');
200+
const { username, app } = await inquirer.prompt([
201+
{
202+
type: 'input',
203+
name: 'username',
204+
message:
205+
'Enter the username for which you want to enable multi-factor authentication:'
206+
},
207+
{
208+
type: 'input',
209+
name: 'app',
210+
message: phrases.enterAppName
211+
}
212+
]);
213+
const { algorithm, digits, period } = await getAlgorithm();
214+
215+
const { url, secret } = generateSecret({ app, username, algorithm, digits, period });
216+
showQR(url);
217+
218+
// Compose config
219+
const config = { mfa: secret };
220+
if (algorithm !== 'SHA1') {
221+
config.mfaAlgorithm = algorithm;
222+
}
223+
showInstructions({ app, username, secret, url, config });
224+
}
225+
};

Parse-Dashboard/CLI/utils.js

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module.exports = {
2+
copy(text) {
3+
const proc = require('child_process').spawn('pbcopy');
4+
proc.stdin.write(text);
5+
proc.stdin.end();
6+
}
7+
}

Parse-Dashboard/CLIHelper.js

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
const { createUser, createMFA } = require('./CLI/mfa');
2+
3+
module.exports = {
4+
createUser,
5+
createMFA
6+
};

Parse-Dashboard/index.js

+11
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const path = require('path');
1111
const jsonFile = require('json-file-plus');
1212
const express = require('express');
1313
const parseDashboard = require('./app');
14+
const CLIHelper = require('./CLIHelper.js');
1415

1516
const program = require('commander');
1617
program.option('--appId [appId]', 'the app Id of the app you would like to manage.');
@@ -28,9 +29,19 @@ program.option('--sslKey [sslKey]', 'the path to the SSL private key.');
2829
program.option('--sslCert [sslCert]', 'the path to the SSL certificate.');
2930
program.option('--trustProxy [trustProxy]', 'set this flag when you are behind a front-facing proxy, such as when hosting on Heroku. Uses X-Forwarded-* headers to determine the client\'s connection and IP address.');
3031
program.option('--cookieSessionSecret [cookieSessionSecret]', 'set the cookie session secret, defaults to a random string. You should set that value if you want sessions to work across multiple server, or across restarts');
32+
program.option('--createUser', 'helper tool to allow you to generate secure user passwords and secrets. Use this on trusted devices only.');
33+
program.option('--createMFA', 'helper tool to allow you to generate multi-factor authentication secrets.');
3134

3235
program.parse(process.argv);
3336

37+
for (const key in program) {
38+
const func = CLIHelper[key];
39+
if (func && typeof func === 'function') {
40+
func();
41+
return;
42+
}
43+
}
44+
3445
const host = program.host || process.env.HOST || '0.0.0.0';
3546
const port = program.port || process.env.PORT || 4040;
3647
const mountPath = program.mountPath || process.env.MOUNT_PATH || '/';

0 commit comments

Comments
 (0)