diff --git a/client/src/actions/authActions.js b/client/src/actions/authActions.js new file mode 100644 index 0000000..7f75389 --- /dev/null +++ b/client/src/actions/authActions.js @@ -0,0 +1,109 @@ +import axios from 'axios'; + +import { getErrors } from './errorActions'; + +import { + USER_LOADING, + USER_LOADED, + AUTH_ERROR, + LOGIN_SUCCESS, + LOGIN_FAIL, + LOGOUT_SUCCESS, + REGISTER_SUCCESS, + REGISTER_FAIL +} from '../actions/types'; + +export const loadUser = () => (dispatch, getState) => { + dispatch({ type: USER_LOADING }); + + axios + .get('http://localhost:5000/api/auth/user', tokenConfig(getState)) + .then(res => { + dispatch({ + type: USER_LOADED, + payload: res.data + }) + }) + .catch(err => { + dispatch(getErrors(err.response.data, err.response.status)); + dispatch({ + type: AUTH_ERROR + }) + }) +} + +export const register = ({ name, email, password }) => dispatch => { + const config = { + headers: { + 'Content-Type': 'application/json' + } + } + + // Request body + const body = JSON.stringify({ name, email, password }); + + axios + .post('http://localhost:5000/api/users', body, config) + .then(res => { + dispatch({ + type: REGISTER_SUCCESS, + payload: res.data + }) + }) + .catch(err => { + dispatch(getErrors(err.response.data, err.response.status, REGISTER_FAIL)); + dispatch({ + type: REGISTER_FAIL + }) + }) +} + +export const login = ({ email, password }) => dispatch => { + // Headers + const config = { + headers: { + 'Content-Type': 'application/json' + } + } + + // Request body + const body = JSON.stringify({ email, password }); + + axios + .post('http://localhost:5000/api/auth', body, config) + .then(res => { + dispatch({ + type: LOGIN_SUCCESS, + payload: res.data + }) + }) + .catch(err => { + dispatch(getErrors(err.response.data, err.response.status, LOGIN_FAIL)); + dispatch({ + type: LOGIN_FAIL + }) + }) +} + +export const logout = () => { + return { + type: LOGOUT_SUCCESS + } +} + +export const tokenConfig = getState => { + // Get token from local storage + const token = getState().auth.token; + + const config = { + headers: { + "Content-Type": "application/json" + } + } + + if (token) { + config.headers['x-auth-token'] = token; + } + + return config +} \ No newline at end of file diff --git a/client/src/actions/types.js b/client/src/actions/types.js index 26eb46b..4f024d5 100644 --- a/client/src/actions/types.js +++ b/client/src/actions/types.js @@ -1,2 +1,11 @@ export const GET_ERRORS = 'GET_ERRORS'; -export const CLEAR_ERRORS = 'CLEAR_ERRORS'; \ No newline at end of file +export const CLEAR_ERRORS = 'CLEAR_ERRORS'; + +export const USER_LOADING = 'USER_LOADING'; +export const USER_LOADED = 'USER_LOADED'; +export const AUTH_ERROR = 'AUTH_ERROR'; +export const LOGIN_SUCCESS = 'LOGIN_SUCCESS'; +export const LOGIN_FAIL = 'LOGIN_FAIL'; +export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS'; +export const REGISTER_SUCCESS = 'REGISTER_SUCCESS'; +export const REGISTER_FAIL = 'REGISTER_FAIL'; \ No newline at end of file diff --git a/client/src/components/AppNavbar.js b/client/src/components/AppNavbar.js index 4d0dd44..f0f3547 100644 --- a/client/src/components/AppNavbar.js +++ b/client/src/components/AppNavbar.js @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React from 'react'; import { Collapse, Navbar, @@ -13,8 +13,14 @@ import { // how to use React Router with reactstrap import { NavLink as RRNavLink } from 'react-router-dom'; +import { connect } from 'react-redux'; + import PropTypes from 'prop-types'; +import RegisterModal from './auth/RegisterModal'; +import LoginModal from './auth/LoginModal'; +import Logout from './auth/Logout'; + class AppNavbar extends React.Component { state = { isOpen: false // for the collapsible menu @@ -27,6 +33,33 @@ class AppNavbar extends React.Component { } render() { + const { isAuthenticated, user } = this.props.auth; + + const guestLinks = ( + <> + + + + + + + + ); + + const authLinks = ( + <> + + + {user ? `Welcome, ${user.name}` : ''} + + + + + + + ); + + return (
@@ -35,7 +68,9 @@ class AppNavbar extends React.Component { @@ -45,4 +80,12 @@ class AppNavbar extends React.Component { } } -export default AppNavbar; \ No newline at end of file +AppNavbar.propTypes = { + auth: PropTypes.object.isRequired +} + +const mapStateToProps = state => ({ + auth: state.auth // from rootReducer +}); + +export default connect(mapStateToProps, null)(AppNavbar); \ No newline at end of file diff --git a/client/src/components/auth/LoginModal.js b/client/src/components/auth/LoginModal.js new file mode 100644 index 0000000..68254be --- /dev/null +++ b/client/src/components/auth/LoginModal.js @@ -0,0 +1,139 @@ +import React from 'react'; +import { + Button, + Modal, + ModalHeader, + ModalBody, + Form, + FormGroup, + Label, + Input, + NavLink, + Alert +} from 'reactstrap'; + +import PropTypes from 'prop-types'; + +import { connect } from 'react-redux'; + +import { login } from '../../actions/authActions'; +import { clearErrors } from '../../actions/errorActions'; + +import { LOGIN_FAIL } from '../../actions/types'; + +class LoginModal extends React.Component { + state = { + modal: false, + email: '', + password: '', + msg: null + } + + componentDidUpdate(prevProps) { + const { error, isAuthenticated } = this.props; + if (error !== prevProps.error) { + if (error.id === LOGIN_FAIL) { + this.setState({ + msg: error.msg.msg + }); + } + else { + this.setState({ msg: null }) + } + } + + // If authenticated, close modal + if (this.state.modal) { + if (isAuthenticated) { + this.toggle(); + } + } + } + + toggle = () => { + // clearErrors() prevents the previous error message from showing up + // when the modal is re-opened + this.props.clearErrors(); + this.setState({ + modal: !this.state.modal + }); + } + // Using [e.target.name] allows onChange to be used with multiple state vars + onChange = e => { + this.setState({ + [e.target.name]: e.target.value + }) + } + + onSubmit = e => { + e.preventDefault(); + + const { email, password } = this.state; + + const user = { + email, + password + } + + this.props.login(user); + } + + render() { + return ( +
+ Login + + + Login + + { this.state.msg ? {this.state.msg} : null } +
+ + + + + + + + + +
+
+
+
+ ); + } +} + +LoginModal.propTypes = { + isAuthenticated: PropTypes.bool, + error: PropTypes.object.isRequired, + login: PropTypes.func.isRequired, + clearErrors: PropTypes.func.isRequired +} + +const mapStateToProps = state => ({ + isAuthenticated: state.auth.isAuthenticated, // from authReducer + error: state.error +}); + +export default connect( + mapStateToProps, + { login, clearErrors })(LoginModal); diff --git a/client/src/components/auth/Logout.js b/client/src/components/auth/Logout.js new file mode 100644 index 0000000..6e717d0 --- /dev/null +++ b/client/src/components/auth/Logout.js @@ -0,0 +1,22 @@ +import React from 'react'; +import PropTypes from 'prop-types' +import { connect } from 'react-redux'; + +import { logout } from '../../actions/authActions'; +import { NavLink } from 'reactstrap'; + +class Logout extends React.Component { + render() { + return ( + <> + Logout + + ); + } +} + +Logout.propTypes = { + logout: PropTypes.func.isRequired +} + +export default connect(null, { logout })(Logout); \ No newline at end of file diff --git a/client/src/components/auth/RegisterModal.js b/client/src/components/auth/RegisterModal.js new file mode 100644 index 0000000..f78c379 --- /dev/null +++ b/client/src/components/auth/RegisterModal.js @@ -0,0 +1,149 @@ +import React from 'react'; +import { + Button, + Modal, + ModalHeader, + ModalBody, + Form, + FormGroup, + Label, + Input, + NavLink, + Alert +} from 'reactstrap'; + +import PropTypes from 'prop-types'; + +import { connect } from 'react-redux'; + +import { register } from '../../actions/authActions'; +import { clearErrors } from '../../actions/errorActions'; +import { REGISTER_FAIL } from '../../actions/types'; + +class RegisterModal extends React.Component { + state = { + isOpen: false, + name: '', + email: '', + password: '', + msg: null + } + + componentDidUpdate(prevProps) { + const { error, isAuthenticated } = this.props; + if (error !== prevProps.error) { + if (error.id === REGISTER_FAIL) { + this.setState({ + msg: error.msg.msg + }); + } + else { + this.setState({ msg: null }) + } + } + + // If authenticated, close modal + if (this.state.isOpen) { + if (isAuthenticated) { + this.toggle(); + } + } + } + + toggle = () => { + // clearErrors() prevents the previous error message from showing up + // when the modal is re-opened + this.props.clearErrors(); + this.setState({ + isOpen: !this.state.isOpen + }); + } + // Using [e.target.name] allows onChange to be used with multiple state vars + onChange = e => { + this.setState({ + [e.target.name]: e.target.value + }) + } + + onSubmit = e => { + e.preventDefault(); + + const { name, email, password } = this.state; + + const newUser = { + name, + email, + password + }; + + this.props.register(newUser); + } + + render() { + return ( +
+ Register + + + Register + + { this.state.msg ? {this.state.msg} : null } +
+ + + + + + + + + + + + +
+
+
+
+ ); + } +} + +RegisterModal.propTypes = { + isAuthenticated: PropTypes.bool, + error: PropTypes.object.isRequired, + register: PropTypes.func.isRequired, + clearErrors: PropTypes.func.isRequired +} + +const mapStateToProps = state => ({ + isAuthenticated: state.auth.isAuthenticated, // from authReducer + error: state.error +}); + +export default connect( + mapStateToProps, + { register, clearErrors })(RegisterModal); diff --git a/client/src/reducers/authReducer.js b/client/src/reducers/authReducer.js new file mode 100644 index 0000000..9a1b14e --- /dev/null +++ b/client/src/reducers/authReducer.js @@ -0,0 +1,57 @@ +import { + USER_LOADED, + USER_LOADING, + AUTH_ERROR, + LOGIN_SUCCESS, + LOGIN_FAIL, + LOGOUT_SUCCESS, + REGISTER_SUCCESS, + REGISTER_FAIL +} from '../actions/types'; + +const initialState = { + token: localStorage.getItem('token'), + isAuthenticated: null, + isLoading: false, + user: null +} + +export default function(state = initialState, action) { + switch (action.type) { + case USER_LOADING: + return { + ...state, + isLoading: true + }; + case USER_LOADED: + return { + ...state, + isAuthenticated: true, + isLoading: false, + user: action.payload + }; + case LOGIN_SUCCESS: + case REGISTER_SUCCESS: + localStorage.setItem('token', action.payload.token); + return { + ...state, + ...action.payload, // user AND token + isAuthenticated: true, + isLoading: false + } + case AUTH_ERROR: + case LOGIN_FAIL: + case LOGOUT_SUCCESS: + case REGISTER_FAIL: + localStorage.removeItem('token'); + return { + ...state, + token: null, + user: null, + isAuthenticated: false, + isLoading: false + } + default: + return state; + } +} \ No newline at end of file diff --git a/client/src/reducers/rootReducer.js b/client/src/reducers/rootReducer.js index 6c9e4fc..995718e 100644 --- a/client/src/reducers/rootReducer.js +++ b/client/src/reducers/rootReducer.js @@ -1,7 +1,9 @@ import { combineReducers } from 'redux'; import errorReducer from './errorReducer'; +import authReducer from './authReducer'; export default combineReducers({ - error: errorReducer + error: errorReducer, + auth: authReducer }); \ No newline at end of file diff --git a/middleware/auth.js b/middleware/auth.js new file mode 100644 index 0000000..208dd93 --- /dev/null +++ b/middleware/auth.js @@ -0,0 +1,21 @@ +const jwt = require('jsonwebtoken'); + +function auth(req, res, next) { + const token = req.header('x-auth-token'); + + // Check for token + if (!token) { res.status(401).json({ msg: 'No token; authorization denied.'}); return } + + try { + const decoded = jwt.verify(token, process.env.jwtSecret); + + // Add user from payload + req.user = decoded; + next(); + } catch (e) { + res.status(400).json({ msg: 'Token is not valid.'}); + return; + } +} + +module.exports = auth; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 288a36b..602c0cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -446,6 +446,15 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, "crypto-random-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", @@ -1264,6 +1273,11 @@ "integrity": "sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==", "dev": true }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, "on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", diff --git a/package.json b/package.json index 4a489a3..259283e 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "dependencies": { "bcryptjs": "^2.4.3", "concurrently": "^5.3.0", + "cors": "^2.8.5", "express": "^4.17.1", "jsonwebtoken": "^8.5.1", "mongoose": "^5.10.3" diff --git a/routes/api/auth.js b/routes/api/auth.js new file mode 100644 index 0000000..b5f1ace --- /dev/null +++ b/routes/api/auth.js @@ -0,0 +1,59 @@ +const express = require('express'); +const router = express.Router(); + +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const auth = require('../../middleware/auth'); + +const User = require('../../models/User'); + +// Authenticate user +router.post('/', (req, res) => { + const { email, password } = req.body; + + // Simple validation, but insecure! + if (!email || !password) { + return res.status(400).json({ msg: 'Please enter all fields.' }); + } + + // Check for existing user + User.findOne({ email }) + .then(user => { + if (!user) { + return res.status(400).json({ msg: 'User does not exist.' }); + } + + // Validate password + bcrypt.compare(password, user.password) + .then(isMatch => { + if (!isMatch) return res.status(400).json({ msg: 'Invalid credentials.' }); + + jwt.sign( + { id: user.id }, + process.env.jwtSecret, + { expiresIn: 3600 }, // expires in an hour + (err, token) => { + if (err) { throw err; } + + res.json({ + token, + user: { + id: user.id, + name: user.name, + email: user.email + } + }) + } + ) + }) + }) +}); + +// Get user data +router.get('/user/', auth, (req, res) => { + User.findById(req.user.id) + .select('-password') + .then(user => res.json(user)); +}) + +module.exports = router; \ No newline at end of file diff --git a/server.js b/server.js index b2aba85..a7fe0f2 100644 --- a/server.js +++ b/server.js @@ -1,7 +1,9 @@ const express = require('express'); const mongoose = require('mongoose'); +const cors = require('cors'); const userRoutes = require('./routes/api/users'); +const authRoutes = require('./routes/api/auth'); const app = express(); @@ -16,6 +18,22 @@ mongoose const port = process.env.PORT || 5000; +// See https://daveceddia.com/access-control-allow-origin-cors-errors-in-react-express/ +// for source of the whitelist code +const whitelist = ['http://localhost:3000']; +const corsOptions = { + origin: function(origin, callback) { + if (whitelist.indexOf(origin) !== -1) { + callback(null, true); + } + else { + callback(new Error(`${origin} not allowed by CORS`)); + } + } +} + +app.use(cors(corsOptions)); app.use('/api/users', userRoutes); +app.use('/api/auth', authRoutes); app.listen(port, () => console.log(`Server started on port ${port}.`)); \ No newline at end of file