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 = `` + } + 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 ( +
+ + Signing up signifies that you have read and agree to the + Terms of Service + and + Privacy Policy. +
+ } + 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 ( +
+ +
+ +
{this.props.header}
+ {this.props.children} +
+
+ {this.props.footer} +
+
+ { + if (this.props.disableSubmit) { + return; + } + this.refs.form.submit() + }} + className={styles.submit} + value={this.props.action} /> + +
+ ); + } +} 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 })}>
+ Log Out 👋 Server Guide 📚 Help 💊
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');