diff --git a/Parse-Dashboard/Authentication.js b/Parse-Dashboard/Authentication.js
index aea2ed6515..81d7b57f08 100644
--- a/Parse-Dashboard/Authentication.js
+++ b/Parse-Dashboard/Authentication.js
@@ -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,10 +75,9 @@ 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 &&
@@ -29,9 +85,12 @@ function authenticate(userToTest) {
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;
diff --git a/Parse-Dashboard/app.js b/Parse-Dashboard/app.js
index 7676ce35aa..69037e7178 100644
--- a/Parse-Dashboard/app.js
+++ b/Parse-Dashboard/app.js
@@ -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,6 +59,20 @@ 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 = {
@@ -65,15 +80,6 @@ module.exports = function(config, allowInsecureHTTP) {
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 = `
+ ${errors.join(' ')}
+
`
+ }
+ res.send(`
+
+
+
+
+
+
+ Parse Dashboard
+
+
+ ${errors}
+
+
+
+
+ `);
+ });
+
// 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(`
diff --git a/package.json b/package.json
index 8de782f4e9..11e8ed6dc5 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/CSRFInput/CSRFInput.react.js b/src/components/CSRFInput/CSRFInput.react.js
index f488d7e910..ff5f9e316e 100644
--- a/src/components/CSRFInput/CSRFInput.react.js
+++ b/src/components/CSRFInput/CSRFInput.react.js
@@ -12,7 +12,7 @@ import React from 'react';
// containing the CSRF token into a form
let CSRFInput = () => (
-
+
);
diff --git a/src/components/LoginForm/LoginForm.example.js b/src/components/LoginForm/LoginForm.example.js
new file mode 100644
index 0000000000..b7711238e7
--- /dev/null
+++ b/src/components/LoginForm/LoginForm.example.js
@@ -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 (
+
+ Forgot something?}
+ action='Log In'>
+ } />
+ } />
+
+
+ );
+ }
+ }, {
+ render() {
+ return (
+
+ }
+ action='Sign Up'>
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+ }
+ }
+];
diff --git a/src/components/LoginForm/LoginForm.react.js b/src/components/LoginForm/LoginForm.react.js
new file mode 100644
index 0000000000..b62e250350
--- /dev/null
+++ b/src/components/LoginForm/LoginForm.react.js
@@ -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 (
+
+ );
+ }
+}
diff --git a/src/components/LoginForm/LoginForm.scss b/src/components/LoginForm/LoginForm.scss
new file mode 100644
index 0000000000..1481175028
--- /dev/null
+++ b/src/components/LoginForm/LoginForm.scss
@@ -0,0 +1,79 @@
+/*
+ * 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 'stylesheets/globals.scss';
+
+.login {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin-left: -175px;
+ text-align: center;
+ width: 350px;
+}
+
+.form {
+ margin-top: 30px;
+}
+
+.header {
+ height: 50px;
+ line-height: 50px;
+ border-radius: 5px 5px 0 0;
+ color: $blue;
+ font-size: 16px;
+ font-weight: 700;
+ text-align: center;
+ background: white;
+ border-bottom: 1px solid #e0e0e1;
+}
+
+.footer {
+ @include NotoSansFont;
+ position: relative;
+ height: 40px;
+ padding: 10px 0;
+ width: 100%;
+ background: white;
+ border-radius: 0 0 5px 5px;
+ color: #c1c0c9;
+ font-size: 12px;
+ text-align: center;
+
+ a {
+ color: #c1c0c9;
+
+ &:hover {
+ color: $mainTextColor;
+ }
+ }
+}
+
+.submit {
+ @include NotoSansFont;
+ display: block;
+ background: $green;
+ height: 42px;
+ width: 100%;
+ font-size: 16px;
+ line-height: 42px;
+ border-radius: 5px;
+ color: white;
+ margin-top: 15px;
+ outline: none;
+ border: none;
+ cursor: pointer;
+
+ &:hover, &:focus {
+ background: $darkGreen;
+ }
+
+ &:disabled {
+ background: #dadada;
+ cursor: default;
+ }
+}
diff --git a/src/components/Sidebar/FooterMenu.react.js b/src/components/Sidebar/FooterMenu.react.js
index ae5ab80bdd..271119219d 100644
--- a/src/components/Sidebar/FooterMenu.react.js
+++ b/src/components/Sidebar/FooterMenu.react.js
@@ -42,6 +42,7 @@ export default class FooterMenu extends React.Component {
position={this.state.position}
onExternalClick={() => this.setState({ show: false })}>
diff --git a/src/lib/tests/Authentication.test.js b/src/lib/tests/Authentication.test.js
index a2016df0b1..8320e2c1cc 100644
--- a/src/lib/tests/Authentication.test.js
+++ b/src/lib/tests/Authentication.test.js
@@ -33,9 +33,10 @@ const encryptedUsers = [
}
]
-function createAuthenticationResult(isAuthenticated, appsUserHasAccessTo) {
+function createAuthenticationResult(isAuthenticated, matchingUsername, appsUserHasAccessTo) {
return {
isAuthenticated,
+ matchingUsername,
appsUserHasAccessTo
}
}
@@ -44,48 +45,60 @@ describe('Authentication', () => {
it('does not authenticate with no users', () => {
let authentication = new Authentication(null, false);
expect(authentication.authenticate({name: 'parse.dashboard', pass: 'abc123'}))
- .toEqual(createAuthenticationResult(false, null));
+ .toEqual(createAuthenticationResult(false, null, null));
});
it('does not authenticate with no auth', () => {
let authentication = new Authentication(unencryptedUsers, false);
expect(authentication.authenticate(null))
- .toEqual(createAuthenticationResult(false, null));
+ .toEqual(createAuthenticationResult(false, null, null));
});
it('does not authenticate invalid user', () => {
let authentication = new Authentication(unencryptedUsers, false);
expect(authentication.authenticate({name: 'parse.invalid', pass: 'abc123'}))
- .toEqual(createAuthenticationResult(false, null));
+ .toEqual(createAuthenticationResult(false, null, null));
});
it('does not authenticate valid user with invalid unencrypted password', () => {
let authentication = new Authentication(unencryptedUsers, false);
expect(authentication.authenticate({name: 'parse.dashboard', pass: 'xyz789'}))
- .toEqual(createAuthenticationResult(false, null));
+ .toEqual(createAuthenticationResult(false, null, null));
});
it('authenticates valid user with valid unencrypted password', () => {
let authentication = new Authentication(unencryptedUsers, false);
expect(authentication.authenticate({name: 'parse.dashboard', pass: 'abc123'}))
- .toEqual(createAuthenticationResult(true, null));
+ .toEqual(createAuthenticationResult(true, 'parse.dashboard', null));
});
it('returns apps if valid user', () => {
let authentication = new Authentication(unencryptedUsers, false);
expect(authentication.authenticate({name: 'parse.apps', pass: 'xyz789'}))
- .toEqual(createAuthenticationResult(true, apps));
+ .toEqual(createAuthenticationResult(true, 'parse.apps', apps));
});
it('authenticates valid user with valid encrypted password', () => {
let authentication = new Authentication(encryptedUsers, true);
expect(authentication.authenticate({name: 'parse.dashboard', pass: 'abc123'}))
- .toEqual(createAuthenticationResult(true, null));
+ .toEqual(createAuthenticationResult(true, 'parse.dashboard', null));
});
it('does not authenticate valid user with invalid encrypted password', () => {
let authentication = new Authentication(encryptedUsers, true);
expect(authentication.authenticate({name: 'parse.dashboard', pass: 'xyz789'}))
- .toEqual(createAuthenticationResult(false, null));
+ .toEqual(createAuthenticationResult(false, null, null));
+ });
+
+ it('authenticates valid user with valid username and usernameOnly', () => {
+ let authentication = new Authentication(unencryptedUsers, false);
+ expect(authentication.authenticate({name: 'parse.dashboard'}, true))
+ .toEqual(createAuthenticationResult(true, 'parse.dashboard', null));
+ });
+
+ it('does not authenticate valid user with valid username and no usernameOnly', () => {
+ let authentication = new Authentication(unencryptedUsers, false);
+ expect(authentication.authenticate({name: 'parse.dashboard'}))
+ .toEqual(createAuthenticationResult(false, null, null));
});
});
diff --git a/src/login/Login.js b/src/login/Login.js
new file mode 100644
index 0000000000..99004dab60
--- /dev/null
+++ b/src/login/Login.js
@@ -0,0 +1,49 @@
+/*
+ * 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';
+import styles from './Login.scss';
+import { setBasePath } from 'lib/AJAX';
+
+export default class Login extends React.Component {
+ constructor(props) {
+ super();
+
+ let errorDiv = document.getElementById('login_errors');
+ if (errorDiv) {
+ this.errors = errorDiv.innerHTML;
+ }
+
+ this.state = {
+ forgot: false
+ };
+ setBasePath(props.path);
+ }
+
+ render() {
+ return (
+
+ } />
+ } />
+ {this.errors ?
+
+ {this.errors}
+
: null}
+
+ );
+ }
+}
diff --git a/src/login/Login.scss b/src/login/Login.scss
new file mode 100644
index 0000000000..78724a2ca2
--- /dev/null
+++ b/src/login/Login.scss
@@ -0,0 +1,24 @@
+/*
+ * 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 'stylesheets/globals.scss';
+
+.message {
+ background: #f9f9fa;
+ height: 50px;
+ padding: 10px;
+ font-size: 13px;
+}
+
+.error {
+ background: #f9f9fa;
+ height: 50px;
+ line-height: 50px;
+ overflow: hidden;
+ font-size: 13px;
+ color: $red;
+}
diff --git a/src/login/index.js b/src/login/index.js
new file mode 100644
index 0000000000..8d3ea4dc44
--- /dev/null
+++ b/src/login/index.js
@@ -0,0 +1,17 @@
+/*
+ * 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 Login from './Login';
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+require('stylesheets/fonts.scss');
+
+// App entry point
+
+var path = window.PARSE_DASHBOARD_PATH || '/';
+ReactDOM.render(, document.getElementById('login_mount'));
diff --git a/src/parse-interface-guide/ComponentsMap.js b/src/parse-interface-guide/ComponentsMap.js
index 4357e5a53b..941aa039d6 100644
--- a/src/parse-interface-guide/ComponentsMap.js
+++ b/src/parse-interface-guide/ComponentsMap.js
@@ -43,6 +43,7 @@ export let LiveReload = require('components/LiveReload/LiveReload
export let Loader = require('components/Loader/Loader.example');
export let LoaderContainer = require('components/LoaderContainer/LoaderContainer.example');
export let LoaderDots = require('components/LoaderDots/LoaderDots.example');
+export let LoginForm = require('components/LoginForm/LoginForm.example');
export let LogView = require('components/LogView/LogView.example');
export let LogViewEntry = require('components/LogView/LogViewEntry.example');
export let Markdown = require('components/Markdown/Markdown.example');
diff --git a/webpack/build.config.js b/webpack/build.config.js
index b1474f33bf..8c849a06a7 100644
--- a/webpack/build.config.js
+++ b/webpack/build.config.js
@@ -7,7 +7,10 @@
*/
var configuration = require('./base.config.js');
-configuration.entry = {dashboard: './dashboard/index.js'};
+configuration.entry = {
+ dashboard: './dashboard/index.js',
+ login: './login/index.js'
+};
configuration.output.path = './Parse-Dashboard/public/bundles';
module.exports = configuration;
diff --git a/webpack/production.config.js b/webpack/production.config.js
index d8f29b59d6..a84fdbd080 100644
--- a/webpack/production.config.js
+++ b/webpack/production.config.js
@@ -9,6 +9,7 @@ var configuration = require('./base.config.js');
configuration.entry = {
dashboard: './dashboard/index.js',
+ login: './login/index.js',
PIG: './parse-interface-guide/index.js',
quickstart: './quickstart/index.js',
};
diff --git a/webpack/publish.config.js b/webpack/publish.config.js
index 97ebc4e6d2..db53ef7a52 100644
--- a/webpack/publish.config.js
+++ b/webpack/publish.config.js
@@ -7,7 +7,10 @@
*/
var configuration = require('./base.config.js');
-configuration.entry = {dashboard: './dashboard/index.js'};
+configuration.entry = {
+ dashboard: './dashboard/index.js',
+ login: './login/index.js'
+};
configuration.output.path = './Parse-Dashboard/public/bundles';
var webpack = require('webpack');