Authenticating Users in SPA using Node, Passport, React and Redux
Implementing user authentication can be a difficult task, because we can use various libraries and authentication strategies. There are a lot of tutorials on this topic, but often times they miss fundamental information or don’t reflect set up of our project. In this blog post, I don’t attempt to write an universal tutorial for user authentication. However, I will point out some parts in authentication flow I didn’t find in other tutorials.
We will be building a user authentication in a single page application with Node, React, Redux and Koa combined with Passport. I think this is a standard set up for Node.js projects, but what we will build is principally applicable to any SPA with unidirectional data flow. We will implement local authentication, where users can log in using an email and passport. We will also add authentication with Facebook, which can be used with other social networks and OAuth providers.
You can find a lot of good tutorials, which will help you implement user authentication in Node.js projects, but all these projects are multiple page applications:
- User Authentication with Passport and Koa
- Local Authentication Using Passport in Node.js
- Easy Node Authentication: Setup and Local
- Build User Authentication with Node.js, Express, Passport, and MongoDB
- Authenticating Users in a Node App using Express & Passport (Part One)
- Authenticating Node.js Applications With Passport
If you are looking for how to implement user authentication in React.js or Redux, you will probably come across these tutorial, which are very helpful:
- Secure Your React and Redux App with JWT Authentication
- Tips to handle Authentication in Redux #2 introducing redux-saga
- Protected routes and Authentication with React and Node.js
In preview below, you can see the result of this tutorial. You can check the source code on Github and deployed demo on Heroku.
I will be using Koa framework, which is very similar to Express and popular authentication middleware library Passport. In this project, I use Redis for:
- storing user’s session information
- mocking a database with users
If you are looking for how to use PostgreSQL or MongoDB in Node.js, just check tutorials in the beginning of this article. For developing single page application, we will use React, React Router, Redux, Redux-Saga, Webpack and Twitter Bootstrap.
Getting Started
We will need to have Node, with npm, installed on our machine. We will also need to install Redis. Once all of the prerequisite software is set up, we can create the node application with the following command:
$ npm init
You’ll be prompted to put in basic information for Node project. We will need following dependencies.
{ | |
"dependencies": { | |
"@koa/cors": "^2.2.1", | |
"axios": "^0.18.0", | |
"bcrypt": "^2.0.0", | |
"bootstrap": "^4.1.0", | |
"classnames": "^2.2.5", | |
"dotenv": "^6.0.0", | |
"find-config": "^1.0.0", | |
"jquery": "^3.3.1", | |
"js-cookie": "^2.2.0", | |
"koa": "^2.5.0", | |
"koa-body": "^2.5.0", | |
"koa-bodyparser": "^4.2.0", | |
"koa-logger": "^3.2.0", | |
"koa-passport": "^4.0.1", | |
"koa-redis": "^3.1.2", | |
"koa-router": "^7.4.0", | |
"koa-session": "^5.8.1", | |
"koa-static": "^4.0.2", | |
"lodash": "^4.17.10", | |
"passport": "^0.4.0", | |
"passport-facebook": "^2.1.1", | |
"passport-local": "^1.0.0", | |
"popper.js": "^1.14.3", | |
"react": "^16.3.2", | |
"react-dom": "^16.3.2", | |
"react-loadable": "^5.3.1", | |
"react-modal": "^3.4.4", | |
"react-redux": "^5.0.7", | |
"react-router": "^4.2.0", | |
"react-router-dom": "^4.2.2", | |
"react-router-redux": "^5.0.0-alpha.9", | |
"react-tippy": "^1.2.2", | |
"redis": "^2.8.0", | |
"redis-commands": "^1.3.5", | |
"redux": "^4.0.0", | |
"redux-thunk": "^2.2.0", | |
} | |
} |
For complete list, including development dependencies, check this file.
Now, install all the dependencies with command:
$ npm i
We will also need other configuration files for React project such as webpack.config.js, .babelrc and others, which you can see in root folder of the project.
Below is an overview of project structure. Back-end is located in server.js
, serverConfig.js
and auth.js
files.
├── script │ ├── controllers │ │ └── auth.js │ ├── views │ │ ├── about │ │ │ └── AboutView.js │ │ ├── actions │ │ │ ├── access.actions.js │ │ │ └── modals.actions.js │ │ ├── components │ │ │ ├── App │ │ │ ├── Header │ │ │ └── LoginForm │ │ ├── home │ │ │ └── HomeView.js │ │ ├── sagas │ │ │ ├── access.sagas.js │ │ │ ├── index.js │ │ │ └── modals.sagas.js │ │ ├── state │ │ │ ├── access.reducers.js │ │ │ ├── index.js │ │ │ └── modals.reducers.js │ │ └── routes.jsx │ └── server.js │ └── serverConfig.js ├── index.html ├── index.jsx ├── package.json └── webpack.config.js
Building and Setting up the Server
Now, when we install all npm packages, we can start to implement the server for our Node.js project. We create server.js
file in script
folder.
Application Setup server.js
In the beginning of server.js
file, we just add required modules and create koa application.
require('dotenv').config({ path: require('find-config')('.env') }); | |
const Koa = require('koa'); | |
const Router = require('koa-router'); | |
const koaLogger = require('koa-logger'); | |
const cors = require('@koa/cors'); | |
const bodyParser = require('koa-bodyparser'); | |
const serve = require('koa-static'); | |
const send = require('koa-send'); | |
const path = require('path'); | |
const session = require('koa-session'); | |
const redisStore = require('koa-redis'); | |
const ratelimit = require('koa-simple-ratelimit'); | |
const redis = require('redis'); | |
const config = require('./serverConfig'); | |
const includes = require('lodash/includes'); | |
const app = new Koa(); | |
// this last koa middleware catches any request that isn't handled by | |
// koa-static or koa-router, ie your index.html in your example | |
app.use(function* index() { | |
yield send(this, '/dist/index.html'); | |
}); | |
// don't listen to this port if the app is required from a test script | |
if (!module.parent) { | |
app.listen(process.env.PORT || 1337); | |
console.log('app listen on port: 1337'); | |
} |
If we open the terminal, we can run the application by command:
$ node ./script/server.js
If we want to refresh our server every time we change files, we need to use nodemon. Just install with: npm install -g nodemon
and add new command into package.js
file:
"debug": "./node_modules/nodemon/bin/nodemon.js --inspect ./script/server.js"
Now we can initialize Redis and create a mock database with a user.
// create mock database with one user | |
const db = redis.createClient(); | |
db.on('error', (err) => { | |
console.log(`Redis Error ${err}`); | |
}); | |
db.set('usersMockDatabase', JSON.stringify([ | |
{ | |
id: 1, | |
email: '[email protected]', | |
// "test" — generated by bcrypt calculator | |
password: '$2a$04$4yQfCo8kMpH24T2iQkw9p.hPjcz10m.FcWmgkOhkXNPSpbwHZ877S', | |
userName: 'Chouomam', | |
}, | |
]), redis.print); | |
module.exports = { | |
db, | |
}; |
In the first line, we initialized Redis, then we created a new key usersMockDatabase
with a value – user object. We should always encrypt passwords before saving them to the database. In the code snippet above, I just pasted encrypted test
string using online bcrypt-calculator.
Next, we move session data out of memory into an external session store Redis.
app.use( | |
session( | |
{ | |
store: redisStore(), | |
}, | |
app, | |
), | |
); |
Routes app/routes.js
We are setting up the router to specify how an application responds to a client requests to a particular endpoint. We will have the following routes.
const auth = require('./controllers/auth'); | |
const router = new Router(); | |
router | |
/* Handle Login POST */ | |
.post('/login', ctx => passport.authenticate('local', (err, user) => { | |
if (!user) { | |
ctx.throw(401, err); | |
} else { | |
ctx.body = user; | |
return ctx.login(user); | |
} | |
})(ctx)) | |
/* GET User Profile */ | |
.get('/users/profile', auth.getLoggedUser) | |
/* Handle Logout POST */ | |
.get('/logout', (ctx) => { | |
ctx.logout(); | |
ctx.body = {}; | |
}) | |
/* Handle Login Facebook */ | |
.get('/auth/facebook', passport.authenticate('facebook')) | |
.get( | |
'/auth/facebook/callback', | |
passport.authenticate('facebook', { | |
successRedirect: '/facebook/success/', | |
failureRedirect: '/', | |
}), | |
); | |
app.use(router.routes()).use(router.allowedMethods()); |
Passport Setup
Now we initialize passport along with its session authentication middleware. I highly recommend to check this article, which explains passport authentication flow.const passport = require('koa-passport'); | |
app.use(passport.initialize()); | |
app.use(passport.session()); |
In the first line, you can see, we required ./controllers/auth.js
file, where is handled all the passport implementation. This is where we configure our authentication strategy for local and facebook. We will add required libraries, modules and implement serializing and de-serializing the user information to the session.
const bcrypt = require('bcrypt'); | |
const passport = require('koa-passport'); | |
const FacebookStrategy = require('passport-facebook').Strategy; | |
const LocalStrategy = require('passport-local').Strategy; | |
const config = require('../serverConfig'); | |
const { db } = require('../server'); | |
const { promisify } = require('util'); | |
const getAsync = promisify(db.get).bind(db); | |
passport.serializeUser((user, done) => { | |
done(null, user.id); | |
}); | |
passport.deserializeUser(async (id, done) => { | |
try { | |
let user = null; | |
await getAsync('usersMockDatabase').then((users) => { | |
user = JSON.parse(users).find(currUser => currUser.id === id); | |
}); | |
if (user) { | |
done(null, user); | |
} else { | |
done(null, false); | |
} | |
} catch (err) { | |
done(err); | |
} | |
}); |
Passport Local Strategy
Next, we define passport’s strategy for handling login using a username and password. At first, we check the database for a user matching the given email. If a user with given email is found, the retrieved user’s password is compared to the one provided.
passport.use( | |
new LocalStrategy( | |
{ | |
usernameField: 'email', | |
passwordField: 'password', | |
}, | |
async (email, password, done) => { | |
let user = null; | |
await getAsync('usersMockDatabase').then((users) => { | |
const currUsers = JSON.parse(users); | |
user = currUsers.find(currUser => currUser.email === email); | |
}); | |
if (!user) { | |
done({ type: 'email', message: 'No such user found' }, false); | |
return; | |
} | |
if (bcrypt.compareSync(password, user.password)) { | |
done(null, { id: user.id, email: user.email, userName: user.userName }); | |
} else { | |
done({ type: 'password', message: 'Passwords did not match' }, false); | |
} | |
}, | |
), | |
); |
Passport Facebook Strategy
Before we implement facebook strategy, we need to create a new facebook application, set it up to enable OAuth and add redirect URIs. You can see more info here and here. Then we need to copy clientID
, clientSecret
and callbackURL
into .env
and serverConfig.js
configuration files.
passport.use( | |
new FacebookStrategy( | |
{ | |
clientID: config.facebookAuth.clientID, | |
clientSecret: config.facebookAuth.clientSecret, | |
callbackURL: config.facebookAuth.callbackURL, | |
profileFields: [ | |
'id', | |
'displayName', | |
'picture.width(200).height(200)', | |
'first_name', | |
'middle_name', | |
'last_name', | |
'email', | |
], | |
}, | |
(accessToken, refreshToken, profile, done) => { | |
process.nextTick(async () => { | |
const facebookUser = { | |
id: Math.random(), | |
userName: profile.displayName, | |
email: profile.emails[0].value, | |
imgUrl: profile.photos[0].value, | |
imgHeight: 200, | |
imgWidth: 200, | |
userProfileId: profile.id, | |
}; | |
await getAsync('usersMockDatabase').then((users) => { | |
// save new user into database | |
const currUsers = JSON.parse(users); | |
currUsers.push(facebookUser); | |
db.set('usersMockDatabase', JSON.stringify(currUsers)); | |
}); | |
return done(null, facebookUser); | |
}); | |
}, | |
), | |
); |
Adding Protected Endpoints
Passport also gives the ability to protect access to a specific route. It means that if user tries to accesshttp://localhost:1337/users/profile
without authenticating, he will be redirected to home page by doing:
exports.getLoggedUser = async (ctx) => { | |
if (ctx.isAuthenticated()) { | |
const reqUserId = ctx.req.user.id; | |
let user = null; | |
await getAsync('usersMockDatabase').then((users) => { | |
user = JSON.parse(users).find(currUser => currUser.id === reqUserId); | |
}); | |
if (user) { | |
delete user.password; | |
ctx.response.body = user; | |
} else { | |
const statusCode = 500; | |
ctx.throw(statusCode, "User doesn't exist"); | |
} | |
} else { | |
ctx.redirect('/'); | |
} | |
}; |
Rate Limiting
We should also implement Rate limiting to control how many requests a given consumer can send to the API. All successful API requests include the following three headers with details about the current rate limit status:
X-Rate-Limit-Limit
– the number of requests allowed in a given time intervalX-Rate-Limit-Remaining
– how many calls user has remaining in the same intervalX-Rate-Limit-Reset
– the time when the rate limit will be reset.
Most HTTP frameworks support it out of the box. For example, if you are using Koa and Redis, there is the koa-simple-ratelimit package.
app.use(ratelimit({ | |
db, | |
duration: 60000, | |
max: 100, | |
})); |
CORS
When we develop single page applications, we usually deploy front-end code on different server than API. In this case, we need to allow CORS and enable Access-Control-Allow-Credentials.
const corsOptions = { | |
credentials: true, | |
}; | |
app.use(cors(corsOptions)); |
Building and Setting up the SPA in React and Redux
Back-end part in Node.js is ready and now we can implement SPA in React and Redux. We will focus on unidirectional data flow in single page applications. The same principles can be applied to other frameworks or libraries for implementing unidirectional data flow such as Flux or MobX. In this project I decided to use popular libraries React, React-Router, Redux and Redux-Saga. Redux is a state container for JavaScript applicatins that describes the state of the application as a single object and Redux-Saga is used to make handling side effects in Redux nice and simple.
Let’s start with implementing React entry point with views and other components.
Main React Entry File
At first we define index.jsx
, which bootstraps the react + redux application by rendering the App
component (wrapped in a redux Provider) into the div
element defined in the base index.html
.
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="utf-8"> | |
<title>AUTH FLOW</title> | |
<meta name="description" content=""> | |
<!– Mobile-friendly viewport –> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
</head> | |
<body> | |
<div id="app"> | |
</div> | |
<script type="text/javascript" src="/bundle.js"></script> | |
</body> | |
</html> |
import React from 'react'; | |
import ReactDOM from 'react-dom'; | |
import createHistory from 'history/createBrowserHistory'; | |
import { createStore, combineReducers, applyMiddleware } from 'redux'; | |
import { ConnectedRouter, routerReducer, routerMiddleware } from 'react-router-redux'; | |
import { Provider } from 'react-redux'; | |
import createSagaMiddleware from 'redux-saga'; | |
import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly'; | |
import 'bootstrap'; | |
import 'bootstrap/dist/css/bootstrap.css'; | |
import 'react-tippy/dist/tippy.css'; | |
import App from 'components/App'; | |
import reducers from 'state/index'; | |
import sagas from 'sagas/index'; | |
const sagaMiddleware = createSagaMiddleware(); | |
const history = createHistory(); | |
const store = createStore( | |
combineReducers({ | |
…reducers, | |
router: routerReducer, | |
}), | |
composeWithDevTools(applyMiddleware(routerMiddleware(history), sagaMiddleware)), | |
); | |
sagaMiddleware.run(sagas); | |
ReactDOM.render( | |
<Provider store={store}> | |
<ConnectedRouter history={history}> | |
<div> | |
<App /> | |
</div> | |
</ConnectedRouter> | |
</Provider>, | |
document.getElementById('app'), | |
); |
React + Redux App Component
The App
component is the root component for the React application, it contains routes, global alert notification and modal window for logging.
import React, { Component } from 'react'; | |
import Header from 'components/Header'; | |
import Routes from 'routes'; | |
import LoginFormModal from 'components/LoginForm'; | |
import './App.scss'; | |
export class App extends Component { | |
state = { | |
error: null, | |
errorInfo: null, | |
}; | |
componentDidCatch(error, errorInfo) { | |
this.setState({ error, errorInfo }); | |
} | |
render() { | |
const { error, errorInfo } = this.state; | |
return ( | |
<div> | |
<Header /> | |
<main> | |
{!error && <Routes />} | |
{error && ( | |
<div className="App__error container mt-3"> | |
<div role="alert" className="alert alert-danger"> | |
<h4>An error occurred. Please reload the page and try again.</h4> | |
<p className="App__stacktrace"> | |
{process.env.NODE_ENV === 'development' && errorInfo.componentStack} | |
</p> | |
</div> | |
</div> | |
)} | |
</main> | |
<LoginFormModal /> | |
</div> | |
); | |
} | |
} | |
export default App; |
React + Redux Home Page and About Page
Now we will add home and about views. In home view we added getProfile
action, because we want see user profile data on this page.
import React from 'react'; | |
export default function AboutView() { | |
return ( | |
<div className="about-view m-3"> | |
<h1>ABOUT PAGE</h1> | |
</div> | |
); | |
} |
import React, { Component } from 'react'; | |
import PropTypes from 'prop-types'; | |
import { connect } from 'react-redux'; | |
import { AsyncAboutView } from 'asyncViews'; | |
import { getProfile } from 'actions/access.actions'; | |
import get from 'lodash/get'; | |
import ProfilePlaceholder from 'assets/img/profile_placeholder.png'; | |
import { isValidURL } from 'utils/utils'; | |
import './homeView.scss'; | |
class HomeView extends Component { | |
static propTypes = { | |
userObj: PropTypes.object, | |
getProfile: PropTypes.func, | |
}; | |
static defaultProps = { | |
userObj: {}, | |
getProfile: () => null, | |
}; | |
state = { | |
showProfile: false, | |
}; | |
componentDidMount() { | |
AsyncAboutView.preload(); | |
} | |
getProfile = () => { | |
this.setState({ showProfile: true }, () => { | |
this.props.getProfile(); | |
}); | |
}; | |
render() { | |
const { userObj } = this.props; | |
const { showProfile } = this.state; | |
let profileImg = null; | |
let profileWidth = 0; | |
let profileHeight = 0; | |
let backgroundSize = null; | |
if (get(userObj, 'isAuthenticated')) { | |
if (get(userObj, 'loggedUserObj.imgUrl') && isValidURL(userObj.loggedUserObj.imgUrl)) { | |
profileImg = userObj.loggedUserObj.imgUrl; | |
profileWidth = userObj.loggedUserObj.imgWidth; | |
profileHeight = userObj.loggedUserObj.imgHeight; | |
} else { | |
profileImg = ProfilePlaceholder; | |
} | |
backgroundSize = 'auto 60px'; | |
if (profileWidth < profileHeight) { | |
backgroundSize = '60px auto'; | |
} | |
} | |
return ( | |
<div className="HomeView m-3"> | |
<h1>HOME PAGE</h1> | |
{get(userObj, 'isAuthenticated') && ( | |
<button type="button" className="btn btn-outline-dark mt-3" onClick={this.getProfile}> | |
Get Profile | |
</button> | |
)} | |
{get(userObj, 'isAuthenticated') && | |
showProfile && ( | |
<div className="d-flex mt-3"> | |
<style | |
dangerouslySetInnerHTML={{ | |
__html: [ | |
'.ProfileImg {', | |
` background-image: url(${profileImg});`, | |
` background-size: ${backgroundSize};`, | |
'}', | |
].join('\n'), | |
}} | |
/> | |
<div className="ProfileImg mr-2 rounded-circle" /> | |
<div className="UserInfo d-flex flex-column justify-content-center ml-2"> | |
{get(userObj, 'loggedUserObj.userName') && ( | |
<div className="UserName"> | |
<span>UserName: </span> | |
<strong>{userObj.loggedUserObj.userName}</strong> | |
</div> | |
)} | |
{get(userObj, 'loggedUserObj.email') && ( | |
<div className="Email"> | |
<span>Email: </span> | |
<strong>{userObj.loggedUserObj.email}</strong> | |
</div> | |
)} | |
</div> | |
</div> | |
)} | |
</div> | |
); | |
} | |
} | |
const mapStateToProps = state => ({ | |
userObj: state.access.user, | |
}); | |
const mapDispatchToProps = dispatch => ({ | |
getProfile: () => dispatch(getProfile()), | |
}); | |
export default connect( | |
mapStateToProps, | |
mapDispatchToProps, | |
)(HomeView); |
React + Redux LoginForm Component
Next we can implement modal window, where users can log in.
import React, { Component } from 'react'; | |
import PropTypes from 'prop-types'; | |
import { withRouter } from 'react-router'; | |
import { connect } from 'react-redux'; | |
import Cookies from 'js-cookie'; | |
import get from 'lodash/get'; | |
import includes from 'lodash/includes'; | |
import { login } from 'actions/access.actions'; | |
import toggleLogin from 'actions/modals.actions'; | |
import cn from 'classnames'; | |
import ReactModal from 'react-modal'; | |
import { Tooltip } from 'react-tippy'; | |
import './loginForm.scss'; | |
class LoginForm extends Component { | |
static propTypes = { | |
loginUser: PropTypes.func, | |
toggleLogin: PropTypes.func, | |
openLogin: PropTypes.bool.isRequired, | |
userObj: PropTypes.object, | |
error: PropTypes.any, | |
}; | |
static defaultProps = { | |
error: null, | |
userObj: {}, | |
loginUser: () => null, | |
toggleLogin: () => null, | |
}; | |
state = { | |
error: null, | |
}; | |
componentWillReceiveProps(nextProps) { | |
if (nextProps.userObj && nextProps.userObj.isAuthenticated) { | |
this.closeModal(); | |
} | |
if (nextProps.error) { | |
this.setState({ error: nextProps.error }); | |
} | |
} | |
handleInputChange = (e) => { | |
const nextState = {}; | |
nextState[e.target.name] = e.target.value; | |
this.setState(nextState); | |
}; | |
closeModal = () => { | |
this.props.toggleLogin(false); | |
}; | |
handleSubmitForm = (e) => { | |
e.preventDefault(); | |
this.props.loginUser(this.email.value, this.password.value); | |
}; | |
handleFocusInput = (e) => { | |
const label = document.querySelector(`[for=${e.target.id}]`); | |
if (!label.classList.contains('active')) { | |
label.classList.add('active'); | |
} | |
this.resetError(); | |
}; | |
handleBlurInput = (e) => { | |
if (!e.target.value) { | |
document.querySelector(`[for=${e.target.id}]`).classList.remove('active'); | |
} | |
}; | |
onClickModalWindow = () => { | |
this.resetError(); | |
}; | |
resetError = () => { | |
if (this.errorElement && this.errorElement.length > 0) { | |
this.setState({ error: null }); | |
} | |
}; | |
onFacebookLogin = () => { | |
const inOneHour = new Date(new Date().getTime() + 60 * 60 * 1000); | |
Cookies.set('lastLocation_before_logging', this.props.location.pathname, { expires: inOneHour }); | |
window.location.href = `${window.location.origin}/auth/facebook`; | |
}; | |
render() { | |
const { openLogin } = this.props; | |
const { error } = this.state; | |
const customModalStyle = { | |
overlay: { | |
backgroundColor: 'rgba(0, 0, 0, 0.75)', | |
}, | |
}; | |
const errorMessage = error ? error.message : ''; | |
return ( | |
<ReactModal | |
isOpen={openLogin} | |
contentLabel="Modal" | |
ariaHideApp={false} | |
closeTimeoutMS={500} | |
onRequestClose={this.closeModal} | |
style={customModalStyle} | |
className={{ | |
base: 'loginForm', | |
afterOpen: 'loginForm_after-open', | |
beforeClose: 'loginForm_before-close', | |
}} | |
> | |
<div onClick={this.onClickModalWindow}> | |
<h5 className="loginForm__heading mx-auto mb-2 text-center">Sign in</h5> | |
<div className="loginForm__formContainer d-flex flex-column px-3 py-4"> | |
<button | |
type="button" | |
className="btn facebook-login-container mx-auto mt-2 rounded" | |
onClick={this.onFacebookLogin} | |
> | |
Continue with Facebook | |
</button> | |
<div className="form-divider mt-3"> | |
<span className="d-flex flex-row"> | |
<strong className="loginForm__dividerLabel">or</strong> | |
</span> | |
</div> | |
<form className="loginForm__form d-flex flex-column mx-auto mb-2" onSubmit={this.handleSubmitForm}> | |
<span className="loginForm__formHeader my-3 text-center">Sign in with your email</span> | |
<div className="form-group"> | |
<div | |
className={cn('form-group', 'mb-4', { | |
'has-error': includes(get(error, 'type'), 'email'), | |
})} | |
> | |
<Tooltip | |
html={<span>{errorMessage}</span>} | |
open={includes(get(error, 'type'), 'email')} | |
onRequestClose={() => this.setState({ error: null })} | |
> | |
<input | |
type="email" | |
className="form-control floatLabel" | |
id="registerInputEmail" | |
required | |
onChange={this.handleInputChange} | |
onFocus={this.handleFocusInput} | |
onBlur={this.handleBlurInput} | |
autoComplete="email" | |
ref={el => (this.email = el)} | |
/> | |
<label htmlFor="registerInputEmail">Email</label> | |
</Tooltip> | |
</div> | |
<div className={cn('form-group', { 'has-error': includes(get(error, 'type'), 'password') })}> | |
<Tooltip | |
html={<span>{errorMessage}</span>} | |
open={includes(get(error, 'type'), 'password')} | |
onRequestClose={() => this.setState({ error: null })} | |
> | |
<input | |
type="password" | |
className="form-control floatLabel mt-2" | |
id="registerInputPassword" | |
required | |
onChange={this.handleInputChange} | |
onFocus={this.handleFocusInput} | |
onBlur={this.handleBlurInput} | |
autoComplete="current-password" | |
ref={el => (this.password = el)} | |
/> | |
<label htmlFor="registerInputPassword">Password</label> | |
</Tooltip> | |
</div> | |
</div> | |
<button type="submit" className="btn loginForm__signIn"> | |
Sign in | |
</button> | |
</form> | |
</div> | |
</div> | |
</ReactModal> | |
); | |
} | |
} | |
const mapStateToProps = state => ({ | |
userObj: state.access.user, | |
error: state.access.error, | |
openLogin: state.toggleModal.login, | |
}); | |
const mapDispatchToProps = dispatch => ({ | |
loginUser: (email, password) => dispatch(login(email, password)), | |
toggleLogin: newState => dispatch(toggleLogin(newState)), | |
}); | |
export default withRouter( | |
connect( | |
mapStateToProps, | |
mapDispatchToProps, | |
)(LoginForm), | |
); |
In LoginForm
we implemented actions loginUser
used for logging user with email and password. Another action toggleLogin
is used for changing modal open state. Let’s say we want to display modal window for logging on other pages and in this case it is better to keep modal open state in a reducer instead of component’s state.
We don’t use an action for logging users with Facebook, you can see the explanation below. We need to save our current URL location in cookies, which we will need after Facebook successful login redirect.
onFacebookLogin = () => { | |
const inOneHour = new Date(new Date().getTime() + 60 * 60 * 1000); | |
Cookies.set('lastLocation_before_logging', this.props.location.pathname, { expires: inOneHour }); | |
window.location.href = `${window.location.origin}/auth/facebook`; | |
}; |
stackoverflow.comWhy is xhr getting blocked on the same url a browser can access?
Because it is a cross-domain request, and as such the remote party would have to allow that request first, which is what is referred to as CORS.
Facebook does not allow its login dialog to be loaded via script from different domains – for the obvious reason that users need to be able to be verify which site they are sending their login credentials to via the browser address bar, to avoid phishing.
You can not load the FB login dialog via XHR/AJAX in the background; you need to call/redirect to it in the top window instance.
Redux Actions Folder
Now we can implement actions.
import * as ActionTypes from 'constants/actionTypes'; | |
export const login = (email, password) => ({ | |
type: ActionTypes.LOGIN_REQUESTED, | |
email, | |
password, | |
}); | |
export const logout = () => ({ | |
type: ActionTypes.LOGOUT_REQUESTED, | |
}); | |
export const getProfile = () => ({ | |
type: ActionTypes.PROFILE_REQUESTED, | |
}); |
import * as ActionTypes from 'constants/actionTypes'; | |
const toggleLogin = newState => ({ | |
type: ActionTypes.TOGGLE_MODAL_REQUESTED, | |
newState, | |
}); | |
export default toggleLogin; |
Redux Sagas Folder
Next we will implement sagas.
import { all, fork } from 'redux-saga/effects'; | |
import { watchGetProfile, watchLogout, watchLogin } from './access.sagas'; | |
import watchToggleModal from './modals.sagas'; | |
export default function* rootSaga() { | |
yield all([ | |
fork(watchGetProfile), | |
fork(watchLogin), | |
fork(watchLogout), | |
fork(watchToggleModal), | |
]); | |
} |
import { put, takeLatest } from 'redux-saga/effects'; | |
import * as ActionTypes from 'constants/actionTypes'; | |
function* toggleModal(action) { | |
const { newState } = action; | |
if (!newState) { | |
// if login modal is closed, reset error | |
yield put({ type: ActionTypes.LOGIN_FAILED, error: null }); | |
} | |
yield put({ type: ActionTypes.TOGGLE_MODAL_SUCCEEDED, newState }); | |
} | |
export default function* watchToggleModal() { | |
yield takeLatest(ActionTypes.TOGGLE_MODAL_REQUESTED, toggleModal); | |
} |
import { put, call, takeLatest } from 'redux-saga/effects'; | |
import Cookies from 'js-cookie'; | |
import * as ActionTypes from 'constants/actionTypes'; | |
import { get, post } from 'utils/api'; | |
import lGet from 'lodash/get'; | |
function* login(action) { | |
const { email, password } = action; | |
try { | |
const response = yield call( | |
post, | |
'/login', | |
{ email, password }, | |
); | |
const inOneWeek = new Date(new Date().getTime() + (1000 * 60 * 60 * 24 * 7)); | |
Cookies.set('auth__flow__spa__loggedUserObj', response.data, { expires: inOneWeek }); | |
yield put({ type: ActionTypes.LOGIN_SUCCEEDED, user: response.data }); | |
} catch (error) { | |
if (lGet(error.response, 'data')) { | |
yield put({ type: ActionTypes.LOGIN_FAILED, error: error.response.data }); | |
} else { | |
yield put({ type: ActionTypes.LOGIN_FAILED, error }); | |
} | |
} | |
} | |
export function* watchLogin() { | |
yield takeLatest(ActionTypes.LOGIN_REQUESTED, login); | |
} | |
function* logout() { | |
try { | |
yield call( | |
get, | |
'/logout', | |
); | |
Cookies.remove('auth__flow__spa__loggedUserObj'); | |
yield put({ type: ActionTypes.LOGOUT_SUCCEEDED }); | |
} catch (error) { | |
if (lGet(error.response, 'data')) { | |
yield put({ type: ActionTypes.LOGOUT_FAILED, error: error.response.data }); | |
} else { | |
yield put({ type: ActionTypes.LOGOUT_FAILED, error: error.response }); | |
} | |
} | |
} | |
export function* watchLogout() { | |
yield takeLatest(ActionTypes.LOGOUT_REQUESTED, logout); | |
} | |
function* getProfile() { | |
try { | |
const response = yield call( | |
get, | |
'/users/profile', | |
); | |
const inOneWeek = new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * 7); | |
Cookies.set('auth__flow__spa__loggedUserObj', response, { expires: inOneWeek }); | |
yield put({ type: ActionTypes.PROFILE_SUCCEEDED, user: response }); | |
} catch (error) { | |
yield put({ type: ActionTypes.PROFILE_FAILED, error: error.response }); | |
} | |
} | |
export function* watchGetProfile() { | |
yield takeLatest(ActionTypes.PROFILE_REQUESTED, getProfile); | |
} |
We don’t want to lose user data, when we refresh a page after login. If we take a look at login saga worker, we can see that response data, which contains user object is stored in cookies. We can store user data somewhere else, but we shouldn’t do it in the reducer, because reducers should have no side effects. We need to get user data in a reducer, when we initialize state.
Ajax API Calls
Now we can implement API calls.
import axios from 'axios'; | |
import { API_ADDRESS } from 'config'; | |
export function get(path, params) { | |
const url = `${API_ADDRESS}${path}`; | |
return axios({ | |
method: 'get', | |
url, | |
params, | |
withCredentials: true, | |
}).then(resp => resp.data); | |
} | |
export function post(path, data, params) { | |
const url = `${API_ADDRESS}${path}`; | |
return axios({ | |
method: 'post', | |
url, | |
data, | |
params, | |
withCredentials: true, | |
}); | |
} |
As you can see, we are using withCredentials property. If we use CORS and don’t set withCredentials
to true
, isAuthenticated returns false and this passport’s method doesn’t work.
Redux Reducers Folder
Next, we can implement reducers.
import access from 'state/access.reducers'; | |
import toggleModal from 'state/modals.reducers'; | |
export default { | |
access, | |
toggleModal, | |
}; |
import * as ActionTypes from '../constants/actionTypes'; | |
export default function toggleModal(state = { login: false }, action) { | |
switch (action.type) { | |
case ActionTypes.TOGGLE_MODAL_SUCCEEDED: | |
return { login: action.newState }; | |
default: | |
return state; | |
} | |
} |
import Cookies from 'js-cookie'; | |
import * as ActionTypes from '../constants/actionTypes'; | |
const initialState = { | |
user: { | |
isAuthenticated: typeof Cookies.get('auth__flow__spa__loggedUserObj') !== 'undefined', | |
loggedUserObj: Cookies.getJSON('auth__flow__spa__loggedUserObj'), | |
}, | |
error: null, | |
}; | |
export default function access(state = initialState, action) { | |
switch (action.type) { | |
case ActionTypes.LOGIN_SUCCEEDED: | |
case ActionTypes.PROFILE_SUCCEEDED: { | |
return { | |
…state, | |
user: { | |
…state.user, | |
isAuthenticated: true, | |
loggedUserObj: action.user, | |
}, | |
error: null, | |
}; | |
} | |
case ActionTypes.LOGIN_FAILED: { | |
return { | |
…state, | |
error: action.error, | |
}; | |
} | |
case ActionTypes.PROFILE_FAILED: | |
case ActionTypes.LOGOUT_SUCCEEDED: { | |
return { | |
…state, | |
user: { | |
isAuthenticated: false, | |
}, | |
error: null, | |
}; | |
} | |
default: | |
return state; | |
} | |
} |
React Router Routes
Now we can define routes.
import React from 'react'; | |
import Loadable from 'react-loadable'; | |
const Loading = () => ( | |
<div /> | |
); | |
export const AsyncAboutView = Loadable({ | |
loader: () => import('about/AboutView'), | |
loading: () => <Loading />, | |
}); | |
export const AsyncHomeView = Loadable({ | |
loader: () => import('home/HomeView'), | |
loading: () => <Loading />, | |
}); |
import React from 'react'; | |
import { Switch, Route } from 'react-router-dom'; | |
import Cookies from 'js-cookie'; | |
import { Redirect } from 'react-router'; | |
import { AsyncHomeView, AsyncAboutView } from 'asyncViews'; | |
export default function Routes() { | |
return ( | |
<Switch> | |
<Route path="/" exact component={AsyncHomeView} /> | |
<Route path="/about" component={AsyncAboutView} /> | |
<Route | |
path="/facebook/success/" | |
render={() => ( | |
<Redirect | |
to={{ | |
pathname: Cookies.get('lastLocation_before_logging'), | |
state: { loadUser: true }, | |
}} | |
/> | |
)} | |
/> | |
</Switch> | |
); | |
} |
After facebook successful redirect, we want to enter the page where we logged in. If we want to recognize in a React component, that we were redirected, we can use location state.
After successful redirecting, we set a state loadUser
, which will be used in componentWillReceiveProps
method in Header
component to get user profile data.
If we want to make routes accessible only to authenticated users, we need to check documentation or tutorials on protected (authentication) routes for our routing library.
React + Redux Header Component
import React, { Component } from 'react'; | |
import PropTypes from 'prop-types'; | |
import { withRouter } from 'react-router'; | |
import { connect } from 'react-redux'; | |
import { NavLink } from 'react-router-dom'; | |
import get from 'lodash/get'; | |
import ProfilePlaceholder from 'assets/img/profile_placeholder.png'; | |
import { isValidURL } from 'utils/utils'; | |
import { logout, getProfile } from 'actions/access.actions'; | |
import toggleLogin from 'actions/modals.actions'; | |
import './header.scss'; | |
class Header extends Component { | |
static propTypes = { | |
/* Router */ | |
location: PropTypes.any.isRequired, | |
history: PropTypes.any.isRequired, | |
/* Redux */ | |
userObj: PropTypes.object, | |
logoutUser: PropTypes.func, | |
getProfile: PropTypes.func, | |
toggleLogin: PropTypes.func, | |
}; | |
static defaultProps = { | |
userObj: {}, | |
logoutUser: () => null, | |
toggleLogin: () => null, | |
getProfile: () => null, | |
}; | |
componentWillReceiveProps(nextProps) { | |
if (get(nextProps.location, 'state.loadUser')) { | |
this.props.getProfile(); | |
this.props.history.replace({ state: null }); | |
} | |
} | |
handleLoginClick = () => { | |
this.props.toggleLogin(true); | |
}; | |
onLogoutClick = () => { | |
this.props.logoutUser(); | |
}; | |
render() { | |
const { userObj } = this.props; | |
let userInfoEle = null; | |
let profileImg = null; | |
let profileWidth = 0; | |
let profileHeight = 0; | |
if (get(userObj, 'isAuthenticated')) { | |
if (get(userObj, 'loggedUserObj.imgUrl') && isValidURL(userObj.loggedUserObj.imgUrl)) { | |
profileImg = userObj.loggedUserObj.imgUrl; | |
profileWidth = userObj.loggedUserObj.imgWidth; | |
profileHeight = userObj.loggedUserObj.imgHeight; | |
} else { | |
profileImg = ProfilePlaceholder; | |
} | |
let name = get(userObj, 'loggedUserObj.email'); | |
if (get(userObj, 'loggedUserObj.userName')) { | |
name = userObj.loggedUserObj.userName; | |
} | |
let backgroundSize = 'auto 30px'; | |
if (profileWidth < profileHeight) { | |
backgroundSize = '30px auto'; | |
} | |
userInfoEle = ( | |
<div className="navbar-signed d-flex"> | |
<style | |
dangerouslySetInnerHTML={{ | |
__html: [ | |
'.img-profile {', | |
` background-image: url(${profileImg});`, | |
` background-size: ${backgroundSize};`, | |
'}', | |
].join('\n'), | |
}} | |
/> | |
<div className="img-profile mr-2 rounded-circle" /> | |
<div className="dropdown profile-label d-flex align-items-center"> | |
<a | |
href="#" | |
className="dropdown-toggle Header__dropdown" | |
data-toggle="dropdown" | |
role="button" | |
aria-haspopup="true" | |
aria-expanded="false" | |
> | |
{name} | |
</a> | |
<div className="dropdown-menu dropdown-menu-right pull-right"> | |
<li className="dropdown-menu__item text-center"> | |
<button className="btn w-100 rounded-0" onClick={this.onLogoutClick}> | |
Logout | |
</button> | |
</li> | |
</div> | |
</div> | |
</div> | |
); | |
} else { | |
userInfoEle = ( | |
<ul className="navbar-nav navbar-right"> | |
<li> | |
<a onClick={this.handleLoginClick}>Sign in</a> | |
</li> | |
</ul> | |
); | |
} | |
return ( | |
<div className="Header"> | |
<header role="navigation"> | |
{/* | |
************ NAVBAR MENU | |
*/} | |
<nav className="navbar navbar-light navbar-expand-lg bg-light"> | |
<div className="container-fluid"> | |
<a className="navbar-brand" href="#"> | |
Navbar | |
</a> | |
<button | |
className="navbar-toggler" | |
type="button" | |
data-toggle="collapse" | |
data-target="#bs-example-navbar-collapse-1" | |
aria-controls="bs-example-navbar-collapse-1" | |
aria-expanded="false" | |
aria-label="Toggle navigation" | |
> | |
<span className="navbar-toggler-icon" /> | |
</button> | |
{/* Collect the nav links, forms, and other content for toggling */} | |
<div className="collapse navbar-collapse" id="bs-example-navbar-collapse-1"> | |
<ul className="nav navbar-nav navbar-left h-100"> | |
<li> | |
<NavLink className="Header__navLink pl-2 ml-2" to="/" exact> | |
Home | |
</NavLink> | |
</li> | |
<li> | |
<NavLink className="Header__navLink pl-2 ml-2" to="/about"> | |
About | |
</NavLink> | |
</li> | |
</ul> | |
</div> | |
{userInfoEle} | |
</div> | |
</nav> | |
</header> | |
</div> | |
); | |
} | |
} | |
const mapStateToProps = state => ({ | |
userObj: state.access.user, | |
}); | |
const mapDispatchToProps = dispatch => ({ | |
logoutUser: () => dispatch(logout()), | |
toggleLogin: newState => dispatch(toggleLogin(newState)), | |
getProfile: () => dispatch(getProfile()), | |
}); | |
export default withRouter( | |
connect( | |
mapStateToProps, | |
mapDispatchToProps, | |
)(Header), | |
); |
Conclusion
In this tutorial we implemented user authentication in a single page application using Node, Passport, React and Redux. The purpose of this article was to provide whole solution for authenticating with an email and password, and authenticating with an OAuth provider. We shouldn’t forget to implement rate limiting. If we host back-end and front-end on different servers, we need to use CORS and enable http headers for cross-site authentication. We can apply this tutorial in any SPA with unidirectional data flow. If you see any ways to improve this article, let me know please.
3 COMMENTS
I am beginner, but seems littlebit antipattern for me… You have states all over Components and views even Redux is used to have just one store with state. Or I am wrong?
You should check some redux tutorials or examples here https://reactjs.org/community/starter-kits.html#other-starter-kits
Redux is meant to be “single source of truth”. When you check my project, you can see I have a single store https://github.com/AndrejGajdos/auth-flow-spa-node-react/blob/13028909806af612c65c13d169f84978cbfdef8a/index.jsx#L32 which combines multiple reducers. You need to connect reducers with components. Why would you connect whole application state with all components? Specific components need only part of application state.