DEV Community

Cover image for Building a Pokedex with Rails Part 2 - Authentication
Meks (they/them)
Meks (they/them)

Posted on

Building a Pokedex with Rails Part 2 - Authentication

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

it does not add the api/v1 in the pathname as we can see here:

Alt Text
, 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:

Alt Text

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!

Oldest comments (0)