Outline
For our project, our users will need the ability to create an account and log in to our application. Devise is an excellent authentication system made for Rails that allows us to easily drop-in User functionality into our project. We'll have to make some changes to our controllers to authenticate with JWT's since Devise uses session authentication by default.
Creating the User Model
In order to get started with Devise, we'll need to generate the configuration file which will contain our settings for Devise.
In the root of your Rails project, run:
rails generate devise:install
This command generates a file in config/initializers/devise.rb
which will configure devise when Rails boots.
In the root of your Rails project, run:
rails generate devise User
For our user profiles, we'll also need a few extra fields in addition to the ones Devise generates by default to store usernames, image URLs and user bio's. Let's go ahead and create a migration to add those columns to our Users table.
username
, image
and bio
column to the Users table
In the root of your Rails project, run:
rails g migration AddProfileFieldsToUsers username:string:uniq image:string bio:text
This migration creates a username, image and bio fields for our User. Providing
the uniq
option to username creates a unique index for that column.
We should now have all the migrations necessary for creating our users table. Let's go ahead and run these migrations to apply them to our database.
In the root of your Rails project, run:
rake db:migrate
Once the migration is finished, we should have our Users table created in our
database and a User model generated in app/models/user.rb
.
According to our specification, we want all our routes to start with /api
. To achieve this, we can use a scope in our router to prefix our routes, and pass additonal options to them. We'll want to pass defaults: { format: :json }
to our scope so that our controllers know that requests will be JSON requests by default.
api
In config/routes.rb
, wrap the devise routes in a scope :api
block, and pass in the options
defaults: { format: :json }
to the scope
method
scope :api, defaults: { format: :json } do
devise_for :users
end
Setting up Registration and Login
In order to authenticate Users with JWT tokens, we'll need to create a method on our User model that will generate a token for an instance of a user.
Create the following function in app/models/user.rb
def generate_jwt
JWT.encode({ id: id,
exp: 60.days.from_now.to_i },
Rails.application.secrets.secret_key_base)
end
In our JWT payload, we're including the id of the user, and setting the
expiration time of the token to 60 days in the future. We're using the
secret_key_base
that rails usually uses for cookies to sign our JWT tokens
(we won't be using cookies on our server) you can choose to create a different
secret if you'd like. In production this key will be set using an environment
variable.
While we're in app/models/user.rb
, let's add a validation for usernames. We want our usernames to be required and unique (so that no two users can haave the same username), along with only allowing letters and numbers.
In app/models/user.rb
add the following:
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable
+ validates :username, uniqueness: { case_sensitive: false }, presence: true, allow_blank: false, format: { with: /\A[a-zA-Z0-9]+\z/ }
Now, let's go ahead and setup our views for rendering JSON responses. We'll be using Jbuilder which is a gem that ships with Rails that allows us to create reusable JSON templates as our view layer.
app/views
called users
Create the following partial in app/views/users
called _user.json.jbuilder
:
json.(user, :id, :email, :username, :bio, :image)
json.token user.generate_jwt
This JSON partial will be used by our controllers whenever we're dealing with authentication. It contains the user's JWT token which is considered sensitive, so it will be the partial only ever gets rendered for the current user. Later down the road, we'll create a partial for user profiles which will be public-facing. In order for Devise to use the jbuilder template we just created, we'll need to create a couple jbuilder views in Devise's template folders. We're only going to be using two of Devise's endpoints in our API, registration and logging in.
Create a folder in app/views
named devise
.
Then, in the newly created app/views/devise
, create two more folders named registrations
and sessions
.
Create the following template in the registrations
and sessions
folder made in the previous step and name them create.json.jbuilder
json.user do |json|
json.partial! 'users/user', user: current_user
end
Next, let's override the create
method on Devise's SessionController
in order to customize the response behavior of the login endpoint. By default,
Devise responds with a 401 status code when authentication fails, and formats the error JSON as {error: 'Invalid username or password'}
. We want our login endpoint to respond with a 422 status code, and the body to follow the errors format in the documentation.
422
status code when a sign-in request fails
Create the following file in app/controllers/sessions_controller.rb
:
class SessionsController < Devise::SessionsController
def create
user = User.find_by_email(sign_in_params[:email])
if user && user.valid_password?(sign_in_params[:password])
@current_user = user
else
render json: { errors: { 'email or password' => ['is invalid'] } }, status: :unprocessable_entity
end
end
end
This create
method is very simlar to the stock Devise method. It looks up our user and attempts to authenticate them but responds with a 422
when authentication fails.
Now we need to let Devise know that we want to use our own SessionController
. We also
need to make our login route /api/users/login
instead of the default
/api/users/sign_in
.
config/routes.rb
In config/routes.rb
, make sure your update your devise_for
routes with the following:
scope :api, defaults: { format: :json } do
devise_for :users, controllers: { sessions: :sessions },
path_names: { sign_in: :login }
end
The controllers: { sessions: :sessions }
part tells the router to use our custom SessionsController
,
and path_names: { sign_in: :login }
replaces sign_in
in our URL for our authentication endpoint with login
.
By default, Devise doesn't allow addition fields beyond email
and password
.
We'll need to add some code in app/controllers/application_controller.rb
to
allow our users to provide usernames on registration. The Devise Readme
has instructions on how to do this which we'll be following
Under the private keyword in app/controllers/application_controller.rb
,
create the following configure_permitted_parameters
function:
private
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_up, keys: [:username])
end
Then, add the following before_action
filter after the underscore_params!
filter
line in application_controller.rb
before_action :underscore_params!
+ before_action :configure_permitted_parameters, if: :devise_controller?
Private methods in our controllers make sure that we can't mistakenly use them as controller actions, which usually require something to be rendered. Next, we'll need our controllers to call this function only if they're a Devise controller.
Before we can test our authentication endpoints, we'll need to override the way Devise figures out which user is logged in. Usually devise retrieves this information from cookies, but for our API we'll need to check the Authorization header of our request for a JWT token and get the logged in user from that.
authenticate_user
method for authenticating users with JWTs
Create the following private authenticate_user
method in application_controller.rb
private
def configure_permitted_parameters
devise_parameter_sanitizer.for(:sign_up) << :username
end
def authenticate_user
if request.headers['Authorization'].present?
authenticate_or_request_with_http_token do |token|
begin
jwt_payload = JWT.decode(token, Rails.application.secrets.secret_key_base).first
@current_user_id = jwt_payload['id']
rescue JWT::ExpiredSignature, JWT::VerificationError, JWT::DecodeError
head :unauthorized
end
end
end
end
Then, add a before_action
filter in application_controller.rb
specifying :authenticate_user
Here we're checking the incoming request for an Authorization header. The authenticate_or_request_with_http_token
is part of Rails and will grab the token from the Authorization header if it's in the format Authorization: Token jwt.token.here
. This conveniently gives us just the JWT, whereas if we just looked at the Authorization token we'd need to strip out the Token
part before the JWT. Next, we'll attempt to decode the token. If that fails by throwing any JWT exception, we'll rescue it and send a 401 back to the client. The decode method also throws exceptions for expired tokens. If we can successfully decode the token, we'll grab the id
value from the payload, and set it to the instance variable @current_user_id
for later use (all of our
controllers inherit from ApplicationController
so we'll be able to use this value from any of our controllers). We're also avoiding any database calls,
deferring any queries for the user for when we actually need it.
The top part of your application_controller.rb
(before the private
keyword) should look similar to this:
class ApplicationController < ActionController::Base
# Prevent CSRF attacks by raising an exception.
# For APIs, you may want to use :null_session instead.
protect_from_forgery with: :null_session
respond_to :json
before_action :underscore_params!
before_action :configure_permitted_parameters, if: :devise_controller?
before_action :authenticate_user
Note that although we're calling :authenticate_user
on each request, we only
inturrupt the request if the JWT token is invalid, but not if the JWT token
is missing. This allows requests that don't require authentication to continue.
Next, let's override the authenticate_user!
, current_user
and signed_in?
methods from Devise in application_controller.rb
authenticate_user!
method to use the one we created
Add the following private
methods to application_controller.rb
def authenticate_user!(options = {})
head :unauthorized unless signed_in?
end
def current_user
@current_user ||= super || User.find(@current_user_id)
end
def signed_in?
@current_user_id.present?
end
Now we can access current_user
and use signed_in?
throughout our
application as if we were using Devise without JWT's, allowing us to access the
current user and checking if a user's signed in using the same syntax.
authenticate_user!
can also be used the same way as a before_action
filter, allowing us to reject requests the require authentication using the
familiar Devise syntax.
Finally, let's create a couple endpoints for a user to update and retrieve their own information.
Add a :user
resource to config/routes.rb
scope :api, defaults: { format: :json } do
devise_for :users, controllers: { sessions: :sessions },
path_names: { sign_in: :login }
resource :user, only: [:show, :update]
end
By default rails creates 6 routes for singular resources.
We only need to get and update users, so we're passing only: [:show, :update]
to the resource. This makes it so that only a GET
and PUT/PATCH
route is
made.
Create app/controllers/users_controller.rb
with the following code:
class UsersController < ApplicationController
before_action :authenticate_user!
end
The before_action :authenticate_user!
callback ensures that a user needs to be authenticated
before the controller action is reached.
Then, create a private method on UsersController
for retrieving whitelisted user params
private
def user_params
params.require(:user).permit(:username, :email, :password, :bio, :image)
end
This is the strong parameters syntax introduced in Rails 4. Only values we specified within permit
will be available when we call user_params
, any values in params not listed in permit
will be dropped. This prevents preventing mass-assignment so that clients can't set arbitrary fields
on models. Additionally, any requests without a user
in the request will result in a 400 status code.
show
and update
actions for UsersController
In app/controllers/users_controller.rb
add the following two methods:
+ def show
+ end
+
+ def update
+ if current_user.update_attributes(user_params)
+ render :show
+ else
+ render json: { errors: current_user.errors }, status: :unprocessable_entity
+ end
+ end
private
def user_params
Both the show
and update
actions will render the same user JSON, so we can call render :show
in our update action to reuse the same template from show
that we'll be creating next. The show
action doesn't require any logic since we can access current_user
from our templates.
Now that our controller actions have been created, we need to create views so that they'll actually respond with JSON data.
In app/views/users
, create show.json.jbuilder
with the following code:
json.user do |json|
json.partial! 'users/user', user: current_user
end
This template uses the user partial we created earlier in app/views/users/_user.json.jbuilder
is used by both our show
and update
actions in UsersController
Testing Authentication with Postman
Now we can to start our Rails server using rails s
. In order to test the functionality
and results of the endpoints we've made, we'll be using Postman to make requests to
our Rails server. We've created a Postman collection for all the requests in this project
for you to download.
You can download Postman from getpostman.com. Postman can be installed as a native application on Mac or as a browser extension on Chrome.
We should be able to run all of the requests in the Auth
folder of the Postman. The Login and Register
requests in Postman should return a JWT token in the response body. In order to identify
the Postman client as a user, we'll have to set the Authorization
header for each request. Luckily,
Postman has Environment Variables which can be
shared between requests. For requests that require authentication, we've already prefilled
the Authorization
header to read from an environment variable named token
, so we can make
requests on behalf of a user by setting the token
environment variable to a user's JWT token.
After registering a user, you should be able to log in with the same credentials and
update the fields of that user. You can customize the parameters being sent by
Postman in the Body tab of each request.
You should be able to:
- Create an account using the Register request in Postman
- Test the Login endpoint using Postman
- Try registering another user with the same email or username, you should get an error back from the backend
- Test the Current User endpoint using Postman
- Try logging in to the user you created with an invalid password, you should get an error back from the backend
- Try updating the email, username, bio, or image for the user