The purpose of this project is to document and explore an approach to mobile authentication in Rails applications, that supports both a conventional server-rendered Rails web frontend and a native mobile application frontend.
This project was created to support this discussion in the Ruby on Rails Discussion forum: APIs for mobile alongside traditional web application with HTML views.
This project is a Rails 5.2 project created with the following command:
rails new --force --database=postgresql rails_web_and_mobile
I choose Rails 5.2 since that is the version that I got this approach working in, and I'm trying to avoid any surprises.
This section will outline the steps taken to get from a bare rails project to one that support authentication for a server rendered web frontend alongside token based authentication for a native mobile app. I made each major change on a separate branch and then merged the branch into master. I also attempted to keep each change needed to make progress isolated in a single commit.
The project uses scaffolding.
The basic idea for this project is that we are building a system for a small Corner Shop where Customers can submit Orders which will be viewed and fulfilled by Shopkeepers.
All the plumbing for Orders were generated using scaffolding:
-
Create Orders using scaffold:
rails generate scaffold Order item:string quantity:integer status:string
-
Create database:
rails db:create
-
Run migrations
rails db:migrate
-
Added dropdown list for Order
status
. Status is implemented as an enum with string values and dropdown list is populated using the enum attriubutes.
Added some placeholder pages for Home and About.
-
rails g controller Pages home about
-
Set application root to the Home page
Devise is used for authentication and was setup as follows:
- Add Devise gem to the Gemfile and install as documented in the Getting Started Guide. The only change is that I specified the version of Devise in the Gemfile.
-
Add Devise Customer model
rails generate devise Customer
-
Redirect Customer to Orders page after login
-
Add Login and Logout links
-
Require that Customer be signed in to access Orders
-
Generate migration to create association
rails generate migration AddCustomerReferenceToOrders customer:belongs_to
-
Run the migration
rails db:migrate
-
Restrict Customers to their Orders
Only return Customer's orders when listing and searching. Associate Orders to Customers on creation (using
current_customer
).
This section follows this guide.
-
Add
devise-jwt
gem -
Configure Devise and Warden for JWT
A few things that I don't understand here:
-
What exactly do these changes do and why are they needed?
-
skip_session_storage - what's the purpose of setting this?
-
config.navigational_formats - why does this need to be set?
-
-
Configure routes for API login and logout
Questions:
-
Why is the following code needed inside the api route:
devise_scope :customer do get "login", to: "customers/sessions#new" delete "logout", to: "customers/sessions#destroy" end
-
-
Update the Customer table to add field for jti
We are using the JTIMatcher recovation strategy
I had to uncomment
class_name: "ApiCustomer",
inroutes.rb
to get this to run, since the model hasn't been setup yet.Update the Customer table using the following migration:
rails generate migration AddJTIToCustomers
#db/migrate/20200517053419_add_jti_to_customers.rb class AddJtiToCustomers < ActiveRecord::Migration[5.2] def change add_column :customers, :jti, :string # populate jti so we can make it not nullable Customer.all.each do |customer| customer.update_column(:jti, SecureRandom.uuid) end change_column_null :customers, :jti, false add_index :customers, :jti, unique: true end end
rails db:migrate
-
Update the Customer model to ensure that the jti column is filled out at time of Customer creation
before_create :add_jti def add_jti self.jti ||= SecureRandom.uuid end
-
Add an ApiCustomer model, as a sub-class of Customer
class ApiCustomer < Customer include Devise::JWT::RevocationStrategies::JTIMatcher devise :jwt_authenticatable, jwt_revocation_strategy: self validates :jti, presence: true def generate_jwt JWT.encode({ id: id, exp: 1.day.from_now.to_i }, Rails.env.devise.jwt.secret_key) end end
Question: why couldn't this have been in the regular Customer model?
-
Configure json requests to use
api_customer
scope for authentication.# Disable CSRF protection for json calls protect_from_forgery with: :exception, unless: :json_request? protect_from_forgery with: :null_session, if: :json_request? skip_before_action :verify_authenticity_token, if: :json_request? rescue_from ActionController::InvalidAuthenticityToken, with: :invalid_auth_token # Set the current customer so that Devise and other gems that use `current_customer` can work. before_action :set_current_customer, if: :json_request? private def json_request? request.format.json? end # Use api_customer Devise scope for JSON access def authenticate_customer!(*args) super and return unless args.blank? json_request? ? authenticate_api_customer! : super end def invalid_auth_token respond_to do |format| format.html { redirect_to sign_in_path, error: 'Login invalid or expired' } format.json { head 401 } end end # So we can use Pundit policies for api_customers def set_current_customer @current_customer ||= warden.authenticate(scope: :api_customer) end
Is this all so that we can have a different set of behaviours for API users (vs. browser users)?
-
Override API SessionsController
This controller responds with json by default, signs in the user and returns the jwt token. I'm guessing that this sign in process is what allows the token to be used transparently and what allows
current_customer
to be set so other controllers just work?class Api::SessionsController < Devise::SessionsController # I'm guessing this isn't required since we don't track signed in/signed out status for the API user? skip_before_action :verify_signed_out_user # This sets the default response format to json instead of html respond_to :json # POST /api/login def create unless request.format == :json sign_out # why is this needed? render status: 406, json: { message: "JSON requests only." } and return end # auth_options should have `scope: :api_customer` resource = warden.authenticate!(auth_options) if resource.blank? render status: 401, json: { response: "Access denied." } and return end sign_in(resource_name, resource) respond_with resource, location: after_sign_in_path_for(resource) do |format| format.json { render json: { success: true, jwt: current_token, response: "Authentication successful" } } end end private def current_token request.env["warden-jwt_auth.token"] end end
-
Add “new” view in json format
If this file isn't added, the follow error is generated when attempting to login:
undefined method `api_customers_url' for #<Api::SessionsController:0x00007fb9ded22298> Did you mean? api_customer_session_url actionpack (5.2.4.2) lib/action_dispatch/routing/polymorphic_routes.rb:232:in `polymorphic_method'
-
Add jwt_key_base to credentials file
Generate the key with
rake secret
Run
rails credentials:edit
Add the generated key as
jwt_key_base
.