Devise Jwt You Need to Sign in or Sign Up Before Continuing
I needed to implement Devise and JWT using Rails (Rails 5), and I thought, how hard could this be? Boy was I naive... Now there is a lot of information out there on how to do this, but each resource was using a different method and nothing really seemed to work. Well, I've finally figured it out and I want to share it with the world for 2 reasons:
- It may save someone days of researching and trial-and-error.
- Selfishly, I want to know where I can go to look it up for next time
Warning, this post will assume some knowledge of Rails and a few popular gems, it's a little bit more advanced than my normal stuff so far. So here we go.
How does it work?
First thing's first, there are a few ways this can be handled. There is (was?) a Devise-JWT gem that integrated JWT and worked very similarly to Devise's regular flow. When I tried to go that route, it did not work and I wasted many, many hours troubleshooting. I did eventually succeed in registration, but the sign_in functionality was still not working. It's very probable that this was do to user error, but regardless, I found my way to be much simpler.
Here's how it works.
So basically, you can really think about this in two steps. Step 1 is the standard devise-driven authentication. Step 2 is passing the JSON Web Token back and forth.
Implementation
Project Generation
First, let's build our project. Since we don't need the full Rails functionality because we'll be setting up a separate front-end, we can use the --api flag rails new example-project --api
. One of the effects of this flag is that the project will be set up without rails sessions - this is important.
Gemfile
Once we've built our project, first thing we'll do is build out the Gemfile. For the purposes of our authentication flow, we'll need 3 gems
-
devise
for actual authentication -
jwt
for handling the JSON Web Tokens we'll be passing back and forth. -
bcrypt
for password-related unit testing - this only needs to be included in the test environment because otherwise it's included in Devise. - BONUS: I pretty much always add
pry
to help with debugging, and it comes in real handy when I need to check what params are coming over.
Devise Initializer
To configure Devise, we'll run rails generate devise:install
from our console to create an initializer file: config/initializers/devise.rb. The good news is that we can largely keep the default configuration; the only special thing we need to do is to set config.skip_session_storage = [:http_auth]
(about quarter way down the file).
User Model
Now we need to set up our user model. Devise has a special way to do this by running rails generate devise User
. This command creates a User model and prefills it with some Devise functionality, it also creates a database 'devise_create_users' migration, and adds a line to the routes file: devise_for :users
which creates routes to the default Devise Controllers.
Once the User model is created, we can finish configuring Devise by selecting which modules we want and adding it after the devise
macro. For my app, I just used the basic defaults: devise :database_authenticatable, :registerable
One last thing before we can call the User model ready. Since a given JSON Web Token (JWT) will be associated to a given user, it makes sense to think of a user "creating" their token. Additionally, the goal is to get as much of the app's logic in the models, so to address both of these concerns we will place the logic of creating a JWT in the User model. Here we use the JWT gem to encode a token containing only the user's id. How can the id be the only thing we need you ask? Thinking back to our "How Does It Work" Diagram above, remember that the user will need to pass in their credentials as parameters at the sign-in page and, if successful, the server will issue an encrypted token for them. This is that token, so it will only be used to authenticate that the user is who they say they are once they've already logged in and they try to make a subsequent call to the API. Thus, we only need a way to identify the user: their unique id
attribute works perfectly for this purpose.
def generate_jwt JWT.encode({id: id, exp: 60.days.from_now.to_i}, Rails.application.secrets.secret_key_base) end
Routes
As stated above, the rails generate devise User
generator will create a route for us automatically that looks like this: devise_for :users
. For our purposes, the default controllers aren't going to work on their own because they are meant to operate via sessions, which we will not have in our api-only implementation. So, we'll need to overwrite some of the default functionality - to do this, we need to point to custom registrations and sessions controllers:
devise_for :users, controllers: { registrations: :registrations, sessions: :sessions }
Database
Also stated above, the rails generate devise User
generator will create our database migration for us, so the only change we need to make is uncommenting any non-default modules you added in your User model, as well as adding any custom fields you may need. Once you're done, run rake db:migrate
and we're done here.
Intermission (Coffee Break)
We've gotten through a lot already, but there's quite a bit more to come, so before we get into the controllers, which contain most of our logic and functionality, take a quick breather and grab a fresh cup of coffee. If you're following along, this is a good time to double check that everything is correct in your app so far...
Ready to continue? Okay, let's do this!
Controllers
There are three controllers that we're going to be concerned with for this, and each of these 3 controllers will have a specific job from the diagram at the top of this article.
- The Application Controller is where we will process a JWT when a user sends a request to our API. It's vital to keep in mind that the Application Controller is not concerned with credentials - it simply checks for a valid JWT.
- The Registrations Controller is where a user will create his/her credentials, and it will assign the JWT to the user once complete.
- The Sessions Controller is where a user will authenticate his/her credentials and it will assign the JWT to the user if successful.
Application Controller < ActionController::API
We will set up our JWT processing functionality first because, once a JWT is assigned, we'll want to check to make sure it's working correctly. Since we know that we will be passing in JSON, we will start off the Application Controller with the following line respond_to :json
. Since all other controllers inherit from the Application Controller, we only need to do this for this controller - it will automatically be passed down to the rest. This is also where we'll want to provide our app with similar private methods to what the standard Devise implementation would give us, so let's set up our authentication method authenticate_user!
as well as a signed_in?
and current_user
method, then we'll look at how to get them to work.
For our authenticate_user!
, we know that we want this to reject a user as unauthorized unless they are correctly signed in. We also know we'll eventually have a signed_in?
method available, so let's go ahead and proceed using that:
def authenticate_user!(options = {}) head :unauthorized unless signed_in? end
But for this to work, of course, we need to define signed_in?
. Default Devise does this by checking the session for the presence of a user_id. We won't have a session for this, but what we will have is a JWT. We now know that we need a method to somehow pull a user's id
out of the JWT and return it. Let's call it @current_user_id
and use that future value in our signed_in? method like so:
def signed_in? @current_user_id.present? end
While we're at it, since we know that we'll have a @current_user_id
to work with, let's use it to define our current_user method too. We need this to take the id and search our database for a corresponding user record:
def current_user @current_user ||= super || User.find(@current_user_id) end
That's easy enough, essentially just copying the Devise methods, now we just have to find a way to extract that id from a passed JWT. One final reminder: remember that this controller is NOT meant to make sure that the user authenticates against his/her credentials, it's just to see whether they are signed in or not by looking at the JWT. If a user HAS a valid JWT, it means that they have correctly authenticated their credentials and the server gave them one. With that in mind, this is actually super simple using the jwt
gem:
def process_token jwt_payload = JWT.decode(request.headers['Authorization'].split(' ')[1], Rails.application.secrets.secret_key_base).first @current_user_id = jwt_payload['id'] end
That will work, assuming that there IS an Auth header, and that it has a valid JWT. I'm not willing to bet that either of these are always going to happen, so let's put some error handling around it. We want to throw an error if an invalid JWT is sent, but not if there is no Auth header sent at all:
def process_token if request.headers['Authorization'].present? begin jwt_payload = JWT.decode(request.headers['Authorization'].split(' ')[1].remove('"'), 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
There! Now there's just one last step. We need to make sure that the token is processed before we try to take any other action. To do this, we just need to add before_action :process_token
underneath respond_to :json
. Now whenever our app is called, it will process the token (if provided) and then take whatever action is required.
Registrations Controller < Devise::RegistrationsController
Okay, next step is to provide our app the ability to register a new user and assign them a JWT to be passed to our Application Controller for processing. As long as we're just using the default attributes for Devise (and calling them "sign_up_params", we don't need to worry about whitelisting parameters because Devise is already doing it for us. The reason we need to have our own controller is so that we can have the user instance build its token for the controller to deliver it. On the client side, we would use this returned token to store in a httpOnly
cookie, (or whatever other storage option you prefer).
def create user = User.new(sign_up_params) if user.save token = user.generate_jwt render json: token.to_json else render json: { errors: { 'email or password' => ['is invalid'] } }, status: :unprocessable_entity end end
Sessions Controller < Devise::SessionsController
Finally, the last step in our implementation! Just gotta set up the Sessions Controller so that a user can return and sign back in, and it works the same way as the Registrations Controller. The user will submit params through the front-end, including their email, which our API will use to query the database and return our user instance. Then we'll validate that the password they provided matches the stored password and, if successful, we will distribute a JWT:
def create user = User.find_by_email(sign_in_params[:email]) if user && user.valid_password?(sign_in_params[:password]) token = user.generate_jwt render json: token.to_json else render json: { errors: { 'email or password' => ['is invalid'] } }, status: :unprocessable_entity end end
Wrap-Up
So there it is. This is how I was finally able to get JWT working with server-side authentication using Devise, the de-facto standard for Rails. Once I realized that JWT is really a separate process from authenticating credentials, it wasn't so bad to figure out. Let me know what you think in the comments. Is there a better way to combine these two gems? Are there major issues with this implementation? If you've successfully used devise-jwt
, what is the secret??
Thanks so much for reading and hanging in there to the end! Below this is just the final code (minus Gemfile and Initializer), in case you want to see it all in one place.
Full Code:
# User.rb class User < ApplicationRecord # Include default devise modules. Others available are: # :confirmable, :recoverable, :rememberable, :validatable, :lockable, :timeoutable, :trackable and :omniauthable devise :database_authenticatable, :registerable def generate_jwt JWT.encode({id: id, exp: 60.days.from_now.to_i}, Rails.application.secrets.secret_key_base) end end # Routes.rb Rails.application.routes.draw do devise_for :users, controllers: { registrations: :registrations, sessions: :sessions } root to: "home#index" end # Database Schema create_table "users", force: :cascade do |t| t.string "email", default: "", null: false t.string "encrypted_password", default: "", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["email"], name: "index_users_on_email", unique: true end # ApplicationController.rb class ApplicationController < ActionController::API respond_to :json before_action :process_token private # Check for auth headers - if present, decode or send unauthorized response (called always to allow current_user) def process_token if request.headers['Authorization'].present? begin jwt_payload = JWT.decode(request.headers['Authorization'].split(' ')[1], 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 # If user has not signed in, return unauthorized response (called only when auth is needed) def authenticate_user!(options = {}) head :unauthorized unless signed_in? end # set Devise's current_user using decoded JWT instead of session def current_user @current_user ||= super || User.find(@current_user_id) end # check that authenticate_user has successfully returned @current_user_id (user is authenticated) def signed_in? @current_user_id.present? end end # RegistrationsController.rb class RegistrationsController < Devise::RegistrationsController def create user = User.new(sign_up_params) if user.save token = user.generate_jwt render json: token.to_json else render json: { errors: { 'email or password' => ['is invalid'] } }, status: :unprocessable_entity end end end # SessionsController.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]) token = user.generate_jwt render json: token.to_json else render json: { errors: { 'email or password' => ['is invalid'] } }, status: :unprocessable_entity end end end
Source: https://dev.to/dhintz89/devise-and-jwt-in-rails-2mlj
0 Response to "Devise Jwt You Need to Sign in or Sign Up Before Continuing"
إرسال تعليق