Skip to content

Commit

Permalink
Change from basic auth to form based login (#562)
Browse files Browse the repository at this point in the history
* Restore login related files (with some tweaks) from 9afc03b

* Add eslint and editorconfig for linting and consistency

* Clean up the login page and unused scss

* Update server to respond to use passport users and server /login

* Implement form based login with passport, passport-local, and cookie-session

* Add log out button to footer

* Disable CSRFInput for now as it isn't properly implemented

* Remove unused basic-auth module

* Revert "Add eslint and editorconfig for linting and consistency"

This reverts commit 008092b.

* Add two more test cases for Authentication.authenticate

* Code clean up

* Add proper CSRF handling to login through express and existing React components
  • Loading branch information
JeremyPlease authored and flovilmart committed Nov 10, 2016
1 parent c49b057 commit be9f498
Show file tree
Hide file tree
Showing 16 changed files with 444 additions and 40 deletions.
77 changes: 69 additions & 8 deletions Parse-Dashboard/Authentication.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
"use strict";
var bcrypt = require('bcryptjs');
var csrf = require('csurf');
var passport = require('passport');
var LocalStrategy = require('passport-local').Strategy;

/**
* Constructor for Authentication class
Expand All @@ -8,8 +12,61 @@
* @param {boolean} useEncryptedPasswords
*/
function Authentication(validUsers, useEncryptedPasswords) {
this.validUsers = validUsers;
this.useEncryptedPasswords = useEncryptedPasswords || false;
this.validUsers = validUsers;
this.useEncryptedPasswords = useEncryptedPasswords || false;
}

function initialize(app) {
var self = this;
passport.use('local', new LocalStrategy(
function(username, password, cb) {
var match = self.authenticate({
name: username,
pass: password
});
if (!match.matchingUsername) {
return cb(null, false, { message: 'Invalid username or password' });
}
cb(null, match.matchingUsername);
})
);

passport.serializeUser(function(username, cb) {
cb(null, username);
});

passport.deserializeUser(function(username, cb) {
var user = self.authenticate({
name: username
}, true);
cb(null, user);
});

app.use(require('connect-flash')());
app.use(require('body-parser').urlencoded({ extended: true }));
app.use(require('cookie-session')({
key : 'parse_dash',
secret : 'magic',
cookie : {
maxAge: (2 * 7 * 24 * 60 * 60 * 1000) // 2 weeks
}
}));
app.use(passport.initialize());
app.use(passport.session());

app.post('/login',
csrf(),
passport.authenticate('local', {
successRedirect: '/apps',
failureRedirect: '/login',
failureFlash : true
})
);

app.get('/logout', function(req, res){
req.logout();
res.redirect('/login');
});
}

/**
Expand All @@ -18,20 +75,22 @@ function Authentication(validUsers, useEncryptedPasswords) {
* @param {Object} userToTest
* @returns {Object} Object with `isAuthenticated` and `appsUserHasAccessTo` properties
*/
function authenticate(userToTest) {
let bcrypt = require('bcryptjs');

function authenticate(userToTest, usernameOnly) {
var appsUserHasAccessTo = null;
var matchingUsername = null;

//they provided auth
let isAuthenticated = userToTest &&
//there are configured users
this.validUsers &&
//the provided auth matches one of the users
this.validUsers.find(user => {
let isAuthenticated = userToTest.name == user.user &&
(this.useEncryptedPasswords ? bcrypt.compareSync(userToTest.pass, user.pass) : userToTest.pass == user.pass);
if (isAuthenticated) {
let isAuthenticated = false;
let usernameMatches = userToTest.name == user.user;
let passwordMatches = this.useEncryptedPasswords ? bcrypt.compareSync(userToTest.pass, user.pass) : userToTest.pass == user.pass;
if (usernameMatches && (usernameOnly || passwordMatches)) {
isAuthenticated = true;
matchingUsername = user.user;
// User restricted apps
appsUserHasAccessTo = user.apps || null;
}
Expand All @@ -41,10 +100,12 @@ function authenticate(userToTest) {

return {
isAuthenticated,
matchingUsername,
appsUserHasAccessTo
};
}

Authentication.prototype.initialize = initialize;
Authentication.prototype.authenticate = authenticate;

module.exports = Authentication;
73 changes: 55 additions & 18 deletions Parse-Dashboard/app.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
'use strict';
const express = require('express');
const basicAuth = require('basic-auth');
const path = require('path');
const packageJson = require('package-json');
const csrf = require('csurf');
const Authentication = require('./Authentication.js');
var fs = require('fs');

const currentVersionFeatures = require('../package.json').parseDashboardFeatures;
Expand Down Expand Up @@ -58,22 +59,27 @@ module.exports = function(config, allowInsecureHTTP) {
app.enable('trust proxy');
}

const users = config.users;
const useEncryptedPasswords = config.useEncryptedPasswords ? true : false;
const authInstance = new Authentication(users, useEncryptedPasswords);
authInstance.initialize(app);

// CSRF error handler
app.use(function (err, req, res, next) {
if (err.code !== 'EBADCSRFTOKEN') return next(err)

// handle CSRF token errors here
res.status(403)
res.send('form tampered with')
});

// Serve the configuration.
app.get('/parse-dashboard-config.json', function(req, res) {
let response = {
apps: config.apps,
newFeaturesInLatestVersion: newFeaturesInLatestVersion,
};

const users = config.users;
const useEncryptedPasswords = config.useEncryptedPasswords ? true : false;

let auth = null;
//If they provide auth when their config has no users, ignore the auth
if (users) {
auth = basicAuth(req);
}

//Based on advice from Doug Wilson here:
//https://github.com/expressjs/express/issues/2518
const requestIsLocal =
Expand All @@ -90,12 +96,10 @@ module.exports = function(config, allowInsecureHTTP) {
return res.send({ success: false, error: 'Configure a user to access Parse Dashboard remotely' });
}

let Authentication = require('./Authentication');
const authInstance = new Authentication(users, useEncryptedPasswords);
const authentication = authInstance.authenticate(auth);

const successfulAuth = authentication.isAuthenticated;
const appsUserHasAccess = authentication.appsUserHasAccessTo;
const authentication = req.user;

const successfulAuth = authentication && authentication.isAuthenticated;
const appsUserHasAccess = authentication && authentication.appsUserHasAccessTo;

if (successfulAuth) {
if (appsUserHasAccess) {
Expand All @@ -111,9 +115,8 @@ module.exports = function(config, allowInsecureHTTP) {
return res.json(response);
}

if (users || auth) {
if (users) {
//They provided incorrect auth
res.set('WWW-Authenticate', 'Basic realm=Authorization Required');
return res.sendStatus(401);
}

Expand Down Expand Up @@ -146,8 +149,42 @@ module.exports = function(config, allowInsecureHTTP) {
}
}

app.get('/login', csrf(), function(req, res) {
if (!users || (req.user && req.user.isAuthenticated)) {
return res.redirect('/apps');
}
let mountPath = getMount(req);
let errors = req.flash('error');
if (errors && errors.length) {
errors = `<div id="login_errors" style="display: none;">
${errors.join(' ')}
</div>`
}
res.send(`<!DOCTYPE html>
<head>
<link rel="shortcut icon" type="image/x-icon" href="${mountPath}favicon.ico" />
<base href="${mountPath}"/>
<script>
PARSE_DASHBOARD_PATH = "${mountPath}";
</script>
</head>
<html>
<title>Parse Dashboard</title>
<body>
<div id="login_mount"></div>
${errors}
<script id="csrf" type="application/json">"${req.csrfToken()}"</script>
<script src="${mountPath}bundles/login.bundle.js"></script>
</body>
</html>
`);
});

// For every other request, go to index.html. Let client-side handle the rest.
app.get('/*', function(req, res) {
if (users && (!req.user || !req.user.isAuthenticated)) {
return res.redirect('/login');
}
let mountPath = getMount(req);
res.send(`<!DOCTYPE html>
<head>
Expand Down
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,17 @@
"LICENSE"
],
"dependencies": {
"basic-auth": "^1.0.3",
"bcryptjs": "^2.3.0",
"body-parser": "^1.15.2",
"commander": "^2.9.0",
"connect-flash": "^0.1.1",
"cookie-session": "^2.0.0-alpha.1",
"csurf": "^1.9.0",
"express": "^4.13.4",
"json-file-plus": "^3.2.0",
"package-json": "^2.3.1",
"bcryptjs": "^2.3.0"
"passport": "^0.3.2",
"passport-local": "^1.0.0"
},
"devDependencies": {
"babel-core": "~5.8.12",
Expand Down
2 changes: 1 addition & 1 deletion src/components/CSRFInput/CSRFInput.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import React from 'react';
// containing the CSRF token into a form
let CSRFInput = () => (
<div style={{ margin: 0, padding: 0, display: 'inline' }}>
<input name='authenticity_token' type='hidden' value={getToken()} />
<input name='_csrf' type='hidden' value={getToken()} />
</div>
);

Expand Down
65 changes: 65 additions & 0 deletions src/components/LoginForm/LoginForm.example.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2016-present, Parse, LLC
* All rights reserved.
*
* This source code is licensed under the license found in the LICENSE file in
* the root directory of this source tree.
*/
import LoginForm from 'components/LoginForm/LoginForm.react';
import LoginRow from 'components/LoginRow/LoginRow.react';
import React from 'react';

export const component = LoginForm;

export const demos = [
{
render() {
return (
<div style={{ background: '#06283D', height: 500, position: 'relative' }}>
<LoginForm
header='Access your Dashboard'
footer={<a href='javascript:;'>Forgot something?</a>}
action='Log In'>
<LoginRow
label='Email'
input={<input type='email' />} />
<LoginRow
label='Password'
input={<input type='password' />} />
</LoginForm>
</div>
);
}
}, {
render() {
return (
<div style={{ background: '#06283D', height: 700, position: 'relative' }}>
<LoginForm
header='Sign up with Parse'
footer={
<div>
<span>Signing up signifies that you have read and agree to the </span>
<a href='https://parse.com/about/terms'>Terms of Service</a>
<span> and </span>
<a href='https://parse.com/about/privacy'>Privacy Policy</a>.
</div>
}
action='Sign Up'>
<LoginRow
label='Email'
input={<input type='email' placeholder='email@domain' autoComplete='off' />} />
<LoginRow
label='Password'
input={<input type='password' placeholder='The stronger, the better' autoComplete='off' />} />
<LoginRow
label='App Name'
input={<input type='text' placeholder='Name your first app' />} />
<LoginRow
label='Company'
input={<input type='text' placeholder='(Optional)' />} />
</LoginForm>
</div>
);
}
}
];
45 changes: 45 additions & 0 deletions src/components/LoginForm/LoginForm.react.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2016-present, Parse, LLC
* All rights reserved.
*
* This source code is licensed under the license found in the LICENSE file in
* the root directory of this source tree.
*/
import CSRFInput from 'components/CSRFInput/CSRFInput.react';
import Icon from 'components/Icon/Icon.react';
import PropTypes from 'lib/PropTypes';
import React from 'react';
import styles from 'components/LoginForm/LoginForm.scss';
import { verticalCenter } from 'stylesheets/base.scss';

// Class-style component, because we need refs
export default class LoginForm extends React.Component {
render() {
return (
<div className={styles.login} style={{ marginTop: this.props.marginTop || '-220px' }}>
<Icon width={80} height={80} name='infinity' fill='#093A59' />
<form method='post' ref='form' action={this.props.endpoint} className={styles.form}>
<CSRFInput />
<div className={styles.header}>{this.props.header}</div>
{this.props.children}
<div className={styles.footer}>
<div className={verticalCenter} style={{ width: '100%' }}>
{this.props.footer}
</div>
</div>
<input
type='submit'
disabled={!!this.props.disableSubmit}
onClick={() => {
if (this.props.disableSubmit) {
return;
}
this.refs.form.submit()
}}
className={styles.submit}
value={this.props.action} />
</form>
</div>
);
}
}
Loading

3 comments on commit be9f498

@JeremyPlease
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@flovilmart @drew-gross

Is there anything else that needs to be done in order to make a new release off the latest master?

Some more unit tests would be nice and I'd be happy to add some in the coming weeks. Just hoping we can get a release sooner than later.

@flovilmart
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can release I believe :)

@flovilmart
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs a PR for the changelog, as well as tag :)

Please sign in to comment.