Most web applications we interact with require you to login for certain features on their website. This brings us to Authorization and Authentication.These are two independent yet interconnected concepts
- Authentication allows you to provide valid credentials, i.e. username and password. Think of logging-in as authenticating the user. Is the user who they say they are.
- Authorization - defines what data the authenticated user can access.
-
Devise is a Ruby gem.
- libraries or packages of code that we can bring into our applications.
-
Devise is a very popular gem, used in many applications, which means it has a lot of community support. Go to https://rubygems.org/gems/devise/versions/4.9.3?locale=en
-
Devise gives us the ability to create a user in our application. The user can log into an account and have special access to certain parts of your application. This brings us to two important concepts, authorization and authentication.
- $
bundle add devise
- $
rails generate devise:install
- $
rails generate devise User
- $
rails db:migrate
Note the backend files related to User (schema/model/etc.)
What's great about Devise is that it can handle both authorization and authentication.
-
So we can authenticate a user, which means it gives us login pages where a user can create an account or login to an account.
-
Since we have a decoupled application, we will need to verify that a person is who they are on the backend. We also need the frontend to know that a user is logged in and is now authorized to access specific features in the application. To pass this information, we will be using JWT.
-
JWT stands for JSON Web Token, and it is an open standard for securely transmitting information between frontend and backend as a JSON object. We mainly use JWT in authorization.
-
When a user logs in, the server generates a JWT, signs it using a secret key, and sends it back to the client.
-
The client (frontend) stores the token in the browser using localStorage. localStorage is a JavaScript property that allows us to save key-value pairs in the browser. Now all subsequent API requests will include the token.
Do this very methodically. Make sure everything goes in the right file/folder. To implement JWT with Devise, we need to install two more gems:
gem 'devise-jwt'
gem 'rack-cors'
then run bundle
CORS stands for Cross-Origin Resource Sharing. Our React frontend and Rails backend applications are running on two different servers. We have to tell the Rails app that (while in development) it is okay to accept requests from any outside domain.
Along with allowing requests, we will need to send a JWT token to the frontend through the headers.
config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'http://localhost:3001'
resource '*',
headers: ["Authorization"],
expose: ["Authorization"],
methods: [:get, :post, :put, :patch, :delete, :options, :head],
max_age: 600
end
end
We will also need to ensure that devise does not use flash messages since this is an API.
api/config/initializers/devise.rb
config.navigational_formats = []
We need access to some additional files that devise has available to handle sign ups and logins.
$ rails g devise:controllers users -c registrations sessions
We will replace the code in the registrations controller and sessions controller:
app/controllers/users/registrations_controller.rb
class Users::RegistrationsController < Devise::RegistrationsController
respond_to :json
def create
build_resource(sign_up_params)
resource.save
sign_in(resource_name, resource)
render json: resource
end
end
app/controllers/users/sessions_controller.rb
# app/users/sessions_controller.rb
class Users::SessionsController < Devise::SessionsController
respond_to :json
private
def respond_with(resource, _opts = {})
render json: resource
end
def respond_to_on_destroy
render json: { message: "Logged out." }
end
end
Then we need to update the devise routes:
config/routes.rb
Rails.application.routes.draw do
devise_for :users,
path: '',
path_names: {
sign_in: 'login',
sign_out: 'logout',
registration: 'signup'
},
controllers: {
sessions: 'users/sessions',
registrations: 'users/registrations'
}
end
We need a secret key to create a JWT token. Generate one with this command: $bundle exec rake secret
Rails stores secrets in the credentials.yml.enc and uses the master.key, both found in the config folder.
To add your secret key, use the following command:
$ EDITOR="code --wait" bin/rails credentials:edit
Now you can assign the secret to a key jwt_secret_key at the bottom of this file.
jwt_secret_key: <newly-created secret key>
** do not use the angle brackets, nor do you need the key to be in quotes.
IMPORTANT You need to save the crentials file and then CLOSE THE FILE - this will encrypt and save the file. Otherwise your changes will not be saved and your app will break.
in your devise.rb file add the following - anywhere is fine, I added to bottom of my file.
config/initializers/devise.rb
# api/config/initializers/devise.rb
config.jwt do |jwt|
jwt.secret = Rails.application.credentials.jwt_secret_key
jwt.dispatch_requests = [
['POST', %r{^/login$}],
]
jwt.revocation_requests = [
['DELETE', %r{^/logout$}]
]
jwt.expiration_time = 5.minutes.to_i
end
While there are several ways to revoke a token with devise-jwt, we are going to use the DenyList.
- Create denylist table:
$ rails generate model jwt_denylist
- Update the migration file with the following code:
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
-
$ rails db:migrate
-
Update the user model so that it uses JWT tokens. You will be removing part of the devise attributes. So it should look like this (plus your association)
app/models/user.rb
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:jwt_authenticatable, jwt_revocation_strategy: JwtDenylist
end
...and we're done! Run rails s
to make sure you aren't getting errors.
The first thing we need to do is establish the relationship between users and cats. A cat can only be created by a valid, logged in user and a user can create as many cats as they want. So what would the Active Record associations User has_many cats, cats belongs_to User.
user.rb has_many :cats
cat.rb belongs_to :user
Therefore we will need a foreign key of user_id to the belongs_to table. We can actually do that in the migration creation by using similar syntax, but including a particular keyword: references. Also, because we already have data in our cats database, we'll need to drop and recreate it.
- $
rails db:drop
- $
rails db:create
- $
rails generate migration addUserReferenceToCats user:references
- $
rails db:migrate
We are going to need to update our seeds because now every cat must be associated with a user. To add a User instance in the database, we need a unique username, password, and password confirmation. When this information is successfully submitted to the database, a new instance of User will be created.
Seed data has to align with the relationship of our User and Cat models. Before we have cats, we must have users.
Devise provides us with some built-in validations. For example, every user in the database must have a unique email. To ensure our seeded user data is made correctly, we can use the .first_or_create Active Record method. Using the .where method, we first query for all emails that match a particular user in the database. The .where method will return an array of all matches. The .first_or_create method checks whether the first instance in the array is nil or not. If the value is nil, then no user exists. A nil value will trigger the .create method which requires password and password confirmation keys with matching password values.
user1 = User.where(email: "test1@example.com").first_or_create(password: "password", password_confirmation: "password")
user2 = User.where(email: "test2@example.com").first_or_create(password: "password", password_confirmation: "password")
user1_cats = [
{
name: 'Kevin',
age: 9,
enjoys: 'Talking to the dogs walking by the window',
image: 'https://upload.wikimedia.org/wikipedia/commons/e/e4/Tuxedo_cat_Vladimir_124.jpg'
},
{
name: 'Geppetto',
age: 8,
enjoys: 'being outside in the sun',
image: 'https://www.publicdomainpictures.net/pictures/200000/nahled/ragdoll-cat-with-green-eyes-14766395657Vf.jpg'
}
]
user2_cats = [
{
name: 'Priscilla',
age: 13,
enjoys: 'Wanting attention from all humans. Only humans',
image: 'https://petkeen.com/wp-content/uploads/2021/04/Blue-norwegian-forest-cat_Elisa-Putti_Shutterstock-760x507.jpg'
}
]
user1_cats.each do |cat|
user1.cats.create(cat)
p "created: #{cat}"
end
user2_cats.each do |cat|
user2.cats.create(cat)
p "created: #{cat}"
end
- $
rails db:seed