In this post I’ll share some code that demonstrates JWT authentication between a Rails API backend (using the Knock gem ) and a React frontend. For this example I am using a Pages controller that has a public index route to list all the pages. The Page model will have a boolean attribute (allow_unauth) which determines if the unauthenticated user has access. The show method on the controller will check the user’s access and return its content if allowed. From the React frontend, there is a main component that handles the cookies, fetches the list of pages, and displays navigation items. I used React-Bootstrap for the markup, axios to make API calls, and generator-react-webpack to scaffold the frontend with webpack.
Part 1: Rails API
Scaffold a new Rails API project
mkdir -p api/rails-react-token-auth
cd api/rails-react-token-auth
# create RVM files
echo ruby-2.4.3 > .ruby-version
echo rails-react-token-auth > .ruby-gemset
rvm use .
# add rails
gem install rails
# defaulting to sqlite for this example
rails new . --api
# setup database
rake db:migrate
Knock/JWT integration with User model and controller:
Edit Gemfile, add: gem 'knock'
.
Execute bundle install
to install and dependencies.
Execute rails generate knock:install
generator to add knock configuration.
Update application controller, edit file: app/controllers/application_controller.rb
class ApplicationController < ActionController :: API
include Knock :: Authenticable
end
Created migration to add User table
class CreateUsers < ActiveRecord :: Migration [ 5.1 ]
def change
create_table :users do | t |
t . string :email
t . string :password_digest
t . timestamps
end
add_index :users , %i(email) , unique: true
end
end
Added User model with basic validation, new file: app/models/user.rb
class User < ApplicationRecord
has_secure_password
validates :email , :password , presence: true
validates :email , uniqueness: true
validates :password , length: { minimum: 8 }
end
Add user token controller, new file: app/controllers/api/user_token_controller.rb
, contents:
class Api::UserTokenController < Knock :: AuthTokenController
end
Add controller to fetch current user, new file: app/controllers/api/users_controller.rb
class Api::UsersController < ApplicationController
before_action :authenticate_user
def current
render json: current_user . as_json ( only: %i(id email) )
end
end
Add controller routes, edit file: config/routes.rb
Rails . application . routes . draw do
namespace :api do
post 'user/token' => 'user_token#create'
get 'users/current' => 'users#current'
end
end
Next I added the Pages model and controller:
Rails migration to add Pages table:
class CreatePages < ActiveRecord :: Migration [ 5.1 ]
def change
create_table :pages do | t |
t . string :title
t . text :content
t . boolean :allow_unauth
t . timestamps
end
add_index :pages , %i(title) , unique: true
end
end
Added Page model, new file: app/models/page.rb
class Page < ApplicationRecord
validates :title , :content , presence: true
validates :title , uniqueness: true
end
Created Pages controller, new file: app/controllers/api/pages_controller.rb
class Api::PagesController < ApplicationController
before_action :set_page , only: %i(show)
before_action :authenticate_user , only: %i(show) , if: :page_access
def index
render json: Page . select ( :id , :title , :allow_unauth )
end
def show
render json: @page . as_json ( only: %i(id title content allow_unauth) )
end
private
def set_page
@page = Page . find ( params [ :id ])
end
def page_access
! @page . allow_unauth
end
end
Updated routes for pages controller, edit file: config/routes.rb
Rails . application . routes . draw do
namespace :api do
post 'user/token' => 'user_token#create'
get 'users/current' => 'users#current'
resources :pages , only: %i(index show)
end
end
Added seeds to populate public/private pages and a sample user, edit file: db/seeds.rb
( 1 .. 2 ). each do | i |
Page . create! ( title: "Public Page #{ i } " , content: "Public content #{ i } " , allow_unauth: true ) rescue nil
Page . create! ( title: "Private Page #{ i } " , content: "Super secret content #{ i } " , allow_unauth: false ) rescue nil
end
User . create! ( email: 'eric.london@example.com' , password: 'password' )
Executed rake db:migrate
to execute migrations, and rake db:seed
to populate seeds data.
Last I setup the rack-cors gem to allow the frontend to make API calls.
Edit file Gemfile
, added: gem 'rack-cors'
.
Execute bundle install
to install gem dependencies.
Add basic CORS initializer configuration, edit file: config/initializers/cors.rb
Rails . application . config . middleware . insert_before 0 , Rack :: Cors do
allow do
origins [ 'localhost:8000' ]
resource '*' ,
headers: :any ,
methods: %i(get post put patch delete options head)
end
end
Started the Rails API via: rails s
, on default port 3000.
Part 2: React frontend
Scaffold the frontend project:
mkdir frontend && cd frontend
# setup NVM
echo v8.10.0 > .nvmrc
nvm use .
# install generator
npm install -g yo
npm install -g generator-react-webpack
# create project
yo react-webpack
# add additional npm packages
npm install --save axios
npm install --save react-cookie
npm install --save react-bootstrap
npm install --save react-router-dom
npm install --save react-router-bootstrap
I created an Api.js include to handle all API calls to Rails using axios, new file: src/lib/Api.js
var axios = require ( ' axios ' )
let apiHost = ' http:// ' + ( process . env . API_HOST || ' localhost ' ) + ' :3000 '
module . exports = {
authenticateUser : function ( email , password ) {
let data = {
auth : {
email : email ,
password : password
}
}
return axios . post ( apiHost + ' /api/user/token ' , data )
. then ( function ( response ) {
return response . data . jwt
})
. catch ( function ( error ) {
return undefined
})
},
getCurrentUser : function ( jwt ) {
var config = {
headers : {}
}
if ( jwt ) {
config [ ' headers ' ][ ' Authorization ' ] = ' Bearer ' + jwt
}
return axios . get ( apiHost + ' /api/users/current ' , config )
. then ( function ( response ){
return response . data
})
. catch ( function ( error ) {
return undefined
})
},
getPages : function () {
return axios . get ( apiHost + ' /api/pages ' )
. then ( function ( response ){
return response . data
})
. catch ( function ( error ) {
return undefined
})
},
getPage : function ( jwt , id ) {
var config = {
headers : {}
}
if ( jwt ) {
config [ ' headers ' ][ ' Authorization ' ] = ' Bearer ' + jwt
}
return axios . get ( apiHost + ' /api/pages/ ' + id , config )
. then ( function ( response ){
return response . data
})
. catch ( function ( error ) {
return undefined
})
}
}
Revised the Main.js component to integrate with a CookieProvider, edit file: src/components/Main.js
import React from ' react '
import { CookiesProvider } from ' react-cookie '
import TokenAuth from ' components/TokenAuth.js '
class AppComponent extends React . Component {
render () {
return (
< CookiesProvider >
< TokenAuth />
< /CookiesProvider >
)
}
}
AppComponent . defaultProps = {}
export default AppComponent
The main component adds a single TokenAuth component. This component handles the following:
cookie management
controlling a global application state
fetching Pages and the current User from the API
implementing dynamic routes based on if a user is logged in or not
handling propagation of authentication sign in/out responses
displaying a navigation bar (AppHeader)
new file: src/components/TokenAuth.js
import React from ' react '
import { instanceOf } from ' prop-types '
import { withCookies , Cookies } from ' react-cookie '
import { BrowserRouter as Router , Route } from ' react-router-dom '
import AppHeader from ' ./AppHeader.js '
import AuthSignIn from ' ./AuthSignIn.js '
import AuthSignOut from ' ./AuthSignOut.js '
import PageHome from ' ./PageHome.js '
import Page from ' ./Page.js '
const Api = require ( ' ../lib/Api.js ' )
class TokenAuthComponent extends React . Component {
static propTypes = {
cookies : instanceOf ( Cookies ). isRequired
}
render () {
return (
< Router >
< div >
< AppHeader appState = { this . state } / >
< Route exact path = " / " component = { PageHome } / >
< Route
exact path = ' /page/:id '
render = {( routeProps ) => (
< Page {... routeProps } appState = { this . state } / >
)}
/ >
{ ! this . state . jwt &&
< Route
exact path = " /sign-in "
render = {( routeProps ) => (
< AuthSignIn {... routeProps } propagateSignIn = { this . propagateSignIn } / >
)}
/ >
}
{ this . state . jwt &&
< Route
exact path = " /sign-out "
render = {( routeProps ) => (
< AuthSignOut {... routeProps } propagateSignOut = { this . propagateSignOut } / >
)}
/ >
}
< /div >
< /Router >
)
}
componentDidMount () {
this . getUser ()
this . getPages ()
}
defaultState () {
return {
cookieName : ' rails-react-token-auth-jwt ' ,
email : undefined ,
jwt : undefined ,
user_id : undefined ,
pages : []
}
}
constructor ( props ) {
super ( props )
this . state = this . defaultState ()
this . propagateSignIn = this . propagateSignIn . bind ( this )
this . propagateSignOut = this . propagateSignOut . bind ( this )
}
propagateSignIn ( jwt , history = undefined ) {
const { cookies } = this . props
cookies . set ( this . state . cookieName , jwt , { path : ' / ' })
this . getUser ( history )
}
propagateSignOut ( history = undefined ) {
const { cookies } = this . props
cookies . remove ( this . state . cookieName )
this . setState ({
email : undefined ,
user_id : undefined ,
jwt : undefined
})
if ( history ) history . push ( ' / ' )
}
getPages () {
Api . getPages (). then ( response => {
this . setState ({
pages : response
})
})
}
getUser ( history = undefined ) {
const { cookies } = this . props
let jwt = cookies . get ( this . state . cookieName )
if ( ! jwt ) return null
Api . getCurrentUser ( jwt ). then ( response => {
if ( response !== undefined ) {
this . setState ({
email : response . email ,
user_id : response . id ,
jwt : jwt
})
if ( history ) history . push ( ' / ' )
}
else {
// user has cookie but cannot load current user
cookies . remove ( this . state . cookieName )
this . setState ({
email : undefined ,
user_id : undefined ,
jwt : undefined
})
}
})
}
}
export default withCookies ( TokenAuthComponent )
Here is the template I used for a basic page layout, ex: PageHome, new file: src/components/PageHome.js
import React from ' react '
import { Grid , Row , Col } from ' react-bootstrap '
class PageHomeComponent extends React . Component {
render () {
return (
< Grid >
< Row >
< Col xs = { 12 } md = { 12 } >
Home
< /Col >
< /Row >
< /Grid >
)
}
constructor ( props ) {
super ( props )
}
}
export default PageHomeComponent
Contents of the NavBar component which adds NavItem links for Home, SignIn, SignOut, and each Page conditionally, new file: src/components/AppHeader.js
import React from ' react '
import { Navbar , Nav , NavItem } from ' react-bootstrap '
import { LinkContainer } from ' react-router-bootstrap '
class AppHeaderComponent extends React . Component {
render () {
return (
< Navbar inverse collapseOnSelect >
< Navbar . Header >
< Navbar . Brand >
Rails React Token Auth
< /Navbar.Brand >
< Navbar . Toggle />
< /Navbar.Header >
< Navbar . Collapse >
< Nav >
< LinkContainer exact to = " / " >
< NavItem eventKey = { 1 } >
Home
< /NavItem >
< /LinkContainer >
{ this . props . appState . pages . map ( page =>
< LinkContainer key = { ' page_ ' + page . id } exact to = { ' /page/ ' + page . id } >
< NavItem eventKey = { ' 2. ' + page . id } >
{ page . title }
< /NavItem >
< /LinkContainer >
)}
< /Nav >
< Nav pullRight >
{ ! this . props . appState . jwt &&
< LinkContainer exact to = " /sign-in " >
< NavItem eventKey = { 3 } >
Sign In
< /NavItem >
< /LinkContainer >
}
{ this . props . appState . jwt &&
< LinkContainer exact to = " /sign-out " >
< NavItem eventKey = { 4 } >
Sign Out
< /NavItem >
< /LinkContainer >
}
< /Nav >
< /Navbar.Collapse >
< /Navbar >
)
}
constructor ( props ) {
super ( props )
}
}
export default AppHeaderComponent
The AuthSignIn component provides the user with a sign in form with basic error handling. On submit an API call is made to the UserToken controller, and on success the API returns a JWT (string). The JWT is propagated to the TokenAuthComponent, set in a cookie, and the current user is fetched from the API. The user’s email, id, and the JWT are stored in the TokenAuthComponent state. The JWT is passed to child components (as a prop) and used in subsequent API calls. new file: src/components/AuthSignIn.js
import React from ' react '
import { Grid , Row , Col , FormGroup , FormControl , ControlLabel , Button , Alert } from ' react-bootstrap '
const Api = require ( ' ../lib/Api.js ' )
class AuthSignInComponent extends React . Component {
render () {
return (
< Grid >
< Row >
< Col xs = { 12 } md = { 12 } >
{ this . getFormErrors (). length > 0 && this . state . formSubmitted &&
< Alert bsStyle = " danger " >
< strong > Please correct the following errors : < /strong >
< ul >
{
this . getFormErrors (). map (( message , index ) =>
< li key = { ' error_message_ ' + index } > { message } < /li >
)
}
< /ul >
< /Alert >
}
< form onSubmit = { this . handleSubmit } >
< FormGroup >
< ControlLabel > Email < /ControlLabel >
< FormControl
id = " authEmail "
type = " email "
label = " Email address "
placeholder = " Enter email "
onChange = { this . setEmail }
/ >
< /FormGroup >
< FormGroup >
< ControlLabel > Password < /ControlLabel >
< FormControl
id = " authPassword "
type = " password "
label = " Password "
placeholder = " Enter password "
onChange = { this . setPassword }
/ >
< /FormGroup >
< Button type = " submit " >
Log in
< /Button >
< /form >
< /Col >
< /Row >
< /Grid >
)
}
defaultState () {
return {
email : {
value : '' ,
error : ' Email is required. '
},
password : {
value : '' ,
error : ' Password is required. '
},
submit : {
error : ''
},
formSubmitted : false
}
}
constructor ( props ) {
super ( props )
this . state = this . defaultState ()
this . handleSubmit = this . handleSubmit . bind ( this )
this . setPassword = this . setPassword . bind ( this )
this . setEmail = this . setEmail . bind ( this )
}
getFormErrors () {
let fields = [ ' email ' , ' password ' , ' submit ' ]
let errors = []
fields . map ( field => {
let fieldError = this . state [ field ]. error || ''
if ( fieldError . length > 0 ) {
errors . push ( fieldError )
}
})
return errors
}
setEmail ( event ) {
let newVal = event . target . value || ''
let errorMessage = newVal . length === 0 ? ' Email is required. ' : ''
this . setState ({
email : {
value : newVal ,
error : errorMessage
},
submit : {
error : ''
}
})
}
setPassword ( event ) {
let newVal = event . target . value || ''
let errorMessage = newVal . length === 0 ? ' Password is required. ' : ''
this . setState ({
password : {
value : newVal ,
error : errorMessage
},
submit : {
error : ''
}
})
}
handleSubmit ( event ) {
event . preventDefault ()
this . setState ({
formSubmitted : true ,
submit : {
error : ''
}
})
if ( this . getFormErrors (). length > 0 ) {
return false
}
Api . authenticateUser ( this . state . email . value , this . state . password . value ). then ( jwt => {
if ( jwt ) {
this . props . propagateSignIn ( jwt , this . props . history )
}
else {
this . setState ({
submit : {
error : ' Sorry, we could not log you in with the credentials provided. Please try again. '
}
})
}
})
}
}
export default AuthSignInComponent
I provided the route and AuthSignOut component to allow a user to sign out. On controller instantiation, it simply uses the propagate callback on the TokenAuthComponent to remove the cookie and clear the user attributes from state. new file: src/components/AuthSignOut.js
import React from ' react '
class AuthSignOutComponent extends React . Component {
render () {
return null
}
constructor ( props ) {
super ( props )
this . props . propagateSignOut ( this . props . history )
}
}
export default AuthSignOutComponent
The final Page component handles loading and displaying the page content. The AppHeader component provides the nav item for each page, and the route with id param (/page/:id) is defined in TokenAuthComponent. When the Page component is mounted, it attempts to fetch the page content from the API using the JWT header. A ‘access denied’ flash message is displayed instead of the content when the API call fails. new file: src/components/Page.js
import React from ' react '
import { Grid , Row , Col , Alert } from ' react-bootstrap '
const Api = require ( ' ../lib/Api.js ' )
class PageComponent extends React . Component {
render () {
if ( this . state . loading ) {
return null
}
return (
< Grid >
< Row >
< Col xs = { 12 } md = { 12 } >
{ this . state . flashMessage . message &&
< Grid >
< Row >
< Col xs = { 12 } md = { 12 } >
< Alert bsStyle = { this . state . flashMessage . style } >
{ this . state . flashMessage . message }
< /Alert >
< /Col >
< /Row >
< /Grid >
}
< div > { this . state . page . content } < /div >
< /Col >
< /Row >
< /Grid >
)
}
componentDidMount () {
this . getPage ()
}
componentWillReceiveProps ( nextProps ) {
let prevPageId = this . props . match . params . id
let newPageId = nextProps . match . params . id
// check if page component is being reloaded with new page props && reload page from Api
if ( prevPageId !== newPageId ) {
this . setState ({
page : {
id : newPageId ,
content : ''
}
})
this . getPage ( newPageId )
}
}
getPage ( pageId = null ) {
pageId = pageId || this . state . page . id
this . setState ({
loading : true ,
flashMessage : {
message : undefined ,
style : ' success '
}
})
let jwt = this . props . appState . jwt
Api . getPage ( jwt , pageId ). then ( response => {
if ( response ) {
this . setState ({
page : response ,
loading : false
})
}
else {
this . setState ({
loading : false ,
flashMessage : {
message : ' Access Denied. ' ,
style : ' danger '
}
})
}
})
}
constructor ( props ) {
super ( props )
this . state = {
page : {
id : props . match . params . id ,
content : ''
},
loading : true ,
flashMessage : {
message : undefined ,
style : ' success '
}
}
}
}
export default PageComponent
I started the app via: npm start
, and browsed to http://localhost:8000/
to demo:
Source code on Github