Permalink
Browse files

Change from basic auth to form based login (#562)

* 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...
1 parent c49b057 commit be9f498efe78211e6478ec9ce2d4e2cb959830d0 @JeremyPlease JeremyPlease committed with flovilmart Nov 10, 2016
@@ -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
@@ -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');
+ });
}
/**
@@ -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;
}
@@ -41,10 +100,12 @@ function authenticate(userToTest) {
return {
isAuthenticated,
+ matchingUsername,
appsUserHasAccessTo
};
}
+Authentication.prototype.initialize = initialize;
Authentication.prototype.authenticate = authenticate;
module.exports = Authentication;
@@ -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;
@@ -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 =
@@ -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) {
@@ -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);
}
@@ -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>
View
@@ -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",
@@ -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>
);
@@ -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>
+ );
+ }
+ }
+];
@@ -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>
+ );
+ }
+}
Oops, something went wrong.

3 comments on commit be9f498

@JeremyPlease
Contributor

@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
Collaborator

we can release I believe :)

@flovilmart
Collaborator

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

Please sign in to comment.