From 08b2d064f2746db855ebdeb15b1a49e8cd1db55c Mon Sep 17 00:00:00 2001 From: Soon-Mi Sugihara Date: Thu, 24 Sep 2020 15:51:46 -0400 Subject: [PATCH 1/2] Change auth flow --- .env.example | 4 +- README.md | 42 +++-- app.js | 84 ++++++--- bin/www | 21 ++- client/README.md | 58 ++++++- client/package-lock.json | 5 + client/package.json | 2 +- client/src/App.js | 40 +++-- client/src/components/User.js | 13 -- client/src/components/UsersList.js | 26 --- client/src/index.js | 10 ++ client/src/store/configureStore.js | 22 +++ config/index.js | 4 + db/migrations/20200504040340-create-user.js | 15 +- db/models/user.js | 103 ++++++++--- package-lock.json | 181 +++++++++++++++++--- package.json | 14 +- routes/api/csrf.js | 16 ++ routes/api/index.js | 6 +- routes/api/session.js | 44 +++++ routes/api/users.js | 31 +++- routes/util/auth.js | 59 +++++++ routes/util/validation.js | 22 +++ 23 files changed, 654 insertions(+), 168 deletions(-) mode change 100644 => 100755 .env.example mode change 100644 => 100755 README.md mode change 100644 => 100755 app.js mode change 100644 => 100755 client/README.md mode change 100644 => 100755 client/package-lock.json mode change 100644 => 100755 client/package.json mode change 100644 => 100755 client/src/App.js delete mode 100644 client/src/components/User.js delete mode 100644 client/src/components/UsersList.js mode change 100644 => 100755 client/src/index.js create mode 100755 client/src/store/configureStore.js mode change 100644 => 100755 config/index.js mode change 100644 => 100755 db/migrations/20200504040340-create-user.js mode change 100644 => 100755 db/models/user.js mode change 100644 => 100755 package-lock.json mode change 100644 => 100755 package.json create mode 100755 routes/api/csrf.js mode change 100644 => 100755 routes/api/index.js create mode 100755 routes/api/session.js mode change 100644 => 100755 routes/api/users.js create mode 100755 routes/util/auth.js create mode 100755 routes/util/validation.js diff --git a/.env.example b/.env.example old mode 100644 new mode 100755 index 855a321..60516e0 --- a/.env.example +++ b/.env.example @@ -2,4 +2,6 @@ DB_USERNAME=username DB_PASSWORD=password DB_DATABASE=database_name DB_HOST=server -SECRET_KEY=strong_random_bytes +DEBUG=starter:server +JWT_SECRET_KEY=strong_random_bytes +JWT_EXPIRES_IN=604800 \ No newline at end of file diff --git a/README.md b/README.md old mode 100644 new mode 100755 index abcfb8c..ab77f0d --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ This is the backend for the Solo React project. 2. Install dependencies (`npm install`) 3. Create a **.env** file based on the example with proper settings for your development environment -4. Setup your PostgreSQL user, password and database and make sure it matches your **.env** file with CREATEDB privileges +4. Setup your PostgreSQL user, password and database and make sure it matches + your **.env** file with CREATEDB privileges 5. Run * `npm run db:create` @@ -19,20 +20,27 @@ This is the backend for the Solo React project. ## Deploy to Heroku 1. Create a new project -2. Under Resources click "Find more add-ons" and add the add on called "Heroku Postgres" -3. Install the [Heroku CLI](https://devcenter.heroku.com/articles/heroku-command-line) +2. Under Resources click "Find more add-ons" and add the add on called "Heroku + Postgres" +3. Install the [Heroku CLI] 4. Run `$ heroku login` -5. Add heroku as a remote to this git repo `$ heroku git:remote -a ` -6. Push the project to heroku `$ git push heroku master` -7. Connect to the heroku shell and prepare your database - -```bash - $ heroku run bash - $ sequelize-cli db:migrate - $ sequelize-cli db:seed:all -``` -(You can interact with your database this way as youd like, but beware that `db:drop` should not be run in the heroku environment) - -8. Add a `REACT_APP_BASE_URL` config var. This should be the full URL of your react app: i.e. "https://solo-react.herokuapp.com" - -9. profit \ No newline at end of file +5. Add Heroku as a remote to this git repo `$ heroku git:remote -a ` +6. Push the project to Heroku `$ git push heroku master` +7. Connect to the Heroku shell and prepare your database + + ```bash + $ heroku run bash + $ sequelize-cli db:migrate + $ sequelize-cli db:seed:all + ``` + + (You can interact with your database this way as you'd like, but beware that + `db:drop` should not be run in the Heroku environment. If you want to drop + and create the database, you need to remove and add back the "Heroku + Postgres" add-on.) + +8. Add environment variables on the Heroku environment using the Heroku + dashboard. [Setting Heroku Config Vars] + +[Heroku CLI]: https://devcenter.heroku.com/articles/heroku-command-line +[Setting Heroku Config Variables]: https://devcenter.heroku.com/articles/config-vars \ No newline at end of file diff --git a/app.js b/app.js old mode 100644 new mode 100755 index 80f3b8e..a5ee379 --- a/app.js +++ b/app.js @@ -7,6 +7,8 @@ const path = require('path'); const logger = require('morgan'); const csurf = require('csurf'); const routes = require('./routes'); +const { ValidationError } = require("sequelize"); +const { AuthenticationError } = require('./routes/util/auth'); const app = express(); @@ -15,46 +17,88 @@ app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(express.static(path.join(__dirname, 'public'))); -app.use(cookieParser()) - +app.use(cookieParser()); // Security Middleware -app.use(cors({ origin: true })); -app.use(helmet({ hsts: false })); -app.use(csurf({ - cookie: { - secure: process.env.NODE_ENV === 'production', - sameSite: process.env.NODE_ENV === 'production', - httpOnly: true - } -})); - +if (process.env.NODE_ENV === 'production') { + // require https + app.use(helmet({ hsts: false })); +} else { + // enable CORS only in development + app.use(cors()); +} +app.use( + csurf({ + cookie: { + secure: process.env.NODE_ENV === 'production', + sameSite: process.env.NODE_ENV === 'production', + httpOnly: true, + }, + }) +); +// connect the routes from the /routes folder app.use(routes); // Serve React Application // This should come after routes, but before 404 and error handling. -if (process.env.NODE_ENV === "production") { +if (process.env.NODE_ENV === 'production') { + // Serve the client's index.html file at the root route + app.get('/', (req, res) => { + res.cookie("XSRF-TOKEN", req.csrfToken()); + res.sendFile(path.resolve(__dirname, "client", "build", "index.html")); + }); + + // Serve the static assets in the client's build folder app.use(express.static("client/build")); + + // Serve the client's index.html file at all other routes NOT starting with /api app.get(/\/(?!api)*/, (req, res) => { + res.cookie("XSRF-TOKEN", req.csrfToken()); res.sendFile(path.resolve(__dirname, "client", "build", "index.html")); }); } - +// create a 404 error for any requests that reach this middleware +// (requests will not reach this middleware if the response has been resolved) app.use(function(_req, _res, next) { next(createError(404)); }); +// If error is a sequelize error, make the errors look pretty +app.use((err, _req, _res, next) => { + // check if error is a Sequelize error: + if (err instanceof ValidationError) { + err.errors = err.errors.map((e) => e.message); + err.title = "Sequelize Error"; + } + err.status = 422; + next(err); +}); + +// Error handler app.use(function(err, _req, res, _next) { res.status(err.status || 500); - if (err.status === 401) { - res.set('WWW-Authenticate', 'Bearer'); + + // If there is an authentication error, clear the token + if (err instanceof AuthenticationError) { + res.clearCookie('token'); + } + + // If in production, don't show the error stack trace + if (process.env.NODE_ENV === 'production') { + res.json({ + message: err.message, + error: { errors: err.errors }, + }); + } else { + console.log(err.stack); + res.json({ + message: err.message, + stack: err.stack, + error: JSON.parse(JSON.stringify(err)), + }); } - res.json({ - message: err.message, - error: JSON.parse(JSON.stringify(err)), - }); }); module.exports = app; diff --git a/bin/www b/bin/www index 5d5d431..387c099 100755 --- a/bin/www +++ b/bin/www @@ -7,27 +7,38 @@ const app = require('../app'); const debug = require('debug')('starter:server'); const http = require('http'); +const db = require("../db/models"); +const config = require("../config"); /** * Get port from environment and store in Express. */ -const port = normalizePort(process.env.PORT || '5000'); +const port = normalizePort(config.port || '5000'); app.set('port', port); /** * Create HTTP server. */ - const server = http.createServer(app); /** * Listen on provided port, on all network interfaces. */ -server.listen(port); -server.on('error', onError); -server.on('listening', onListening); +server.on("error", onError); +server.on("listening", onListening); + +db.sequelize + .authenticate() + .then(() => { + debug("Connected to database successfully"); + server.listen(port); + }) + .catch(() => { + console.error("Error connecting to database"); + process.exit(1); + }); /** * Normalize a port into a number, string, or false. diff --git a/client/README.md b/client/README.md old mode 100644 new mode 100755 index f888d2d..044dbc2 --- a/client/README.md +++ b/client/README.md @@ -1,8 +1,58 @@ -This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). +# Client -Your React App will live here. While is development, run this application from this location using `npm start` or `yarn start`. +This project was bootstrapped with [Create React App]. +Your React App will live here. -No environment variables are needed to run this application in development, but be sure to set the REACT_APP_BASE_URL environment variable in heroku! +## Start Up in Development -This app will be automatically built when you deploy to heroku, please see the `heroku-postbuild` script in your `express.js` applications `package.json` to see how this works. \ No newline at end of file +While is development, run this application from this location using `npm start` +or `yarn start`. + +No environment variables are needed to run this application in development, +but be sure to edit the "proxy" in the `package.json` to the reflect port of +your backend server. + +## Deploy to Production + +This app will be automatically built when you deploy to Heroku, please see the +`heroku-postbuild` script in your `express.js` application's `package.json`, +**NOT** React's `package.json` to see how this works. + +## Using Redux + +If you are using Redux, then run: + +`npm install redux react-redux redux-thunk` + +## CSRF Protection + +On all request methods besides `GET`, you need to define a `CSRF-TOKEN` header +that has a value of the `XSRF-TOKEN` cookie. + +Example of a fetch request with CSRF header: + +```js +import Cookies from 'js-cookie'; + +const login = async () => { + const csrfToken = Cookies.get("XSRF-TOKEN"); + const res = await fetch("/api/session", { + method: "put", + headers: { + "Content-Type": "application/json", + "CSRF-TOKEN": csrfToken, + }, + body: JSON.stringify({ + username: "Demo-lition", + password: "password" + }), + }); + res.data = await res.json(); // current user info + if (res.ok) { + return res.data; + } +}; +``` + +[Create React App]: https://github.com/facebook/create-react-app \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json old mode 100644 new mode 100755 index 4536637..b922e67 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -7645,6 +7645,11 @@ } } }, + "js-cookie": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz", + "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/client/package.json b/client/package.json old mode 100644 new mode 100755 index a1329de..cf8a49b --- a/client/package.json +++ b/client/package.json @@ -6,7 +6,7 @@ "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2", - "http-proxy-middleware": "^1.0.5", + "js-cookie": "^2.2.1", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router-dom": "^5.2.0", diff --git a/client/src/App.js b/client/src/App.js old mode 100644 new mode 100755 index 233d24d..6451df2 --- a/client/src/App.js +++ b/client/src/App.js @@ -1,28 +1,30 @@ -import React from 'react'; -import { BrowserRouter, Switch, Route, NavLink } from 'react-router-dom'; +import React, { useState, useEffect } from 'react'; +import { BrowserRouter, Route } from 'react-router-dom'; -import UserList from './components/UsersList'; +function App() { + const [loading, setLoading] = useState(true); + // Check to see if there is a user logged in before loading the application + useEffect(() => { + const loadUser = async () => { + const res = await fetch("/api/session"); + if (res.ok) { + res.data = await res.json(); // current user info + console.log(res.data); + // if using Redux, add current user info to the store + } + setLoading(false); + } + loadUser(); + }, []); -function App() { + if (loading) return null; return ( - - - - - - - -

