This is a sample application and walks through one possible auth implementation. It does not cover everything there is to know about auth and is intended as an introduction. Please do not blindly copy/paste the code here. Use this as a guide for setting up auth in a React/Redux application using JSON Web Tokens.
- Second disclaimer: there are tradeoffs to every auth implementation. To secure our application further, we should set our tokens to expire and make sure our app is being served over HTTPS. Furthermore, there are some tradeoffs to storing JWTs in browser
localStorage
. This StackOverflow post has a concise summary of the benefits/tradeoffs about where/how to store tokens client-side.
-
This section will walk through building a rails server. If you have questions about
Cors
,ActiveModel::Serializer
,Postgres
, namespacing and versioning our API, and/or general questions about Rails as an api only, refer to this guide. -
Let's create our app:
rails new backend_project_name --api --database=postgresql
- We're going to need a few gems in our
Gemfile
so let's go ahead and add them:
bundle add jwt active_model_serializers
-
If you get a gem not found error, try running gem install on each of these, or manually add them to your
Gemfile
. -
Don't forget to uncomment
rack-cors
andbcrypt
from yourGemfile
. -
Finally, run
bundle install
. YourGemfile
should look something like this:
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby '2.5.1'
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
gem 'rails', '~> 5.2.1'
# Use postgresql as the database for Active Record
gem 'pg', '>= 0.18', '< 2.0'
# Use Puma as the app server
gem 'puma', '~> 3.11'
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
# gem 'jbuilder', '~> 2.5'
# Use Redis adapter to run Action Cable in production
# gem 'redis', '~> 4.0'
# Use ActiveModel has_secure_password
gem 'bcrypt', '~> 3.1.7'
# Use ActiveStorage variant
# gem 'mini_magick', '~> 4.8'
# Use Capistrano for deployment
# gem 'capistrano-rails', group: :development
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', '>= 1.1.0', require: false
# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible
gem 'rack-cors'
group :development, :test do
# Call 'byebug' anywhere in the code to stop execution and get a debugger console
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
end
group :development do
gem 'listen', '>= 3.0.5', '< 3.2'
# Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
gem 'spring'
gem 'spring-watcher-listen', '~> 2.0.0'
end
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]
gem "jwt", "~> 2.1"
gem "active_model_serializers", "~> 0.10.7"
-
Don't forget to enable CORS in your app. Uncomment the following in
config/initializers/cors.rb
. Don't forget to change the origins fromexample.com
to*
-
Depending on the use-case and needs of our API, we might want to limit access to our app. For example, if our React frontend is deployed to
myDankReactApp.com
, we might want to limit access to that domain only. If certain endpoints are meant to be public, we can make those available but limit toGET
requests, for example.
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*'
resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head]
end
end
- You can refer to the rack-cors gem for more information about this file.
- Please don't forget to change these settings before deploying your app to the internet. Please
- Run this to set up our User model:
rails g resource User username password_digest bio avatar
rails db:create db:migrate
- Add
has_secure_password
toapp/models/user.rb
. Recall thathas_secure_password
comes fromActiveModel
and adds methods to set and authenticate against a BCrypt password:
class User < ApplicationRecord
has_secure_password
end
- You might also want to add some validations to your users:
class User < ApplicationRecord
has_secure_password
validates :username, uniqueness: { case_sensitive: false }
end
-
Recall that
BCrypt
allows us to salt users' plaintext passwords before running them through a hashing function. A hashing function is, basically, a one way function. Similar to putting something in a meat grinder: we cannot feasibly reconstruct something that's been ground up by a meat grinder. We then store these passwords that have been 'digested' byBCrypt
in our database. Never ever ever store your users' plaintext passwords in your database. It's bad form and should be avoided at all costs. -
Let's take a look at some of the functionality provided by
BCrypt
:
# in rails console
> BCrypt::Password.create('P@ssw0rd')
=> "$2a$10$D0iXNNy/5r2YC5GC4ArGB.dNL6IpUzxH3WjCewb3FM8ciwsHBt0cq"
BCrypt::Password
inherits from the RubyString
class and has its own == instance method that allows us to run a plaintext password throughBCrypt
using the same salt and compare it against an already digested password:
# in rails console
> salted_pw = BCrypt::Password.create('P@ssw0rd')
=> "$2a$10$YQvJPemUzm8IdCCaHxiOOes6HMEHda/.Hl60cUoYb4X4fncgT8ubG"
> salted_pw.class
=> BCrypt::Password
> salted_pw == 'P@ssw0rd'
=> true
BCrypt
also provides a method that will take a stringifiedpassword_digest
and turn it into an instance ofBCrypt::Password
, allowing us to call the over-written==
method.
# in rails console
> sample_digest = User.last.password_digest
=> "$2a$10$SJiIJnmQJ/A4z4fFG5EuE.aOoCjacFuQMVpVzQnhPSJKYLFCoqmWy"
> sample_digest.class
=> String
> sample_digest == 'P@ssword'
=> false
> bcrypt_sample_digest = BCrypt::Password.new(sample_digest)
=> "$2a$10$dw4sYcbLXc8XRX6YGc7ve.ot6LbYevMbSpFQZUaa8tm5NI8cxBPwa"
> bcrypt_sample_digest.class
=> BCrypt::Password
> bcrypt_sample_digest == 'P@ssw0rd'
=> true
- We have no way of storing instances of
BCrypt::Password
in our database. Instead, we're storing users' password digests as strings. If we were to build our ownUser#authenticate
method usingBCrypt
, it might look something like this:
class User < ApplicationRecord
attr_accessor :password
def authenticate(plaintext_password)
if BCrypt::Password.new(self.password_digest) == plaintext_password
self
else
false
end
end
end
# in rails console
> User.last.authenticate('not my password')
=> false
> User.last.authenticate('P@ssw0rd')
=> #<User id: 21, username: "sylviawoods", password_digest: "$2a$10$dw4sYcbLXc8XRX6YGc7ve.ot6LbYevMbSpFQZUaa8tm...", avatar: nil, created_at: "2018-08-31 02:11:15", updated_at: "2018-08-31 02:11:15", bio: "'Sylvia Woods was an American restaurateur who founded the sould food restaurant Sylvia's in Harlem on Lenox Avenue, New York City in 1962. She published two cookbooks and was an important figure in the community.">
- Instead of creating our own
User#authenticate
method, we can useActiveModel#has_secure_password
:
class User < ApplicationRecord
has_secure_password
end
- Let's add a
create
method to ourUsersController
:
class Api::V1::UsersController < ApplicationController
def create
@user = User.create(user_params)
if @user.valid?
render json: { user: UserSerializer.new(@user) }, status: :created
else
render json: { error: 'failed to create user' }, status: :bad_request
end
end
private
def user_params
params.require(:user).permit(:username, :password, :bio, :avatar)
end
end
-
We can use the built in Rails HTTP status code symbols when sending responses to the client;
status: :bad_request
, for instance. Need a refresher on HTTP Status Codes? Check out httpstatusrappers.com/ -
And update our
UserSerializer
:
class UserSerializer < ActiveModel::Serializer
attributes :username, :avatar, :bio
end
- Next let's add the routes we'll need for our server. In
config/routes.rb
:
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :users, only: [:create]
post '/login', to: 'auth#create'
get '/profile', to: 'users#profile'
end
end
end
- Take some time to test this either in Postman or with JavaScript fetch:
fetch('http://localhost:3000/api/v1/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
user: {
username: "sylviawoods",
password: "whatscooking",
bio: "Sylvia Woods was an American restaurateur who founded the sould food restaurant Sylvia's in Harlem on Lenox Avenue, New York City in 1962. She published two cookbooks and was an important figure in the community.",
avatar: "https://upload.wikimedia.org/wikipedia/commons/4/49/Syvia_of_Sylvia%27s_reaturant_N.Y.C_%28cropped%29.jpg"
}
})
})
.then(r => r.json())
.then(console.log)
Note: if you're using Postman and your formatting is set to "raw and JSON", remember to use double quotes ("") in both keys and values in the request.
- Token-based authentication is stateless. We are not storing any information about a logged in user on the server (which also means we don't need a model or table for our user sessions). No stored information means our application can scale and add more machines as necessary without worrying about where a user is logged in. Instead, the client (browser) stores a token and sends that token along with every authenticated request. Instead of storing a plaintext username, or user_id, we can encode user data with JSON Web Tokens (JWT) and store that encoded token client-side.
- Here is the JWT authentication flow for logging in:
- An already existing user requests access with their username and password
- The app validates these credentials
- The app gives a signed token to the client
- The client stores the token and presents it with every request. This token is effectively the user's access pass––it proves to our server that they are who they claim to be.
-
JWTs are composed of three strings separated by periods:
aaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccc
-
The first part (aaaaaaaaaaaa) is the header
-
The second part (bbbbbbbbbbbb) is the payload - the good stuff, like who this person is, and their id in our database.
-
The third part (ccccccccccccc) is the signature. The signature is a hash of the header and the payload. It is hashed with a secret key, that we will provide (and should store in an environment variable using a gem like Figaro)
-
Head on over to jwt.io and see for yourself:
-
- Since we've already added
gem jwt
to our gemfile, let's explore some JWT methods by opening arails console
JWT.encode
takes up to three arguments: a payload to encode, an application secret of the user's choice, and an optional third that can be used to specify the hashing algorithm used. Typically, we don't need to show the third. This method returns a JWT as a string.JWT.decode
takes three arguments as well: a JWT as a string, an application secret, and––optionally––a hashing algorithm.
#in rails console
> payload = { beef: 'steak' }
> jwt = JWT.encode(payload, 'boeuf')
=> "eyJhbGciOiJIUzI1NiJ9.eyJiZWVmIjoic3RlYWsifQ._IBTHTLGX35ZJWTCcY30tLmwU9arwdpNVxtVU0NpAuI"
> decoded_hash = JWT.decode(jwt, 'boeuf')
=> [{"beef"=>"steak"}, {"alg"=>"HS256"}]
> data = decoded_hash[0]
=> {"beef"=>"steak"}
Building this functionality into our ApplicationController
:
- Given that many different controllers will need to authenticate and authorize users––
AuthController
,UsersController
, etc––it makes sense to lift the functionality of encoding/decoding tokens to our top levelApplicationController
. (Recall that all controllers inherit fromApplicationController
)
class ApplicationController < ActionController::API
def encode_token(payload)
# payload => { beef: 'steak' }
JWT.encode(payload, 'my_s3cr3t')
# jwt string: "eyJhbGciOiJIUzI1NiJ9.eyJiZWVmIjoic3RlYWsifQ._IBTHTLGX35ZJWTCcY30tLmwU9arwdpNVxtVU0NpAuI"
end
def decoded_token(token)
# token => "eyJhbGciOiJIUzI1NiJ9.eyJiZWVmIjoic3RlYWsifQ._IBTHTLGX35ZJWTCcY30tLmwU9arwdpNVxtVU0NpAuI"
JWT.decode(token, 'my_s3cr3t')[0]
# JWT.decode => [{ "beef"=>"steak" }, { "alg"=>"HS256" }]
# [0] gives us the payload { "beef"=>"steak" }
end
end
-
According to the JWT Documentation: Whenever the user wants to access a protected route or resource, the user agent (browser in our case) should send the JWT, typically in the Authorization header using the Bearer schema. The content of the header should look like the following:
Authorization: Bearer <token>
- The corresponding
fetch
request might look like this:
fetch('http://localhost:3000/api/v1/profile', {
method: 'GET',
headers: {
Authorization: `Bearer <token>`
}
})
- Knowing this, we can set up our server to anticipate a JWT sent along in request headers, instead of passing the token directly to
ApplicationController#decoded_token
:
class ApplicationController < ActionController::API
def encode_token(payload)
# payload => { beef: 'steak' }
JWT.encode(payload, 'my_s3cr3t')
# jwt string: "eyJhbGciOiJIUzI1NiJ9.eyJiZWVmIjoic3RlYWsifQ._IBTHTLGX35ZJWTCcY30tLmwU9arwdpNVxtVU0NpAuI"
end
def auth_header
# { 'Authorization': 'Bearer <token>' }
request.headers['Authorization']
end
def decoded_token
if auth_header
token = auth_header.split(' ')[1]
# headers: { 'Authorization': 'Bearer <token>' }
begin
JWT.decode(token, 'my_s3cr3t', true, algorithm: 'HS256')
# JWT.decode => [{ "beef"=>"steak" }, { "alg"=>"HS256" }]
rescue JWT::DecodeError
nil
end
end
end
- A few things to note about the code above:
- The
Begin/Rescue
syntax allows us to rescue out of an exception in Ruby. Let's see an example in arails console
. In the event our server receives and attempts to decode an invalid token:
- The
# in rails console
> invalid_token = "nnnnnnnooooooootttttt.vvvvvvaaaallliiiiidddddd.jjjjjjjwwwwwttttttt"
> JWT.decode(invalid_token, 'my_s3cr3t', true, algorithm: 'HS256')
Traceback (most recent call last):
1: from (irb):6
JWT::DecodeError (Invalid segment encoding)
- In other words, if our server receives a bad token, this will raise an exception causing a 500 Internal Server Error. We can account for this by rescuing out of this exception:
# in rails console
> invalid_token = "nnnnnnnooooooootttttt.vvvvvvaaaallliiiiidddddd.jjjjjjjwwwwwttttttt"
> begin JWT.decode(invalid_token, 'my_s3cr3t', true, algorithm: 'HS256')
rescue JWT::DecodeError
nil
> end
=> nil
- Instead of crashing our server, we simply return
nil
and keep trucking along.
- We can then complete our
ApplicationController
by automatically obtaining the user whenever an authorization header is present:
class ApplicationController < ActionController::API
def encode_token(payload)
# don't forget to hide your secret in an environment variable
JWT.encode(payload, 'my_s3cr3t')
end
def auth_header
request.headers['Authorization']
end
def decoded_token
if auth_header
token = auth_header.split(' ')[1]
begin
JWT.decode(token, 'my_s3cr3t', true, algorithm: 'HS256')
rescue JWT::DecodeError
nil
end
end
end
def current_user
if decoded_token
# decoded_token=> [{"user_id"=>2}, {"alg"=>"HS256"}]
# or nil if we can't decode the token
user_id = decoded_token[0]['user_id']
@user = User.find_by(id: user_id)
end
end
def logged_in?
!!current_user
end
end
- Recall that a Ruby object/instance is 'truthy':
!!user_instance #=> true
and nil is 'falsey':!!nil #=> false
. Thereforelogged_in?
will just return a boolean depending on what ourcurrent_user
method returns.
- Finally, let's lock down our application to prevent unauthorized access:
class ApplicationController < ActionController::API
before_action :authorized
def encode_token(payload)
# should store secret in env variable
JWT.encode(payload, 'my_s3cr3t')
end
def auth_header
# { Authorization: 'Bearer <token>' }
request.headers['Authorization']
end
def decoded_token
if auth_header
token = auth_header.split(' ')[1]
# header: { 'Authorization': 'Bearer <token>' }
begin
JWT.decode(token, 'my_s3cr3t', true, algorithm: 'HS256')
rescue JWT::DecodeError
nil
end
end
end
def current_user
if decoded_token
user_id = decoded_token[0]['user_id']
@user = User.find_by(id: user_id)
end
end
def logged_in?
!!current_user
end
def authorized
render json: { message: 'Please log in' }, status: :unauthorized unless logged_in?
end
end
- A few things to note about the code above:
before_action :authorized
will call the authorized method before anything else happens in our app. This will effectively lock down the entire application. Next we'll augment ourUsersController
and build ourAuthController
to allow signup/login.
Updating the UsersController
- Let's update the UsersController so that it issues a token when users register for our app:
class Api::V1::UsersController < ApplicationController
skip_before_action :authorized, only: [:create]
def create
@user = User.create(user_params)
if @user.valid?
@token = encode_token(user_id: @user.id)
render json: { user: UserSerializer.new(@user), jwt: @token }, status: :created
else
render json: { error: 'failed to create user' }, status: :not_acceptable
end
end
private
def user_params
params.require(:user).permit(:username, :password, :bio, :avatar)
end
end
- We need to make sure to skip the
before_action :authorized
coming from ApplicationController
class Api::V1::UsersController < ApplicationController
skip_before_action :authorized, only: [:create]
end
- It wouldn't make sense to ask our users to be logged in before they create an account. This circular logic will make it impossible for users to authenticate into the app. How can a user create an account if our app asks them to be logged in or
authorized
to do so? Skipping the before action 'unlocks' this portion of our app.
- Try creating a new user again with either postman or fetch and confirm that your server successfully issues a token on signup.
-
A token should be issued in two different controller actions:
UsersController#create
andAuthController#create
. Think about what these methods are responsible for––a user signing up for our app for the first time and an already existing user logging back in. In both cases, our server needs to issue a new token🥇. -
We'll need to create a new controller to handle login:
rails g controller api/v1/auth
. Next, let's add the following to this newly created AuthController:
class Api::V1::AuthController < ApplicationController
skip_before_action :authorized, only: [:create]
def create
@user = User.find_by(username: user_login_params[:username])
#User#authenticate comes from BCrypt
if @user && @user.authenticate(user_login_params[:password])
# encode token comes from ApplicationController
token = encode_token({ user_id: @user.id })
render json: { user: UserSerializer.new(@user), jwt: token }, status: :accepted
else
render json: { message: 'Invalid username or password' }, status: :unauthorized
end
end
private
def user_login_params
# params { user: {username: 'Chandler Bing', password: 'hi' } }
params.require(:user).permit(:username, :password)
end
end
-
We can simply call our
ApplicationController#encode_token
method, passing the found user's ID in a payload. The newly created JWT can then be passed back along with the user's data. The user data can be stored in our application's state, e.g., React or Redux, while the token can be stored client-side. -
A few things to keep in mind about the code above:
User.find_by({ name: 'Chandler Bing' })
will either return a user instance if that user can be found OR it will returnnil
if that user is not found.- In the event that the user is not found,
user = User.find_by(username: params[:username])
will evaluate tonil
. - Can we call
.authenticate
onnil
? NO!!NoMethodError (undefined method 'authenticate' for nil:NilClass)
- Ruby, however, is lazy. If Ruby encounters
&&
, both statements in the expression must evaluate to true. If the statement on the left side evaluates to false, Ruby will not even look at the statement on the right. Let's see an example:
# in irb or a rails console
> true && true
=> true
> true && false
=> false
> true && not_a_variable
NameError (undefined local variable or method `not_a_variable` for main:Object)
> false && not_a_variable
=> false
- Let's take another look at our previous example:
@user = User.find_by(username: params[:username])
if @user && @user.authenticate(params[:password])
end
- If
@user
isnil
, which is falsey, ruby will not even attempt to call@user.authenticate
. Without this catch, we'd get aNoMethodError (undefined method 'authenticate' for nil:NilClass)
.
- Again, the client should be sending a JWT along with every authenticated request. Refer to this diagram from scotch.io:
- A sample request might look like:
fetch('http://localhost:3000/api/v1/profile', {
method: 'GET',
headers: {
Authorization: `Bearer <token>`
}
})
- So, let's update our
UsersController
so that an authenticated user can access their profile information:
class Api::V1::UsersController < ApplicationController
skip_before_action :authorized, only: [:create]
def profile
render json: { user: UserSerializer.new(current_user) }, status: :accepted
end
def create
@user = User.create(user_params)
if @user.valid?
@token = encode_token({ user_id: @user.id })
render json: { user: UserSerializer.new(@user), jwt: @token }, status: :created
else
render json: { error: 'failed to create user' }, status: :not_acceptable
end
end
private
def user_params
params.require(:user).permit(:username, :password, :bio, :avatar)
end
end
- One final note about the snippet above:
ApplicationController
callsauthorized
before any other controller methods are called. If authorization fails, our server will never callUsersController#profile
and will instead:
render json: { message: 'Please log in' }, status: :unauthorized
- HTTPS Wikipedia Article
- Storing JWTs in Cookies vs HTML5 localStorage
- StackOverflow Post on Cookies vs localStorage for Storing Tokens
- Mod3 API Setup Guide
- rack-cors gem
- MDN article on CORS
- Bcrypt gem
- Bcrypt::Password source code
- What is a Salt in Cryptography
- What is a Cryptographic Hash Function
- ActiveModel has_secure_password docs
- Mozilla Blog Post on Storing Passwords in a Database
- ActiveModelSerializers gem
- ActiveRecord Validations Documentation
- SitePoint Article on ActiveModelSerializers in Rails
- Postman App for making HTTP requests
- JWT Documentation
- JWT Ruby Gem on GitHub
- JWT in Depth
- Scotch.io: The Ins and Outs of Token Based Authentication
- Authentication
- Authorization
- Authentication vs Authorization
- Figaro Gem for hiding secrets in your app
- Ruby Begin Rescue Documentation
- HTTP Status Rappers
- MDN Article on HTTP Status Codes
- Rails Status Code Symbols Cheat Sheet
- React Documentation
- Redux Documentation
Resources:
- Google Sign-in Basics
- Backend Authentication
- React Google Login package
- dotenv-rails gem
- google-id-token gem
Follow the steps to create your authorization credentials (just up to step 4 under "Create authorization credentials" - skip the sections below that):
https://developers.google.com/identity/sign-in/web/sign-in
Take note of the client ID - you'll need that later for React and Rails.
First let's save the client ID in a .env
file so we can access that later. In the root of your React application, create a file called .env
and add your Google client ID, like so:
REACT_APP_GOOGLE_CLIENT_ID=739034625712-ads90ik8978gyahbbdf7823asd8213as.apps.googleusercontent.com
Next, install this package:
npm install react-google-login
We'll use this to display a Google sign in button and handle logic for authenticating the user with Google. Update the <Login>
component like this:
import React from 'react'
import { GoogleLogin } from 'react-google-login';
class Login extends React.Component {
state = {
username: "",
password: ""
}
// new code!
handleGoogleLogin = (response) => {
// we'll get a tokenId back from Google on successful login that we'll send to our server to find/create a user
if (response.tokenId) {
fetch("http://localhost:3000/google_login", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${response.tokenId}`
}
})
.then(r => r.json())
.then(data => {
console.log(data)
const { user, token } = data
// then set that user in state in our App component
this.props.handleLogin(user)
// also save the id to localStorage
localStorage.token = token
})
}
}
// old code
handleChange = e => {
this.setState({ [e.target.name]: e.target.value })
}
// old code
handleSubmit = e => {
e.preventDefault()
fetch("http://localhost:3000/login", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(this.state)
})
.then(r => r.json())
.then(data => {
console.log(data)
const { user, token } = data
// then set that user in state in our App component
this.props.handleLogin(user)
// also save the id to localStorage
localStorage.token = token
})
}
render() {
return (
<div>
<form onSubmit={this.handleSubmit}>
<h1>Login</h1>
<label>Username</label>
<input type="text" name="username" autoComplete="off" value={this.state.username} onChange={this.handleChange} />
<label>Password</label>
<input type="password" name="password" value={this.state.password} onChange={this.handleChange} autoComplete="current-password" />
<input type="submit" value="Login" />
</form>
<hr />
<div>
{/* this is the new component that will help with Google sign in */}
<GoogleLogin
clientId={process.env.REACT_APP_GOOGLE_OAUTH_CLIENT_ID}
buttonText="Login"
onSuccess={this.handleGoogleLogin}
onFailure={this.handleGoogleLogin}
cookiePolicy={'single_host_origin'}
/>
</div>
</div>
)
}
}
export default Login
That's it for the frontend! The backend will take more work to set up.
First, we'll need to install a couple gems:
bundle add google-id-token
bundle add dotenv-rails
Next, create a .env
file in the root of your project directory and add the Google client ID:
GOOGLE_OAUTH_CLIENT_ID=739034625712-ads90ik8978gyahbbdf7823asd8213as.apps.googleusercontent.com
You should also update your .gitignore
file so that your .env
file isn't checked into Github:
# add this at the bottom of the file
# .env files
.env*
Next, add a route for handling the Google login request:
# config/routes.rb
post "/google_login", to: "users#google_login"
Then, update your UserController
to handle this request:
# app/controllers/user_controller.rb
class UsersController < ApplicationController
# don't run authorize before google_login, remember - authorized should only run for methods where we expect the user is *already* logged in
skip_before_action :authorized, only: [:create, :login, :google_login]
# other methods here...
def google_login
# use a helper method to extract the payload from the google token
payload = get_google_token_payload
if payload
# find/create user from payload (this will be a new method in the User model)
user = User.from_google_signin(payload)
if user
# save user_id in token so we can use it in future requests
token = encode_token({ user_id: user.id })
# send token and user in response
render json: { user: UserSerializer.new(user), token: token }
return
end
end
# for invalid requests, send error messages to the front end
render json: { message: "Could not log in" }, status: :unauthorized
end
private
# helper function to validate the user's token from Google and extract their info
def get_google_token_payload
if request.headers["Authorization"]
# extract the token from the Authorization header
token_id = request.headers["Authorization"].split(" ")[1]
# this is the code from the Google auth gem
validator = GoogleIDToken::Validator.new
begin
# check the token_id and return the payload
# make sure your .env file has a matching key
validator.check(token_id, ENV["GOOGLE_OAUTH_CLIENT_ID"])
rescue GoogleIDToken::ValidationError => e
p "Cannot validate: #{e}"
end
end
end
We'll also add a helper method for creating a new user from the Google payload:
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
validates :username, presence: true, uniqueness: { case_sensitive: false }
def self.from_google_signin(payload)
# find or create a user based on the email address from the Google payload
User.where(username: payload["email"]).first_or_create do |new_user|
new_user.username = payload["email"]
new_user.avatar = payload["picture"]
# we need to assign a password to satisfy bcrypt, so generate a random one...
new_user.password = SecureRandom.base64(15)
end
end
end
Now, test it out! You should be able to login with your Google account and create a new User instance in the backend with that information.