User Authentication in React via Rails API
A comprehensive guide to User authentication for Rails/React
So you have decided that your new project is going to use a Rails API for the server-side and React for the client-side. You enter rails new
and create-react-app
and get to work. You decide that you want to implement user authentication. Aside from a few configuration changes to allow the use of cookies (which I will explain very soon), where to start?
In this article, I will walk through the needed steps and demonstrate how this is very straightforward and simple. I will be using Rails 5 but everything should work for Rails 6 as well.
Phase 1: Configuring Rails App for Cookies
If you did not use the --api
flag in the rails new
command the following steps should not be necessary. If you are starting from an existing Rails API there are a few configuration changes that I will cover in this section.
The gems you will need are bcrypt
and rack-cors
. The gem ‘bcrypt-ruby’ is a Ruby binding for the OpenBSD bcrypt() password hashing algorithm, allowing you to easily store a secure hash of your user passwords. The latter is middleware for handling Cross-Origin Resource Sharing (CORS), which makes cross-origin AJAX possible. It will allow you to whitelist the origin of requests to the Rails server.
Place those in Gemfile and run bundle
.
#Gemfilegem 'bcrypt'gem 'rack-cors'
Let’s start with “application_controller.rb”. You will see the following.
#application_controller.rbclass ApplicationController < ActionController::API
The API Controller is a lightweight version of ActionController::Base, created for applications that don’t require all functionalities that a complete Rails controller provides, allowing you to create controllers with just the features that you need for API only applications. So to get the complete functionality of a Rails controller, we will change this line.
#application_controller.rbclass ApplicationController < ActionController::Base
Next, we will move to “config/application.rb” where we will apply middleware that will allow us to send receive cookies. Add these two lines.
#config/application.rbconfig.middleware.use ActionDispatch::Cookies config.middleware.use ActionDispatch::Session::CookieStore
We will also change the line that statesconfig.api_only = true
to config.api_only = false
. It should be around line 37.
You will now need to create two files at “config/initializers/cookie_serializer.rb” and “config/initializers/session_store.rb” . This will configure how our server will handle the HTTP requests. The server needs to know the key
and the domain
.
#config/initializers/cookie_serailizer.rbRails.application.config.action_dispatch.cookies_serializer = :hybrid#config/initializers/session_store.rbif Rails.env === 'production'
Rails.application.config.session_store :cookie_store, key: '_name-of-your-app', domain: 'name-of-you-app-json-api'
else
Rails.application.config.session_store :cookie_store, key: '_name-of-your-app'
By convention, the key name is your application’s name and should start with an underscore.
The next file you will need to create is also in “config/initializers”. In that folder create a file called “cors.rb” and insert the following.
#config/initializers/cors.rbRails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'http://localhost:3000'
resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
credentials: true
end
end
By default, your React app will run on “http://localhost:3000”. The above code gives all requests from that address access to all server resources and allows them to utilize all HTTP methods.
Another important change is in “config/puma.rb”. We will change the default Rails server from 3000
to 3001
to prevent the server and client from attempting to run on the same server.
#config/puma.rb# Specifies the `port` that Puma will listen on to receive requests; default is 3000.port ENV.fetch("PORT") { 3001 }
And that’s it for our config changes. You should be set up to start building the controllers!
Phase 2: The User Model and Sessions Controller
The easiest way to get a user model going and using bcrypt for secure passwords is with rails generate
.
rails g model User username email password_digest
The careful observer will note that we are creating a password_digest
column instead of “password”. This is what is required by the bcrypt gem as the column in the user table in which to store the encrypted password. Thanks to the magic of encryption, the user model will never see the actual password and our database will never store the password but it will store a “hashed” and “salted” version of the password called a password_digest
.
We can now create and migrate our database.
rake db:create && rake db:migrate
After migration, it is best practice to check “db/schema.rb” to make sure that everything was set up correctly. The user’s table section of the file should look something like this.
#db/schema.rbcreate_table "users", force: :cascade do |t|
t.string "password_digest"
t.string "username"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
To use the secure password functions of bcrypt will need to add a line in “user.rb”. This is also where we can use a little ActiveRecord help and add some validations to the model.
#app/models/user.rbclass User < ApplicationRecord
has_secure_password
validates :username, presence: true
validates :username, uniqueness: true
validates :username, length: { minimum: 4 }
end
Now we can draw start to plan out what kind of actions a user will need. Of course, a user will need to create a username and password and will need to see there own data. We can take care of those needs with just two routesshow
and create
. We will add a third route so we have access to an action that will correspond to a list of all users and as per convention, this route will be named index
. Here is the “config/routes.rb” so far.
#config/routes.rbRails.application.routes.draw doresources :users, only: [:create, :show, :index]end
The file can also look like the following if, to quote The Dude, “you’re not into the whole brevity thing”.
#config/routes.rbRails.application.routers.draw.dopost '/users', to: 'users#create'
get '/users/:user_id', to: 'users#show'
get '/users', to: 'users#index'
Now we can connect the user’s controller. We will build actions that correspond to each of the routes. I will be using standard “REST-ful” routing and assuming that you are familiar. You will need to create a file “app/controllers/users_controller.rb”.
#app/controllers/users_controller.rbclass UsersController < ApplicationController
def index
@users = User.all
if @users
render json: {
users: @users
}
else
render json: {
status: 500,
errors: ['no users found']
}
end
enddef show
@user = User.find(params[:id])
if @user
render json: {
user: @user
}
else
render json: {
status: 500,
errors: ['user not found']
}
end
end
def create
@user = User.new(user_params)
if @user.save
login!
render json: {
status: :created,
user: @user
}
else
render json: {
status: 500,
errors: @user.errors.full_messages
}
end
endprivate
def user_params
params.require(:user).permit(:username, :password, :password_confirmation)
end
end
In the create action I am using a helper method login
. We will define this method and others in the next step. The natural home for helper methods is the “parent” of the UsersController
, ApplicationController
.
#app/contollers/application_controller.rbclass ApplicationController < ActionController::Base
skip_before_action :verify_authenticity_tokenhelper_method :login!, :logged_in?, :current_user, :authorized_user?, :logout!, :set_user
def login!
session[:user_id] = @user.id
enddef logged_in?
!!session[:user_id]
enddef current_user
@current_user ||= User.find(session[:user_id]) if session[:user_id]
enddef authorized_user?
@user == current_user
enddef logout!
session.clear
enddef set_user
@user = User.find_by(id: session[:user_id])
endend
A few things to note here. At the top we haveskip_before_action :verify_authenticity_token
. This is a security token generated by Rails to prevent cross-site request forgery (CSRF). We disable this to prevent “forbidden” parameters that would cause errors in our controller actions. We also have helper_method :login!, :logged_in?, :current_user, :authorized_user?, :logout!
. This allows for these methods to be passed to all other controllers in the app. We will shortly be using user data to create “sessions”. The sessions will be created and managed in the SessionsController
. First, we should draw the sessions routes. We will need login and logout and we will add a GET
route for /logged_in
that will tell us the login status and the current logged in user.
#config/routes.rbRails.application.routes.draw dopost '/login', to: 'sessions#create'
post '/logout', to: 'sessions#destroy'
get '/logged_in', to: 'sessions#is_logged_in?'resources :users, only: [:create, :show, :index] do
resources :items, only: [:create, :show, :index, :destroy]
endend
Now, for the controller.
#app/controllers/sessions_controller.rbclass SessionsController < ApplicationControllerdef create
@user = User.find_by(username: session_params[:username])
if @user && @user.authenticate(session_params[:password])
login!
render json: {
logged_in: true,
user: @user
}
else
render json: {
status: 401,
errors: ['no such user, please try again']
}
end
enddef is_logged_in?
if logged_in? && current_user
render json: {
logged_in: true,
user: current_user
}
else
render json: {
logged_in: false,
message: 'no such user'
}
end
enddef destroy
logout!
render json: {
status: 200,
logged_out: true
}
endprivatedef session_params
params.require(:user).permit(:username, :password)
end
end
We are authenticating the user data with .authenticate
, a method that the user model gets from bcrypt and the has_secure_password
line in the user class.
Phase 3: The React Client
Again, I am assuming that you have a functioning React app that you will be working with and basic knowledge of React. To best follow along, your React app should be placed in a directory “/client” that is in the top level of the Rails app. We can now install some dependencies that you will need. We will be using Axios for the calls to the API and react-router for client-side routing.
npm i axios && npm i react-router && npm i react-router-dom
The main container and acting router of the client app will be “App.js”. Here, we will import dependencies, convert App into a
functional component into a class component as well as write a mock-up of our routes. We are using which renders component exclusively as opposed to inclusively meaning each component is rendered on its own without appearing inside of another component.
//client/src/App.jsimport React, { Component } from 'react';
import axios from 'axios'
import {BrowserRouter, Switch, Route} from 'react-router-dom'class App extends Component {
constructor(props) {
super(props);
this.state = {
isLoggedIn: false,
user: {}
};
};render() {
return (
<div>
<BrowserRouter>
<Switch>
<Route exact path='/' component={}/>
<Route exact path='/login' component={}/>
<Route exact path='/signup' component={}/>
</Switch>
</BrowserRouter>
</div>
);
}
};
export default App;
We will need two methods that will handle the state of the component. They will update state
with the status isLoggedIn
and user data that will be returned from the API. The next post on my Medium will involve the use of redux
and thunk
to manage and dispatch actions to a reducer that we will build to handle much of the login logic. For now, we will use the state of the App
component.
//client/src/App.jshandleLogin = (data) => {
this.setState({
isLoggedIn: true,
user: data.user
})
}
handleLogout = () => {
this.setState({
isLoggedIn: false,
user: {}
})
}
These two methods will update state
with the current session data so user data can be made available only to the logged-in user. How do we get the session data? Obviously, we will make aGET
request to our /logged_in
route that will send us the status and the user but where is the best place to do this? Well, we want it to updated every time that the App
renders so naturally, we use componentDidMount()
. First let’s make the request to the backend.
//client/src/App.jsloginStatus = () => {
axios.get('http://localhost:3001/logged_in',
{withCredentials: true})
.then(response => {
if (response.data.logged_in) {
this.handleLogin(response)
} else {
this.handleLogout()
}
})
.catch(error => console.log('api errors:', error))
};
Here we use Axios to send a GET
request to our /logged_in
route making sure to add {withCredentials: true}
. With this credentials object the request will not work. You can see here one of the advantages of using Axios over fetch(). In a fetch() request, the object that is returned has to be converted to JSON using the .json()
function. Axios converts the response to JSON automatically. Now we can add this function to componentDidMount()
.
//client/src/App.jscomponentDidMount() {
this.loginStatus()
}
Our App
component should now look like this.
//client/src/App.jsimport React, { Component } from 'react';
import axios from 'axios'
import {BrowserRouter, Switch, Route} from 'react-router-dom'class App extends Component {
constructor(props) {
super(props);
this.state = {
isLoggedIn: false,
user: {}
};
}
componentDidMount() {
this.loginStatus()
}loginStatus = () => {
axios.get('http://localhost:3001/logged_in',
{withCredentials: true})
.then(response => {
if (response.data.logged_in) {
this.handleLogin(response)
} else {
this.handleLogout()
}
})
.catch(error => console.log('api errors:', error))
}handleLogin = (data) => {
this.setState({
isLoggedIn: true,
user: data.user
})
}handleLogout = () => {
this.setState({
isLoggedIn: false,
user: {}
})
}
render() {
return (
<div>
<BrowserRouter>
<Switch>
<Route exact path='/' component={}/>
<Route exact path='/login' component={}/>
<Route exact path='/signup' component={}/>
</Switch>
</BrowserRouter>
</div>
);
}
}
export default App;
Phase 5: Home, Login, and Signup Components
We now have a solid backbone of a front end user authentication system. What we are missing now is a place for the user to log in and sign up. First, we will start with the Home
component which will act as our landing screen. It will be stateless and functional.
//client/src/Home.jsimport React from 'react';
import {Link} from 'react-router-dom'const Home = () => {
return (
<div>
<Link to='/login'>Log In</Link>
<br></br>
<Link to='/signup'>Sign Up</Link>
</div>
);
};export default Home;
Home
is just a place to provide links to our other components usingLink
imported from react-router-dom
. The Login
and Signup
components will be almost identical and could be made into one however, we will not go into how to combine the elements into one in the interest of staying focused on our main goal. They will be using basic controlled forms and Signup
will have an added attribute of password_confirmation
(using snake_case as opposed to camelCase because this key will be read by Ruby and we like consistency).
Login:
//client/src/Login.jsimport React, { Component } from 'react';
import axios from 'axios'
import {Link} from 'react-router-dom'class Login extends Component {
constructor(props) {
super(props);
this.state = {
username: '',
email: '',
password: '',
errors: ''
};
}handleChange = (event) => {
const {name, value} = event.target
this.setState({
[name]: value
})
};handleSubmit = (event) => {
event.preventDefault()
};render() {
const {username, email, password} = this.statereturn (
<div>
<h1>Log In</h1>
<form onSubmit={this.handleSubmit}>
<input
placeholder="username"
type="text"
name="username"
value={username}
onChange={this.handleChange}
/>
<input
placeholder="email"
type="text"
name="email"
value={email}
onChange={this.handleChange}
/>
<input
placeholder="password"
type="password"
name="password"
value={password}
onChange={this.handleChange}
/>
<button placeholder="submit" type="submit">
Log In
</button>
<div>
or <Link to='/signup'>sign up</Link>
</div>
</form>
</div>
);
}
}
export default Login;
Signup:
//client/src/Signup.jsimport React, { Component } from 'react';
import axios from 'axios'class Signup extends Component {
constructor(props) {
super(props);
this.state = {
username: '',
email: '',
password: '',
password_confirmation: '',
errors: ''
};
}handleChange = (event) => {
const {name, value} = event.target
this.setState({
[name]: value
})
};handleSubmit = (event) => {
event.preventDefault()
};render() {
const {username, email, password, password_confirmation} = this.state
return (
<div>
<h1>Sign Up</h1>
<form onSubmit={this.handleSubmit}>
<input
placeholder="username"
type="text"
name="username"
value={username}
onChange={this.handleChange}
/>
<input
placeholder="email"
type="text"
name="email"
value={email}
onChange={this.handleChange}
/>
<input
placeholder="password"
type="password"
name="password"
value={password}
onChange={this.handleChange}
/>
<input
placeholder="password confirmation"
type="password"
name="password_confirmation"
value={password_confirmation}
onChange={this.handleChange}
/>
<button placeholder="submit" type="submit">
Sign Up
</button>
</form>
</div>
);
}
}
export default Signup;
Let’s break these components down a bit. The forms are what we call controlled forms. This means that the state of the component is updated vie onChange()
so that when the form is complete all of the data in the form is in our state
. We then utilize onSubmit()
to call our handleSubmit()
functions.
We can now use axios.post
which will need an object that will be sent to the specified route. Here is handleSubmit()
in Login
.
//client/src/Login.jshandleSubmit = (event) => {
event.preventDefault()
const {username, email, password} = this.statelet user = {
username: username,
email: email,
password: password
}
axios.post('http://localhost:3001/login', {user}, {withCredentials: true})
.then(response => {
if (response.data.logged_in) {
this.props.handleLogin(response.data)
this.redirect()
} else {
this.setState({
errors: response.data.errors
})
}
})
.catch(error => console.log('api errors:', error))
};redirect = () => {
this.props.history.push('/')
}handleErrors = () => {
return (
<div>
<ul>
{this.state.errors.map(error => {
return <li key={error}>{error}</li>
})}
</ul>
</div>
)
};
We have also added handleErrors()
that will render our errors from the .catch()
function in our request.
//client/src/Signup.jshandleSubmit = (event) => {
event.preventDefault()
const {username, email, password, password_confirmation} = this.state
let user = {
username: username,
email: email,
password: password,
password_confirmation: password_confirmation
}
axios.post('http://localhost:3001/users', {user}, {withCredentials: true})
.then(response => {
if (response.data.status === 'created') {
this.props.handleLogin(response.data)
this.redirect()
} else {
this.setState({
errors: response.data.errors
})
}
})
.catch(error => console.log('api errors:', error))
};redirect = () => {
this.props.history.push('/')
}handleErrors = () => {
return (
<div>
<ul>{this.state.errors.map((error) => {
return key={error}>{error}</li>
})}
</ul>
</div>
)
};
We use our user object from state to post to the desired route remembering to include {withCredentials:true}
. We have also included a method for redirecting after handleSubmit()
is complete. We have access to the history
props passed to us from react-router-dom
when we use render=props
on App.js
.
We now have a user authentication system for our React client! How you utilize it is up to you. You will have access to isLoggedIn
and can pass it as props wherever you need it to prevent or allow users to have access to components and data within your app.