Certain parts of this tutorial were adapted from this tutorial, https://medium.com/better-programming/build-a-rails-api-with-jwt-61fb8a52d833
Our Mission
Our goal is to make an API where users can login and creates notes under their account. I'm assuming some basic knowledge of Ruby on Rails and that you have it installed. If this is not the case just do this basic hello world tutorial to get a basic introduction before this tutorial.
https://iridakos.com/programming/2013/11/24/saying-hello-world-with-ruby-on-rails
Starting Up Our Project
rails new notesapi --database=postgresql --api
So this will create a new project in a notesapi
folder. The --database=postgresql
flag will make our database.yml and gemfile setup for postgres and the --api
flag will build a minimal build for building api so we don't have all the bulk neccessary for rendering views.
Configuring your dependencies
Make sure to add the following to your Gemfile
gem 'bcrypt', '~> 3.1.7'
gem 'rack-cors'
gem 'jwt'
Then after making these adjustments in terminal run the command bundle install
which will read the Gemfile and install any as of yet uninstalled dependencies. Now what do these libraries do?
bcrypt: Encrypts strings, will be used to encrypt new user passwords and verify where passwords at login match the encrypted password in our database.
rack-cors: This will allow us to manage Cross Origin Resource Sharing (CORS) and determine which URLs are allowed to make requests to our server
JWT: Json Web Tokens (JWT) are often issued when a user successfully logs in containing encrypted information such as username. Our API will look for JWT tokens in request headers to know the request is authorized to access certain data. (So I login get a token, I send the token when I request my notes and this tells the API which user I am and that I'm properly logged in)
CORS
Head into config/initializers/cors.rb
and enter the following. The "*" basically allows all sources to make requests to our server which we can change after we deploy and know what our front-end applications URL will be.
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*'
resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head]
end
end
Setting up Auth Routes
Head over to config/routes.rb
and include the following
Rails.application.routes.draw do
resource :users, only: [:create]
post "/login", to: "users#login"
get "/auto_login", to: "users#auto_login"
end
So we only want the create the resources route for users which will be used to create new users. The '/login' route will be used for logging in and 'auto_login' will allow you to verify you are logged in using your JWT.
Creating the User Model
rails g model User username:string password_digest:string age:integer
Notice the password field is called 'password_digest', this is a convention that the Bcrypt library is looking for to know that it will be responsible for digesting the password and encrypting it.
Securing our User Model
Head into app/models/user.rb
and add the following macro which will notify bCrypt to do its thing.
class User < ApplicationRecord
has_secure_password
end
The User Controller
Run the following command to create a User controller...
rails g controller Users
This will create a new controller file at app/controllers/
, let's open it up and begin building some controller functions.
Migrating the model to your database
Before this next step take a moment to go to your config/database.yml
and set your postgres settings
default: &default
adapter: postgresql
encoding: unicode
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
development:
<<: *default
database: database_name
username: test
password: test
host: localhost
port: 5432
test:
<<: *default
database: database_name
production:
<<: *default
url: <%= ENV['DBURI'] %>
So locally it will work off your localhost database and in production it'll pull a connection string from your Heroku config vars (it'll look for a variable called DBURI in this case).
After setting up the connection run the following command to generate the migration files (files that contain commands to be sent to database) and then migrate (send those commands to your database and set a marker to know which migrations it has already ran).
rails db:create && rails db:migrate
Seed some data
We will want to put some initial data in our database to test and work with so go to your db/seeds.rb
file and add the following line.
user = User.create(username: "alexmerced", password: "pineapple", age: 35)
After saving the new user in the seed file run the following command to seed the database.
rails db:seed
The Application Controller
Head over and open app/controllers/application_controller.rb
Add the following code...
class ApplicationController < ActionController::API
before_action :authorized
def encode_token(payload)
JWT.encode(payload, 'yourSecret')
end
def auth_header
# { Authorization: 'Bearer <token>' }
request.headers['Authorization']
end
def decoded_token
if auth_header
token = auth_header.split(' ')[1]
# header: { 'Authorization': 'Bearer <token>' }
begin
JWT.decode(token, 'yourSecret', true, algorithm: 'HS256')
rescue JWT::DecodeError
nil
end
end
end
def logged_in_user
if decoded_token
user_id = decoded_token[0]['user_id']
@user = User.find_by(id: user_id)
end
end
def logged_in?
!!logged_in_user
end
def authorized
render json: { message: 'Please log in' }, status: :unauthorized unless logged_in?
end
end
So let's break down these methods:
encode_token this method takes in a payload (a hash of key/values you want to save in the token) and signs a token using a secret key. (in production this should an ENV variable.)
auth_header When determining is someone is logged in the frontend usually sends the token in the "Authorization" header preceded by the word bearer, this function checks to see if the header was sent.
example of a request including the appropiate header...
fetch("/", {
method: "post",
headers: {
"Content-Type": "application/json",
Authorization: `bearer ${JWT_TOKEN}`,
},
body: JSON.stringify(requestBody),
})
before_action :authorized : this macro tells rails to run the authorized funcion before completing any request to the API. (why, to make sure they are logged in before they use the API)
decoded_token: If the auth header is present this function parses the string for the token and verified it and pulls out the payload (the hash we stored in the token when we created it). If verification fails it will return nil.
logged_in_user if decoding the token is successful it grabs the username from the payload and searches our user in the database.
logged_in returns true if logged in
authorized: returns a message asking user to log in if unless logged_in return true
The Users Controller
Now to add some methods to the user controller, head over to app/controllers/user_controller.rb
and add the following methods.
class UsersController < ApplicationController
before_action :authorized, only: [:auto_login]
# REGISTER
def create
@user = User.create(user_params)
if @user.valid?
token = encode_token({user_id: @user.id})
render json: {user: @user, token: token}
else
render json: {error: "Invalid username or password"}
end
end
# LOGGING IN
def login
@user = User.find_by(username: params[:username])
if @user && @user.authenticate(params[:password])
token = encode_token({user_id: @user.id})
render json: {user: @user, token: token}
else
render json: {error: "Invalid username or password"}
end
end
def auto_login
render json: @user
end
private
def user_params
params.permit(:username, :password, :age)
end
end
Let's break this down.
before_action :authorized, only: [:auto_login]: This required the authorization method from the Application_Controller to be run before only the auto_login method of this controller.
create This creates a new user using the data returned from the user_params method. If the new user is validated (matches the user schema) then a JWT token is signed and returned in a json object to the client.
login Looks up the username and compares the password passed in, if all checks out, generate an JWT token and sends it over in a JSON object.
auto_login renturns the user object as JSON assuming the user has previous logged in (in which case the authenticate function will run successfully)
user_params private function that parses the needed data from the request to create a new user
Time to test our API Auth
Run your server
rails s -p 4000
make post request to /login with your username and password, copy token from response
make a get request to /auto_login with your token in your headers
Making the Notes item
let's create our notes model/controller/routes with the following command
rails g scaffold note message:string user:references
The Result
model...
class Note < ApplicationRecord
belongs_to :user
end
It has belongs to since we specified the user field references our user model. So a notes cannot be created without associating it with a user.
In the controller file...
class NotesController < ApplicationController
before_action :set_note, only: [:show, :update, :destroy]
# GET /notes
def index
@notes = Note.all
render json: @notes
end
# GET /notes/1
def show
render json: @note
end
# POST /notes
def create
@note = Note.new(note_params)
if @note.save
render json: @note, status: :created, location: @note
else
render json: @note.errors, status: :unprocessable_entity
end
end
# PATCH/PUT /notes/1
def update
if @note.update(note_params)
render json: @note
else
render json: @note.errors, status: :unprocessable_entity
end
end
# DELETE /notes/1
def destroy
@note.destroy
end
private
# Use callbacks to share common setup or constraints between actions.
def set_note
@note = Note.find(params[:id])
end
# Only allow a trusted parameter "white list" through.
def note_params
params.require(:note).permit(:message, :user_id)
end
end
Nice to see all our crud routes initially built out for us!
Then our migration file!
class CreateNotes < ActiveRecord::Migration[6.0]
def change
create_table :notes do |t|
t.string :message
t.references :user, null: false, foreign_key: true
t.timestamps
end
end
end
and last our routes
Rails.application.routes.draw do
resources :notes
resource :users, only: [:create]
post "/login", to: "users#login"
get "/auto_login", to: "users#auto_login"
end
our notes already has all the main resources routes all setup, nice!
Adding auth to notes
in the notes controllers add authorization as a before action...
before_action :authorized
modify the controllers like so...
# GET /notes
def index
@notes = Note.where(user_id: @user.id)
render json: @notes
end
# GET /notes/1
def show
render json: @note
end
# POST /notes
def create
@note = Note.new(note_params)
@note.user_id = @user.id
if @note.save
render json: @note, status: :created, location: @note
else
render json: @note.errors, status: :unprocessable_entity
end
end
Notice the changes are mainly to the create and index function. The change to the index function makes sure that we are only grabbing notes of the particular user and the changes in create makes sure every user is assigned a user_id at creation.
The test the API and you'll find you'll need to login before you can create and view notes, congrats!
Top comments (4)
Very helpful guide! I followed it exactly but had to make a few additional adjustments to the "notes"controller (used "requesters" in my project) to get it working in postman
dev-to-uploads.s3.amazonaws.com/i/...
Awesome tutorial, but ApplicationController::logged_in? should not trigger a database query, as this defeats the purpose of using JWTs. It should instead use Application::decoded_token.
This is great one for newbie!
Just a few questions. How can you login, sign up or logout using CURL for instance?