My Home Page

-
-
+ +

My Home Page

+
); } diff --git a/client/src/components/User.js b/client/src/components/User.js deleted file mode 100644 index 7684d78..0000000 --- a/client/src/components/User.js +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; - - -function User(props) { - return ( - <> - Username: {props.user.username}
- Email: {props.user.email}
-
- - ); -} -export default User; \ No newline at end of file diff --git a/client/src/components/UsersList.js b/client/src/components/UsersList.js deleted file mode 100644 index c458245..0000000 --- a/client/src/components/UsersList.js +++ /dev/null @@ -1,26 +0,0 @@ -import React, { useEffect, useState } from 'react'; - -import User from './User'; - -function UsersList (props) { - const [users, setUsers] = useState([]); - - useEffect(() => { - async function fetchData() { - const response = await fetch('/api/users/'); - const responseData = await response.json(); - setUsers(responseData.users); - } - fetchData(); - }, []); - - const userComponents = users.map((user) => ) - return ( - <> -

User List:

- {userComponents} - - ); -} - -export default UsersList; \ No newline at end of file diff --git a/client/src/index.js b/client/src/index.js old mode 100644 new mode 100755 index 6832e78..d3a10b1 --- a/client/src/index.js +++ b/client/src/index.js @@ -3,6 +3,16 @@ import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; +// In development, the frontend and backend servers are separate so we need to +// call a route to add the CSRF token cookie to the frontend only in development +if (process.env.NODE_ENV !== 'production') { + const getCSRFToken = () => { + return fetch("/api/csrf/token"); + }; + + getCSRFToken(); +} + ReactDOM.render( diff --git a/client/src/store/configureStore.js b/client/src/store/configureStore.js new file mode 100755 index 0000000..fc0c3a8 --- /dev/null +++ b/client/src/store/configureStore.js @@ -0,0 +1,22 @@ +import { createStore, compose, combineReducers, applyMiddleware } from 'redux'; +import { thunk } from 'redux-thunk'; + +const rootReducer = combineReducers({ +}); + +let storeEnhancer; + +if (process.env.NODE_ENV === 'production') { + const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; + storeEnhancer = composeEnhancers(applyMiddleware(thunk)); +} else { + storeEnhancer = applyMiddleware(thunk); +} + +export default function configureStore(initialState) { + return createStore( + rootReducer, + initialState, + storeEnhancer + ) +} \ No newline at end of file diff --git a/config/index.js b/config/index.js old mode 100644 new mode 100755 index 8ef5901..8a834d2 --- a/config/index.js +++ b/config/index.js @@ -6,5 +6,9 @@ module.exports = { password: process.env.DB_PASSWORD, database: process.env.DB_DATABASE, host: process.env.DB_HOST, + }, + jwtConfig: { + secret: process.env.JWT_SECRET_KEY, + expiresIn: process.env.JWT_EXPIRES_IN } }; diff --git a/db/migrations/20200504040340-create-user.js b/db/migrations/20200504040340-create-user.js old mode 100644 new mode 100755 index 9158460..9edb65f --- a/db/migrations/20200504040340-create-user.js +++ b/db/migrations/20200504040340-create-user.js @@ -1,12 +1,12 @@ 'use strict'; module.exports = { up: (queryInterface, Sequelize) => { - return queryInterface.createTable('Users', { + return queryInterface.createTable("Users", { id: { allowNull: false, autoIncrement: true, primaryKey: true, - type: Sequelize.INTEGER + type: Sequelize.INTEGER, }, email: { allowNull: false, @@ -22,17 +22,16 @@ module.exports = { allowNull: false, type: Sequelize.STRING(60).BINARY, }, - tokenId: { - type: Sequelize.STRING(36), - }, createdAt: { allowNull: false, - type: Sequelize.DATE + type: Sequelize.DATE, + defaultValue: Sequelize.fn("NOW"), }, updatedAt: { allowNull: false, - type: Sequelize.DATE - } + type: Sequelize.DATE, + defaultValue: Sequelize.fn("NOW"), + }, }); }, down: (queryInterface) => { diff --git a/db/models/user.js b/db/models/user.js old mode 100644 new mode 100755 index 3fb0252..4491cba --- a/db/models/user.js +++ b/db/models/user.js @@ -1,35 +1,92 @@ 'use strict'; +const { Op } = require('sequelize'); +const bcrypt = require('bcryptjs'); + module.exports = (sequelize, DataTypes) => { - const User = sequelize.define('User', { - email: { - allowNull: false, - type: DataTypes.STRING, - validates: { - isEmail: true, - len: [3, 255], - } - }, - username: { - allowNull: false, - type: DataTypes.STRING, - validates: { - len: [1, 255], + const User = sequelize.define( + "User", + { + email: { + allowNull: false, + type: DataTypes.STRING, + validates: { + isEmail: true, + len: [3, 255], + }, }, - }, - hashedPassword: { - allowNull: false, - type: DataTypes.STRING.BINARY, - validates: { - len: [60, 60], + username: { + allowNull: false, + type: DataTypes.STRING, + validates: { + len: [1, 255], + }, + }, + hashedPassword: { + allowNull: false, + type: DataTypes.STRING.BINARY, + validates: { + len: [60, 60], + }, }, }, - tokenId: { - type: DataTypes.STRING + { + defaultScope: { + attributes: { + exclude: ["hashedPassword", "email", "createdAt", "updatedAt"], + }, + }, + scopes: { + currentUser: { + attributes: { exclude: ["hashedPassword"] }, + }, + loginUser: { + attributes: {}, + }, + }, } - }, {}); + ); User.associate = function(models) { }; + User.prototype.toSafeObject = function() { + const { + id, + username + } = this; + + return { id, username }; + }; + + User.login = async function({ username, password }) { + const user = await User.scope('loginUser').findOne({ + where: { + [Op.or]: [{ username }, { email: username }], + }, + }); + if (user && user.validatePassword(password)) { + return await User.scope('currentUser').findByPk(user.id); + } + }; + + User.prototype.validatePassword = function(password) { + return bcrypt.compareSync(password, this.hashedPassword.toString()); + }; + + User.getCurrentUserById = async function(id) { + return await User.scope("currentUser").findByPk(id); + }; + + User.signup = async function({ username, email, password }) { + console.log(username, email, password); + const hashedPassword = bcrypt.hashSync(password); + const user = await User.create({ + username, + email, + hashedPassword + }); + return await User.scope("currentUser").findByPk(user.id); + }; + return User; }; diff --git a/package-lock.json b/package-lock.json old mode 100644 new mode 100755 index 87c9ddc..2c9a680 --- a/package-lock.json +++ b/package-lock.json @@ -145,6 +145,16 @@ "qs": "6.5.2", "raw-body": "2.3.3", "type-is": "~1.6.16" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + } } }, "bowser": { @@ -186,6 +196,11 @@ "fill-range": "^7.0.1" } }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, "buffer-writer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", @@ -526,11 +541,18 @@ "integrity": "sha1-bYCcnNDPe7iVLYD8hPoT1H3bEwg=" }, "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", "requires": { - "ms": "2.0.0" + "ms": "^2.1.1" + }, + "dependencies": { + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } } }, "decamelize": { @@ -623,6 +645,14 @@ "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=", "dev": true }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "editorconfig": { "version": "0.15.3", "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-0.15.3.tgz", @@ -769,6 +799,14 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=" + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } } } }, @@ -777,22 +815,6 @@ "resolved": "https://registry.npmjs.org/express-async-handler/-/express-async-handler-1.1.4.tgz", "integrity": "sha512-HdmbVF4V4w1q/iz++RV7bUxIeepTukWewiJGkoCKQMtvPF11MLTa7It9PRc/reysXXZSEyD4Pthchju+IUbMiQ==" }, - "express-bearer-token": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/express-bearer-token/-/express-bearer-token-2.4.0.tgz", - "integrity": "sha512-2+kRZT2xo+pmmvSY7Ma5FzxTJpO3kGaPCEXPbAm3GaoZ/z6FE4K6L7cvs1AUZwY2xkk15PcQw7t4dWjsl5rdJw==", - "requires": { - "cookie": "^0.3.1", - "cookie-parser": "^1.4.4" - }, - "dependencies": { - "cookie": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", - "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=" - } - } - }, "express-validator": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-6.4.1.tgz", @@ -843,6 +865,16 @@ "parseurl": "~1.3.2", "statuses": "~1.4.0", "unpipe": "~1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + } } }, "find-up": { @@ -1255,6 +1287,49 @@ "graceful-fs": "^4.1.6" } }, + "jsonwebtoken": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", + "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", + "requires": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^5.6.0" + }, + "dependencies": { + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "requires": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "keyv": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", @@ -1287,6 +1362,41 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, "lowercase-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", @@ -1423,6 +1533,16 @@ "depd": "~1.1.2", "on-finished": "~2.3.0", "on-headers": "~1.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + } } }, "ms": { @@ -1935,6 +2055,16 @@ "on-finished": "~2.3.0", "range-parser": "~1.2.0", "statuses": "~1.4.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + } } }, "sequelize": { @@ -2248,6 +2378,17 @@ "dev": true, "requires": { "debug": "^2.2.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } } }, "unique-string": { diff --git a/package.json b/package.json old mode 100644 new mode 100755 index 3223b35..c0a93a3 --- a/package.json +++ b/package.json @@ -4,20 +4,25 @@ "private": true, "scripts": { "start": "per-env", - "start:development": "dotenv nodemon ./bin/www", + "start:development": "nodemon --ignore client/ -r dotenv/config ./bin/www", "start:production": "./bin/www", + "db:create": "dotenv sequelize db:create", + "db:migrate": "dotenv sequelize db:migrate", + "db:seed:all": "dotenv sequelize db:seed:all", "heroku-postbuild": "npm install --prefix client && npm run build --prefix client" }, "dependencies": { "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.5", "cors": "^2.8.5", "csurf": "^1.11.0", + "debug": "^4.1.1", "express": "^4.16.4", "express-async-handler": "^1.1.4", - "express-bearer-token": "^2.4.0", "express-validator": "^6.4.1", "helmet": "^3.22.0", "http-errors": "~1.6.3", + "jsonwebtoken": "^8.5.1", "morgan": "~1.9.1", "per-env": "^1.0.2", "pg": "^8.0.3", @@ -26,9 +31,8 @@ "uuid": "^8.0.0" }, "devDependencies": { - "nodemon": "^2.0.3", - "debug": "~2.6.9", "dotenv": "^8.2.0", - "dotenv-cli": "^3.1.0" + "dotenv-cli": "^3.1.0", + "nodemon": "^2.0.3" } } diff --git a/routes/api/csrf.js b/routes/api/csrf.js new file mode 100755 index 0000000..0f138b9 --- /dev/null +++ b/routes/api/csrf.js @@ -0,0 +1,16 @@ +const express = require('express'); +const router = express.Router(); + +// In development, the frontend and backend servers are separate so we need to +// have a route to add the CSRF token cookie to the frontend only in development +if (process.env.NODE_ENV !== 'production') { + router.get('/token', function (req, res) { + res.cookie("XSRF-TOKEN", req.csrfToken()); + return res.json({ + message: 'success' + }); + }); +} + + +module.exports = router; \ No newline at end of file diff --git a/routes/api/index.js b/routes/api/index.js old mode 100644 new mode 100755 index 4aa8c1f..072af42 --- a/routes/api/index.js +++ b/routes/api/index.js @@ -1,6 +1,10 @@ const router = require('express').Router(); -const routes = ['users']; +// Whenever you create a file in the /routes/api folder, add a string +// corresponding to the name of the file to this array. +// Then this file will automatically add that file from this folder to the api +// routes. +const routes = ['users', 'session', 'csrf']; for (let route of routes) { router.use(`/${route}`, require(`./${route}`)); diff --git a/routes/api/session.js b/routes/api/session.js new file mode 100755 index 0000000..758d1bc --- /dev/null +++ b/routes/api/session.js @@ -0,0 +1,44 @@ +const express = require("express"); +const asyncHandler = require("express-async-handler"); +const { check } = require("express-validator"); + +const { User } = require("../../db/models"); +const { handleValidationErrors } = require("../util/validation"); +const { getCurrentUser, generateToken } = require("../util/auth"); +const { jwtConfig: { expiresIn }} = require('../../config'); + +const router = express.Router(); + +// Get current user route +router.get( + "/", + getCurrentUser, + asyncHandler(async function (req, res) { + return res.json({ + user: req.user || {} + }); + }) +); + +// Login route +router.put( + "/", + handleValidationErrors, + asyncHandler(async function (req, res, next) { + const user = await User.login(req.body); + if (user) { + const token = await generateToken(user); + res.cookie("token", token, { + maxAge: expiresIn * 1000, // maxAge in milliseconds + httpOnly: true, + secure: process.env.NODE_ENV === "production" + }); + return res.json({ + user, + }); + } + return next(new Error('Invalid credentials')); + }) +); + +module.exports = router; diff --git a/routes/api/users.js b/routes/api/users.js old mode 100644 new mode 100755 index e186ac0..38c6205 --- a/routes/api/users.js +++ b/routes/api/users.js @@ -1,13 +1,34 @@ const express = require('express'); const asyncHandler = require('express-async-handler'); +const { check } = require("express-validator"); -const { User } = require('../../db/models'); +const { User } = require("../../db/models"); +const { handleValidationErrors } = require("../util/validation"); +const { generateToken } = require("../util/auth"); +const { + jwtConfig: { expiresIn }, +} = require("../../config"); const router = express.Router(); -router.get('/', asyncHandler(async function (req, res, next) { - const users = await User.findAll(); - res.json({ users }); -})); +// Signup route +router.post( + "/", + handleValidationErrors, + asyncHandler(async function (req, res) { + console.log(req.body); + const user = await User.signup(req.body); + + const token = await generateToken(user); + res.cookie("token", token, { + maxAge: expiresIn * 1000, // maxAge in milliseconds + httpOnly: true, + secure: process.env.NODE_ENV === "production", + }); + return res.json({ + user, + }); + }) +); module.exports = router; diff --git a/routes/util/auth.js b/routes/util/auth.js new file mode 100755 index 0000000..28c17cb --- /dev/null +++ b/routes/util/auth.js @@ -0,0 +1,59 @@ +const jwt = require("jsonwebtoken"); + +const { + jwtConfig: { secret, expiresIn }, +} = require("../../config"); +const { User } = require("../../db/models"); + +// Automatically generates a message and status code for authentication errors +class AuthenticationError extends Error { + constructor() { + super("Unauthorized"); + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, AuthenticationError); + } + + this.name = "AuthenticationError"; + this.status = 401; + } +} + +// Generates a JWT with the specified user +function generateToken(user) { + const data = user.toSafeObject(); + + return jwt.sign({ data }, secret, { + expiresIn: Number.parseInt(expiresIn) + }); +} + +// Sets the current user to `req.user` if there is a current user +function getCurrentUser(req, _res, next) { + const { token } = req.cookies; + if (!token) return next(); + + return jwt.verify(token, secret, null, async(err, payload) => { + if (!err) { + const userId = payload.data.id; + try { + req.user = await User.getCurrentUserById(userId); + } catch {} + } + return next(); + }); +} + +// If there is no current user, return an error +const requireUser = [ + getCurrentUser, + function (req, _res, next) { + if (req.user) return next(); + + const err = new AuthenticationError(); + return next(err); + } +]; + +module.exports = { generateToken, requireUser, getCurrentUser, AuthenticationError }; diff --git a/routes/util/validation.js b/routes/util/validation.js new file mode 100755 index 0000000..facad3c --- /dev/null +++ b/routes/util/validation.js @@ -0,0 +1,22 @@ +const { validationResult } = require("express-validator"); + +// middleware for formatting errors from express-validator middleware +// to customize, see express-validator's documentation +const handleValidationErrors = (req, _res, next) => { + const validationErrors = validationResult(req); + + if (!validationErrors.isEmpty()) { + const errors = validationErrors.array().map((error) => `${error.param}: ${error.msg}`); + + const err = Error("Bad request."); + err.errors = errors; + err.status = 400; + err.title = "Bad request."; + next(err); + } + next(); +}; + +module.exports = { + handleValidationErrors +}; \ No newline at end of file From b93c54d6da31e82b7067f4a2a4ac3268e90d06e8 Mon Sep 17 00:00:00 2001 From: Soon-Mi Sugihara Date: Fri, 25 Sep 2020 13:10:21 -0400 Subject: [PATCH 2/2] Update security middleware --- app.js | 9 ++++----- routes/api/session.js | 3 ++- routes/api/users.js | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app.js b/app.js index a5ee379..4d7097b 100755 --- a/app.js +++ b/app.js @@ -20,13 +20,12 @@ app.use(express.static(path.join(__dirname, 'public'))); app.use(cookieParser()); // Security Middleware -if (process.env.NODE_ENV === 'production') { - // require https - app.use(helmet({ hsts: false })); -} else { - // enable CORS only in development +if (process.env.NODE_ENV !== 'production') { + // enable cors only in development app.use(cors()); } +// helmet helps set a variety of headers to better secure your app +app.use(helmet()); app.use( csurf({ cookie: { diff --git a/routes/api/session.js b/routes/api/session.js index 758d1bc..e9c7106 100755 --- a/routes/api/session.js +++ b/routes/api/session.js @@ -31,7 +31,8 @@ router.put( res.cookie("token", token, { maxAge: expiresIn * 1000, // maxAge in milliseconds httpOnly: true, - secure: process.env.NODE_ENV === "production" + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production", }); return res.json({ user, diff --git a/routes/api/users.js b/routes/api/users.js index 38c6205..3f81411 100755 --- a/routes/api/users.js +++ b/routes/api/users.js @@ -24,6 +24,7 @@ router.post( maxAge: expiresIn * 1000, // maxAge in milliseconds httpOnly: true, secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production", }); return res.json({ user,