Skip to content

Commit

Permalink
feat: Add MFA to Dashboard (#1624)
Browse files Browse the repository at this point in the history
* 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 <5673677+mtrezza@users.noreply.github.com>
  • Loading branch information
dblythy and mtrezza committed Sep 7, 2021
1 parent fe52082 commit 29a4b48
Show file tree
Hide file tree
Showing 12 changed files with 811 additions and 166 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
[Full Changelog](https://github.com/parse-community/parse-dashboard/compare/2.2.0...master)

## New Features
- 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)

## Improvements
- CI now pushes docker images to Docker Hub (Corey Baker) [#1781](https://github.com/parse-community/parse-dashboard/pull/1781)
- Add CI check to add changelog entry (Manuel Trezza) [#1764](https://github.com/parse-community/parse-dashboard/pull/1764)
Expand Down
34 changes: 31 additions & 3 deletions Parse-Dashboard/Authentication.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ var bcrypt = require('bcryptjs');
var csrf = require('csurf');
var passport = require('passport');
var LocalStrategy = require('passport-local').Strategy;
const OTPAuth = require('otpauth')

/**
* Constructor for Authentication class
Expand All @@ -21,14 +22,22 @@ function initialize(app, options) {
options = options || {};
var self = this;
passport.use('local', new LocalStrategy(
function(username, password, cb) {
{passReqToCallback:true},
function(req, username, password, cb) {
var match = self.authenticate({
name: username,
pass: password
pass: password,
otpCode: req.body.otpCode
});
if (!match.matchingUsername) {
return cb(null, false, { message: 'Invalid username or password' });
}
if (match.otpMissing) {
return cb(null, false, { message: 'Please enter your one-time password.' });
}
if (!match.otpValid) {
return cb(null, false, { message: 'Invalid one-time password.' });
}
cb(null, match.matchingUsername);
})
);
Expand Down Expand Up @@ -82,6 +91,8 @@ function authenticate(userToTest, usernameOnly) {
let appsUserHasAccessTo = null;
let matchingUsername = null;
let isReadOnly = false;
let otpMissing = false;
let otpValid = true;

//they provided auth
let isAuthenticated = userToTest &&
Expand All @@ -91,6 +102,22 @@ function authenticate(userToTest, usernameOnly) {
this.validUsers.find(user => {
let isAuthenticated = false;
let usernameMatches = userToTest.name == user.user;
if (usernameMatches && user.mfa && !usernameOnly) {
if (!userToTest.otpCode) {
otpMissing = true;
} else {
const totp = new OTPAuth.TOTP({
algorithm: user.mfaAlgorithm || 'SHA1',
secret: OTPAuth.Secret.fromBase32(user.mfa)
});
const valid = totp.validate({
token: userToTest.otpCode
});
if (valid === null) {
otpValid = false;
}
}
}
let passwordMatches = this.useEncryptedPasswords && !usernameOnly ? bcrypt.compareSync(userToTest.pass, user.pass) : userToTest.pass == user.pass;
if (usernameMatches && (usernameOnly || passwordMatches)) {
isAuthenticated = true;
Expand All @@ -99,13 +126,14 @@ function authenticate(userToTest, usernameOnly) {
appsUserHasAccessTo = user.apps || null;
isReadOnly = !!user.readOnly; // make it true/false
}

return isAuthenticated;
}) ? true : false;

return {
isAuthenticated,
matchingUsername,
otpMissing,
otpValid,
appsUserHasAccessTo,
isReadOnly,
};
Expand Down
225 changes: 225 additions & 0 deletions Parse-Dashboard/CLI/mfa.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
const crypto = require('crypto');
const inquirer = require('inquirer');
const OTPAuth = require('otpauth');
const { copy } = require('./utils.js');
const phrases = {
enterPassword: 'Enter a password:',
enterUsername: 'Enter a username:',
enterAppName: 'Enter the app name:',
}
const getAlgorithm = async () => {
let { algorithm } = await inquirer.prompt([
{
type: 'list',
name: 'algorithm',
message: 'Which hashing algorithm do you want to use?',
default: 'SHA1',
choices: [
'SHA1',
'SHA224',
'SHA256',
'SHA384',
'SHA512',
'SHA3-224',
'SHA3-256',
'SHA3-384',
'SHA3-512',
'Other'
]
}
]);
if (algorithm === 'Other') {
const result = await inquirer.prompt([
{
type: 'input',
name: 'algorithm',
message: 'Enter the hashing algorithm you want to use:'
}
]);
algorithm = result.algorithm;
}
const { digits, period } = await inquirer.prompt([
{
type: 'number',
name: 'digits',
default: 6,
message: 'Enter the number of digits the one-time password should have:'
},
{
type: 'number',
name: 'period',
default: 30,
message: 'Enter how long the one-time password should be valid (in seconds):'
}
])
return { algorithm, digits, period};
};
const generateSecret = ({ app, username, algorithm, digits, period }) => {
const secret = new OTPAuth.Secret();
const totp = new OTPAuth.TOTP({
issuer: app,
label: username,
algorithm,
digits,
period,
secret
});
const url = totp.toString();
return { secret: secret.base32, url };
};
const showQR = text => {
const QRCode = require('qrcode');
QRCode.toString(text, { type: 'terminal' }, (err, url) => {
console.log(
'\n------------------------------------------------------------------------------' +
`\n\n${url}`
);
});
};

const showInstructions = ({ app, username, passwordCopied, secret, url, encrypt, config }) => {
let orderCounter = 0;
const getOrder = () => {
orderCounter++;
return orderCounter;
}
console.log(
'------------------------------------------------------------------------------' +
'\n\nFollow these steps to complete the set-up:'
);

console.log(
`\n${getOrder()}. Add the following settings for user "${username}" ${app ? `in app "${app}" ` : '' }to the Parse Dashboard configuration.` +
`\n\n ${JSON.stringify(config)}`
);

if (passwordCopied) {
console.log(
`\n${getOrder()}. Securely store the generated login password that has been copied to your clipboard.`
);
}

if (secret) {
console.log(
`\n${getOrder()}. Open the authenticator app to scan the QR code above or enter this secret code:` +
`\n\n ${secret}` +
'\n\n If the secret code generates incorrect one-time passwords, try this alternative:' +
`\n\n ${url}` +
`\n\n${getOrder()}. Destroy any records of the QR code and the secret code to secure the account.`
);
}

if (encrypt) {
console.log(
`\n${getOrder()}. Make sure that "useEncryptedPasswords" is set to "true" in your dashboard configuration.` +
'\n You chose to generate an encrypted password for this user.' +
'\n Any existing users with non-encrypted passwords will require newly created, encrypted passwords.'
);
}
console.log(
'\n------------------------------------------------------------------------------\n'
);
}

module.exports = {
async createUser() {
const data = {};

console.log('');
const { username, password } = await inquirer.prompt([
{
type: 'input',
name: 'username',
message: phrases.enterUsername
},
{
type: 'confirm',
name: 'password',
message: 'Do you want to auto-generate a password?'
}
]);
data.user = username;
if (!password) {
const { password } = await inquirer.prompt([
{
type: 'password',
name: 'password',
message: phrases.enterPassword
}
]);
data.pass = password;
} else {
const password = crypto.randomBytes(20).toString('base64');
data.pass = password;
}
const { mfa, encrypt } = await inquirer.prompt([
{
type: 'confirm',
name: 'encrypt',
message: 'Should the password be encrypted? (strongly recommended, otherwise it is stored in clear-text)'
},
{
type: 'confirm',
name: 'mfa',
message: 'Do you want to enable multi-factor authentication?'
}
]);
if (encrypt) {
// Copy the raw password to clipboard
copy(data.pass);

// Encrypt password
const bcrypt = require('bcryptjs');
const salt = bcrypt.genSaltSync(10);
data.pass = bcrypt.hashSync(data.pass, salt);
}
if (mfa) {
const { app } = await inquirer.prompt([
{
type: 'input',
name: 'app',
message: phrases.enterAppName
}
]);
const { algorithm, digits, period } = await getAlgorithm();
const { secret, url } = generateSecret({ app, username, algorithm, digits, period });
data.mfa = secret;
data.app = app;
data.url = url;
if (algorithm !== 'SHA1') {
data.mfaAlgorithm = algorithm;
}
showQR(data.url);
}

const config = { mfa: data.mfa, user: data.user, pass: data.pass };
showInstructions({ app: data.app, username, passwordCopied: true, secret: data.mfa, url: data.url, encrypt, config });
},
async createMFA() {
console.log('');
const { username, app } = await inquirer.prompt([
{
type: 'input',
name: 'username',
message:
'Enter the username for which you want to enable multi-factor authentication:'
},
{
type: 'input',
name: 'app',
message: phrases.enterAppName
}
]);
const { algorithm, digits, period } = await getAlgorithm();

const { url, secret } = generateSecret({ app, username, algorithm, digits, period });
showQR(url);

// Compose config
const config = { mfa: secret };
if (algorithm !== 'SHA1') {
config.mfaAlgorithm = algorithm;
}
showInstructions({ app, username, secret, url, config });
}
};
7 changes: 7 additions & 0 deletions Parse-Dashboard/CLI/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
copy(text) {
const proc = require('child_process').spawn('pbcopy');
proc.stdin.write(text);
proc.stdin.end();
}
}
6 changes: 6 additions & 0 deletions Parse-Dashboard/CLIHelper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const { createUser, createMFA } = require('./CLI/mfa');

module.exports = {
createUser,
createMFA
};
11 changes: 11 additions & 0 deletions Parse-Dashboard/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const path = require('path');
const jsonFile = require('json-file-plus');
const express = require('express');
const parseDashboard = require('./app');
const CLIHelper = require('./CLIHelper.js');

const program = require('commander');
program.option('--appId [appId]', 'the app Id of the app you would like to manage.');
Expand All @@ -28,9 +29,19 @@ program.option('--sslKey [sslKey]', 'the path to the SSL private key.');
program.option('--sslCert [sslCert]', 'the path to the SSL certificate.');
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.');
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');
program.option('--createUser', 'helper tool to allow you to generate secure user passwords and secrets. Use this on trusted devices only.');
program.option('--createMFA', 'helper tool to allow you to generate multi-factor authentication secrets.');

program.parse(process.argv);

for (const key in program) {
const func = CLIHelper[key];
if (func && typeof func === 'function') {
func();
return;
}
}

const host = program.host || process.env.HOST || '0.0.0.0';
const port = program.port || process.env.PORT || 4040;
const mountPath = program.mountPath || process.env.MOUNT_PATH || '/';
Expand Down
Loading

0 comments on commit 29a4b48

Please sign in to comment.