Sessions with a React/Redux Frontend and Rails API Backend
For my most recent project, I built an app called EduSource that allows parents to crowdsource online educational resources and organize them (check it out on github or live). Think Pinterest for educational resources.
One of the bigger challenges I took on was managing sessions with a decoupled frontend/backend. Rails makes sessions pretty easy to handle, IF EVERYTHING IS IN RAILS, but it gets a little more complicated when you use a different system for managing frontend behavior, particularly when interaction between the frontend and backend is asynchronous.
Sadly, I was unable to find a definitive guide that covers all the things you need to know to make this work. Instead, I pulled together a solution based on three different sources:
- Mike Clark’s overview of the general flow you want to have
- Kailana Kahawaii’s overview on setting up JSON Web Tokens (JWT) with Rails
- JMFurlott’s guide on using JavaScript Cookies to store and utilize session data in React
!!CAVEAT!! — This is a beginners solution. I’d guess that this is not the most secure solution. Rather, it’s a moderately secure solution that I hope will be easier to understand for beginners just learning about how to implement secure sessions.
Step 1: Set up JWT in the Rails API Backend
First things first, you need to install the JWT gem. So, in your Gemfile put gem “jwt”, “~> 2.2”
and then bundle install
it.
Next, you need to generate and securely store a SESSION_SECRET, a cryptographically secure key you’ll use to generate tokens. There are multiple ways to both generate and securely store this key. For example, the article referenced above uses a JWT generator website and rails credentials. I used Ruby’s SecureRandom gem to generate the key and the dotenv gem to store it my Rails environment. It’s helpful to set up your storage environment first. Put gem ‘dotenv-rails’
in your gemfile, bundle install
, then create a .env file in your root directory and put .env
in your .gitignore file (so you don’t upload your secret key to github). Now, after installing securerandom you can run
ruby -e “require ‘sysrandom/securerandom’; puts SecureRandom.hex(64)”
from the terminal to generate a secret key. Copy and paste this key and save it in your .env file like so:
SESSION_SECRET = [insert the key you just generated]
The rest, I blatantly stole from the article above, minus swapping out the return value for the jwt_key
function to return the key from the .env file instead of rails credentials. You can put the code below in your ApplicationController
that other controllers inherit from, or you could create a helper module. I did the former.
def jwt_key
ENV[‘SESSION_SECRET’]
enddef issue_token(user)
JWT.encode({user_id: user.id}, jwt_key, ‘HS256’)
enddef decoded_token
begin
JWT.decode(token, jwt_key, true, { :algorithm => ‘HS256’ })
rescue JWT::DecodeError
[{error: “Invalid Token”}]
end
enddef token
request.headers[‘Authorization’]
enddef user_id
decoded_token.first[‘user_id’]
enddef current_user
user ||= User.find_by(id: user_id)
enddef logged_in?
!!current_user
end
Now, when creating a user you can simply call the issue_token
function, and, when authorizing a user, calling the current_user
function will return a non-nil value only if there is a valid token.
Step 2: Setting up a Session/User Store
There are probably a variety of ways to do this. I used Redux and created a usersReducer
that set up and handled the store for the user data model. My code assumes some working knowledge of how Redux works, but you could also save this data in state if you’re not using Redux. The import piece of information is that the store I set up had the following data structure:
state = {
{…your other data models},
user: {
current: {},
valid: true,
authCompleted: false,
errors: {}
}
Here, user.current
will store a valid user object, user.valid
signifies the result of authenticating and authorizing a user, and user.authCompleted
tells you whether or not an auth process has been conducted yet to see if there is a valid user. I defaulted user.valid
to true
to avoid some unintended redirects to the login screen. However, there is probably a more elegant solution to that, and I suspect it’s safer to default user.valid
to false
rather than setting it as such whenever an auth process starts, as I did.
Step 3: Login/Signup your User
I won’t go through everything here. I’ll just give an outline. The important pieces are to install the js-cookie package, import that functionality, set the cookie with the value of the JWT that is returned from your Rails API backend, and then update your store accordingly. Some of the code that’s needed to do this is:
npm install js-cookie
from the terminal, and:
import * as Cookies from ‘js-cookie’
at the top of whichever React file you’re managing cookies from. For me, this was a usersActions
file managing all dispatches related to user activity.
After sending a fetch request to your API backend, if the user data is valid, set the cookie. The JSON my sessions controller sent back looked something like
{
valid: true,
user: {id: 1, username: 'Charlie'},
token: '[generated api token here]'
}
With that, I was able to set the cookie in one of my userAcitons
functions with the code:
Cookies.remove('eduResourceSession')
Cookies.set('eduResourceSession', jsonData.token, { expires: 14 })
'eduResourceSession’
refers to the name of the cookie that gets stored, but this could be anything, and the second argument in Cookies.set()
sets the value of the cookie to the token that’s returned.
The last step is then to update your store/state based on what’s returned. If the user is valid, set user.valid
to true
, and user.current
to the user data sent back. Regardless of the validation state, set user.authCompleted
to true
.
Step 4: Redirect user to Login if not authorized
For user related components in my react app I would insert the following code:
componentDidMount(){
this.props.authUser()
}
where authUser()
is a function that makes a fetch request to the API sessions controller including the token in the Authorization
header and sets the state to user.valid = true
if the controller returns a valid user.
function authUser(){
let token = Cookies.get("eduResourceSession")
return dispatch => {
dispatch({type: "START_AUTH"})
if (token){
const configObj = {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: token
},
credentials: 'include'
}
fetch(BASE_URL.concat('/authorize'), configObj)
.then(resp => resp.json())
.then(authResp => {
if (authResp.valid === "true"){
dispatch({type: "LOGIN_USER", user: authResp.user})
} else {
dispatch({type: "INVALID_USER", errors: {session: "Please login to continue"}})
}
})
} else {
dispatch({type: "INVALID_USER", errors: {session: "Please login to continue"}})
}
dispatch({type: "COMPLETE_AUTH"})
}
Note also credentials: 'include'
in the request object. This becomes important in production if you are making cross origin requests from your frontend to backend.
After this, I would then put some logic at the top of my render()
function in any components I didn’t want the user to see without logging in:
if (this.props.user.valid){
return <Component/>
} else {
return <Redirect to=’/login’/>`
}
Components re-render when state changes, so the when user.authCompleted
changes during the auth process, the component re-renders and checks again if the user is valid.
Step 5: Authorizing your user when you make requests to the Backend API
The last step is to authorize your user whenever you are making a request to the backend that requires them to be authorized. For my application, this was restricted to POST, PATCH, and GET requests for data that was specifically tied to the user data model. The basic outline for this step is to get the token from cookies whenever making one of these requests, send it in the authorization header, and only return data from or post data to the backend if the token is valid. The code for this is very similar to the code in step 4 so I will not reproduce it here. The main difference is that this logic dictates how the backend responds to a client request, not what to render to the client (as covered in step 4). If you’re making POST or PATCH requests the auth logic will also get triggered by a form submission instead of componentDidMount
.
That is a LOT! I hope this helps you understand the session flow using a React-Redux frontend and Rails API backend. Please reach out if you have any comments, questions or improvements!