Hello, again friends! In Part 1 we got much of the setup for the Pokedex API complete, including building the database, generating models, controllers, serializers, and routes, adding a few validations, and seeding the database with some examples. In this round, we will set up authentication for users using sessions and cookies.
Now there is a lot of debate about whether one should use sessions and cookies vs JSON Web Tokens(JWT) for authentication, and much of it is related to what you want your application to be able to do. Both have their risks, using local storage with JWT leaves you vulnerable to Cross-site Scripting (XSS) attacks, while sessions and cookies can be weak against Cross-Site Request Forgery (CSRF). There are ways to help minimize risks in both cases. This blog provides a good more in-depth discussion and shows another way of implementing authentication that is different from what I will be showing you here.
I am choosing to use sessions and cookies because it has been easier to implement in my experience and will require less work for Jordan to do on the front end when she is ready to start sending requests to this API for the Pokedex project we are collaborating on.
All right, let's get this party started!
In the pokedex-api/config/initializers/cors.rb we need to add credentials: true to the resource section after the accepted methods. This indicates that a request can be made using cookies.
pokedex-api/config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'http://localhost:3000', 'http://localhost:3001', 'http://localhost:3002'
resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
credentials: true
end
end
Since we used the flag --api when we spun the app up using rails new, it does not come configured with sessions and cookies middleware. So we need to add
config.middleware.use ActionDispatch::Cookies
config.middleware.use ActionDispatch::Session::CookieStore, key: '_cookie_name'
to the application.rb in the config folder.
pokedex-api/config/application.rb
require_relative 'boot'
require "rails"
...
module PokedexApi
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 6.0
# Settings in config/environments/* take precedence over those specified here.
# Application configuration can go into files in config/initializers
# -- all .rb files in that directory are automatically loaded after loading
# the framework and any gems in your application.
# Only loads a smaller set of middleware suitable for API only apps.
# Middleware like session, flash, cookies can be added back manually.
# Skip views, helpers and assets when generating a new resource.
config.api_only = true
config.middleware.use ActionDispatch::Cookies
config.middleware.use ActionDispatch::Session::CookieStore, key: '_cookie_name'
end
end
Finally, the generated ApplicationController will subclass ActionController::API which doesn’t include cookie functionality so we need to add that in as well.
pokedex-api/app/controllers/application_controller.rb
class ApplicationController < ActionController::API
include ::ActionController::Cookies
end
With this configuration set up, we are ready to start building the endpoints and the logic we will need for authenticating our users!
Let's go to our routes and create the names of the endpoints we will want to use.
config/routes.rb
Rails.application.routes.draw do
post "/api/v1/login", to: "api/v1/sessions#create"
post "/api/v1/signup", to: "api/v1/users#create"
delete "/api/v1/logout", to: "api/v1/sessions#destroy"
get "/api/v1/get_current_user", to: "api/v1/sessions#get_current_user"
namespace :api do
namespace :v1 do
resources :pokemons
resources :users
end
end
# For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end
Note: we can't nest these manually built routes under the namespaced API and v1 like we did the others. Through trial and error, I discovered that if they are nested under the api/v1 namespace, if you write the route as
get "/get_current_user", to: "/sessions#get_current_user"
it does not add the api/v1 in the pathname as we can see here:
, and if you include it, it adds it twice. Riddle me that one rails!
Note: I have made a design choice to create a SessionsController which will be responsible for handling login, logout, and getting the current user. You could keep this all in the user, but I like separating my concerns out in this way. Signup will go in the UserController because creating a user is an action that is associated with the user.
Now if we start up the rails server with $ rails s, and navigate to http://localhost:3000/api/v1/get_current_user, we get an error that says there is an Uninitialized constant SessionsController, which makes sense since we haven't built it out yet. However, the important part at the moment is to scroll down and check that the routes are looking as we expected:
And they do with everything nested under api/v1.
Now we can create the SessionsController using the command:
$ rails g controller sessions
and it will make the sessions controller for us. Let's go ahead and move it right away into api/v1 and add the namespacing.
app/controllers/api/v1/sessions_controller.rb
class Api::V1::SessionsController < ApplicationController
end
So let's log in a user in the SessionsController using the create action, which we specified in our routes:
app/controllers/api/v1/sessions_controller.rb
class Api::V1::SessionsController < ApplicationController
def create
user = User.find_by(username: params[:session][:username])
if user && user.authenticate(params[:session][:password])
session[:user_id] = user.id
render json: user, status: 200
else
render json: {
error: "Invalid Credentials"
}
end
end
end
Here we create a variable of user set to the object of the user that we find using the params of username which is nested under a key of sessions. If a user by that username is found, we need to verify that they entered in the correct password that matches the one stored in the database. Because of Bcrypt, the gem that we use for authentication, the password is stored in its hashed and salted form, and we cannot compare the passed-in string directly. So we need to use the authenticate method that is provided by Bcrypt which will salt and hash the password that was given in params, and see if the result matches the salted and hashed password that is in the database. If it matches, we create a session with a key of :user_id which is equal to this user's unique database id. We then return that user as a JSON object.
If it fails to authenticate, we want to return a message that the credentials were invalid. I don't want to give them any more information than this because if we tell them that the username or the password was incorrect, a malicious user trying to guess usernames and passwords would have more information for their nefarious attempts and we don't want to help them out in any way.
From experience, I know that since we are in the SessionsController, I am expected the keys I will be wanting from the front end request to be nested under a key of session. Something akin to this:
<ActionController::Parameters {"username"=>"Meks", "password"=>"password", "controller"=>"api/v1/sessions", "action"=>"create", "session"=>{"username"=>"Meks", "password"=>"password"}} permitted: false>
We will verify this when Jordan is ready to start sending us requests from the frontend!
Once a user is logged in, if they submit a page refresh, we don't want them to be kicked off and have to login again. That would be a terrible user experience. So let's build an endpoint that will get the current user based on their session id.
app/controllers/api/v1/sessions_controller.rb
def get_current_user
if logged_in?
render json: current_user, status: 200
else
render json: {
error: "No one logged in"
}
end
end
I'm writing code that I wish I had. It would be really great if I had a method that would check if the user is logged in, and would get that current user for me if they are! And that's what my get_current_user method is banking on. So let's go write that in our application controller so that it is accessible to all the other controllers! This means that it will be reusable for helping to protect resources by checking to see if a current user is allowed to access a route or perform an action.
app/controllers/api/v1/sessions_controller.rb
class ApplicationController < ActionController::API
include ::ActionController::Cookies
def current_user
User.find_by(id: session[:user_id])
end
def logged_in?
!!current_user
end
end
Remember when we logged a user in we set the session to that user's id? Now we are defining a method that will return a User object that has the same id that the session holds. And the method logged_in? is returning a boolean of true if there is a user found and false if not. This is because if no user is logged in, we expect there to be no session to be found.
If a user can log in, we want them to be able to logout! Let's write the destroy action which reflects that happening.
app/controllers/api/v1/sessions_controller.rb
def destroy
session.delete :user_id
if !session[:user_id]
render json: {
notice: "successfully logged out"
}, status: :ok
else
render json: {
error: "Unsuccessful log out"
}
end
end
Here we delete the user_id from the session, and just as a double-check, we will notify the frontend that the logout was successful or give it an error message that it was not. Here is what the SessionsController looks like in its finality:
app/controllers/api/v1/sessions_controller.rb
class Api::V1::SessionsController < ApplicationController
def create
user = User.find_by(username: params[:session][:username])
if user && user.authenticate(params[:session][:password])
session[:user_id] = user.id
render json: user, status: 200
else
render json: {
error: "Invalid Credentials"
}
end
end
def get_current_user
if logged_in?
render json: current_user, status: 200
else
render json: {
error: "No one logged in"
}
end
end
def destroy
session.delete :user_id
if !session[:user_id]
render json: {
notice: "successfully logged out"
}, status: :ok
else
render json: {
error: "Unsuccessful log out"
}
end
end
end
The final piece of the authentication puzzle, creating a new user through signup! We will do that in the UsersController under the create action.
app/controllers/api/v1/users_controller.rb
class Api::V1::UsersController < ApplicationController
def create
user = User.new(user_params)
if user.save
session[:user_id] = user.id
render json: user, status: 200
else
response = {
error: user.errors.full_messages.to_sentence
}
render json: response, status: :unprocessable_entity
end
end
private
def user_params
params.permit(:username, :password)
end
end
Here I decided to write a private method of user_params where only the username and password are permitted parameters. This user_params is used to make a new user, if the data saves successfully we set the session[:user_id] equal to the user that was just created (in other words, we log them in at the same time as they sign up!) and send that user object as JSON to the frontend. If the user does not save because of any validation errors, we want to send the entire message to the frontend so they know exactly why their submission was unsuccessful.
Guess what coders, that's it for authentication! We will see you next time for creating the final endpoints so a user can catch pokemon with persistence and see all the pokemon that they have caught. We will also be collaborating directly with Jordan to make sure that the endpoints are returning to her the data she needs.
Here's the repo if you need a closer look at the code.
Happy coding!
Top comments (0)