We are going to create an API that communicates using REST protocols, and that exchanges JSON data. It’s a good idea to understand what REST is:
https://dzone.com/articles/introduction-to-rest-api-restful-web-services
And also, you will want to understand JSON:
A starter Rails application for the API Server has been created for you in the git repository here. You do NOT need to do the command to create the application. You only need to fork and clone the repository as usual. After you have cloned the repository, cd to the repository directory and create a branch called rest-authentication. This is where you will put the first part of your assignment.
The command we used to create this workspace was:
rails new rest-rails --api -T
Note the –api parameter. This Rails application loads a subset of Rails. You can’t render views with it, but you can send and receive JSON documents, as we will see. The following section on authentication was borrowed, with some edits, from a tutorial by GreekDataGuy here.
You will need some additional gems. Add the following to your Gemfile. These settings should be added before the group development, test section.
gem 'devise'
gem 'devise-jwt'
Then do a bundle install.
Next we set up devise. Devise is a gem that enables authentication, and we are using it in combination with devise-jwt, which allows the creation of json web tokens as credentials. Enter the following commands:
bin/rails g devise:install
bin/rails g devise User
bin/rails db:migrate
Update the app/models/user.rb file as follows:
class User < ApplicationRecord
validates :email, presence: true, uniqueness: true
devise :database_authenticatable,
:jwt_authenticatable,
:registerable,
jwt_revocation_strategy: JwtDenylist
end
One of the problems with JWT token authentication is logoff. In order to enable logoff, we have to invalidate the token, and one means of doing that is to create a list of revoked tokens. Create another model file called app/models/jwt_denylist.rb and paste in the following.
class JwtDenylist < ApplicationRecord
include Devise::JWT::RevocationStrategies::Denylist
self.table_name = 'jwt_denylist'
end
Next, create a migration for the jwt_denylist table:
rails g migration CreateJwtDenylist
The migration file you have created, in db/migrate, should be edited to match this:
class CreateJwtDenylist < ActiveRecord::Migration[6.1]
def change
create_table :jwt_denylist do |t|
t.string :jti, null: false
t.datetime :exp, null: false
end
add_index :jwt_denylist, :jti
end
end
Then run the migration.
bin/rails db:migrate
This completes the initial setup.
We need three controllers, one for user registration, one for session management, and one for testing logon. So, enter the following commands:
bin/rails g controller users/Registrations
bin/rails g controller users/Sessions
bin/rails g controller test
Edit app/controllers/users/registrations_controller.rb, to match the following:
class Users::RegistrationsController < Devise::RegistrationsController
respond_to :json
private
def respond_with(resource, _opts = {})
register_success && return if resource.persisted?
register_failed
end
def register_success
render json: { message: 'Signed up sucessfully.' }, status: :created
end
def register_failed
render json: { message: "Something went wrong." }, status: :bad_request
end
end
It is not really obvious what this controller does, but it overrides the Devise controller to handle JSON responses. The same is true of app/controllers/users/sessions_controller.rb, which should be changed to match this:
class Users::SessionsController < Devise::SessionsController
respond_to :json
private
def respond_with(resource, _opts = {})
if !resource.id.nil?
response.set_header('Access-Control-Expose-Headers','authorization')
render json: { message: 'You are logged in.' }, status: :created
else
render json: { message: 'Authentication failed.'}, status: :unauthorized
end
end
def respond_to_on_destroy
log_out_success && return if current_user
log_out_failure
end
def log_out_success
render json: { message: "You are logged out." }, status: :ok
end
def log_out_failure
render json: { message: "Hmm nothing happened."}, status: :unauthorized
end
end
In general, REST operations other than registration and logon require authentication. So we need a method to verify that a user has been authenticated. We create that method in a new file you should create, app/controllers/concerns/authentication_check.rb, as follows:
module AuthenticationCheck
extend ActiveSupport::Concern
def is_user_logged_in
if current_user.nil?
render json: { message: "No user is authenticated." },
status: :unauthorized
end
end
end
This is the standard way of creating a method that will be accessible to a variety of controllers. Now edit app/controllers/test_controller.rb to match the following. You will see that it calls the method is_user_logged_in.
class TestController < ApplicationController
include AuthenticationCheck
before_action :is_user_logged_in
def show
render json: { message: "If you see this, you're logged in!" },
status: :ok
end
end
This is just a test controller to verify that login works.
In JWT based authentication, the tokens are digitally signed. To make these signatures secure, we need a secret key. We can’t put this key in the code, because then it would be stored in github. Rails has a means of storing secret keys in encrypted form. We will create the secret and store it in an encrypted credentials file. The procedure is a little clumsy, because the credentials file can only be edited with a text mode editor, such as, in this case, vi.
Generate the secret with:
bin/rake secret
This will give a very long string of gobbledygook. Copy the string to the clipboard. Then open the credentials file with this command:
EDITOR=vi rails credentials:edit
Here, you may get a message:
Couldn't decrypt config/credentials.yml.enc. Perhaps you passed the wrong key?
If you see this message (this is likely) just delete config/credentials.yml.enc and repeat the previous command so that you can edit this file.
You next type i to get vi into insert mode. Then, using the arrow keys, move the cursor to the first line after the end of the file, and type in:
devise:
jwt_secret_key: <rake secret key>
Where the rake secret key is pasted in from the clipboard. This is a yml file, so careful indentation is important. Then press esc to get out of insert mode and type :wq and press enter. This will save the encrypted file. When you get done, run the rails console, and type
Rails.application.credentials.devise
It should show you the secret, with a key of jwt_secret_key. If it does not, repeat the steps above. Do not do the next step until you have the secret key set up correctly.
Edit config/initializers/devise.rb to point devise to the encrypted secret we will create. The following lines should be added to the config block:
config.jwt do |jwt|
jwt.secret = Rails.application.credentials.devise[:jwt_secret_key]
end
Now we need to configure routes for the controllers that have been created. config/routes.rb should be edited to match the following:
Rails.application.routes.draw do
devise_for :users,
controllers: {
sessions: 'users/sessions',
registrations: 'users/registrations'
}
get '/test', to: 'test#show'
end
We are now ready to start the REST server. Typically the server would be called by a separate front end process, written in a framework such as React. We will create such a front end, just using Rails, in a future lesson. However, we can test without the front end using a Linux utility called curl, which sends HTTP requests to a specified target.
Start the server as usual. Then try the following curl commands. You will need a separate command shell to send the commands. If you are using vagrant, this will be a separate vagrant ssh session. Your separate command session should be in a directory that is not part of a git project, as we are going to create some temporary files.
We will first try to access the test controller without being authenticated, using the following command:
curl -XGET -H "Content-Type: application/json" http://localhost:3000/test
This will fail. It will return the 401 unauthorized return code, with a message that no user has been authenticated. Next, register a user with this command:
curl -XPOST -H "Content-Type: application/json" -d '{ "user": { "email": "test@example.com", "password": "12345678" } }' http://localhost:3000/users
You should see a message come back that the user was signed up successfully.
Now, log in with the following command:
curl -XPOST -D headers.txt -H "Content-Type: application/json" -d '{ "user": { "email": "test@example.com", "password": "12345678" } }' http://localhost:3000/users/sign_in
You should see a message that you are logged in. With JWT authentication, the authentication token is returned as a Bearer token in the Authorization header. The option -D header.txt in the curl command writes the returned headers to the file header.txt. Display this file using this command:
cat headers.txt
You will see a number of headers, including the authorization header. We want to include the authorization header in subsequent curl requests, so that they are authenticated. Use this command to separate out the authorization header:
grep Authorization headers.txt >authheader.txt
Now you can retry access to the test controller as follows:
curl -XGET -H @authheader.txt -H "Content-Type: application/json" http://localhost:3000/test
If all is working correctly, you should see a message that tells you you are in. If this does not work, retrace your steps to see what is wrong.
You can also log out, invalidating the JWT token, with this command:
curl -XDELETE -H @authheader.txt -H "Content-Type: application/json" http://localhost:3000/users/sign_out
Once you have this working, do git add, commit, and push of your rest-authentication branch, and then submit your pull request as usual. In the next section, we will add additional REST operations.