Time to deal with authenticating users in our bookstore application.

This is an onging series of articles. It’s highly recommended you start with part 0!

What is token authentication?

“And why do we even need it?” are two questions you might be asking yourself right now. You’re probably familiar how a simple authentication flow might work in a “regular” Rails app:

  • User provides login and password,
  • The credentials are checked against a list (e.g. a database) of approved users,
  • If the credentials match, a cookie is set on the user’s browser.

This list is of course simplified, but that’s the general idea. But for an API app we can’t use cookies for a couple of reasons:

  • The frontend app is completely separate from our API backend - the cookies we set in the browser won’t hit the backend at all.
  • There might not even be a browser. I keep stressing this across this whole series of articles, but I feel this bears repeating: anything that can parse JSON should be able to use our app.

Enter JWT

For the above reasons we will use JSON Web Tokens for passing around credentials in our app. A JWT consists of three parts:

  • A header, which contains information about the type of the token and the algorithm used. It looks something like this:
{
  "alg": "HS256",
  "typ":"JWT"
}
  • A payload - that’s whatever we want the token to carry around, for example a user’s ID.
{
  "userId": 1
}
  • A signature, used to ensure that the communication has not been tampered with. A signature is calculated using the HMAC-SHA256 algorithm; without going too deep into the implementation of this, it’s a hash that’s signed with a server-side secret, so the server can verify that it was indeed the one to produce a token.

Thankfully, there’s a Ruby gem to deal with a lot of this for us. It’s called, somewhat unimaginatively for a Ruby gem, jwt. Let’s look at how we can make use of it.

User model

We all knew this had to happen. It happens in almost every Rails project. We need to create a User model and all the stuff around it.

Since this is a very simple app - and we’re learning, so we want to do as much of this by hand as possible! - we’re not going to use anything ready-made. That’s right, remove that gem 'devise' from your Gemfile please.

Rails 4 added the has_secure_password helper to ActiveRecord models, so we’ll use that to extract some pain out of managing passwords. Make sure gem 'bcrypt' is in your Gemfile, because it is required. I’ll lock mine to 3.1.11, but in the future the newest applicable version may of course change.

That’s all we need to create a very simple User:

$ rails g model User email password_digest admin:boolean

Very barebones, but that’s all we need. The migration needs a couple tweaks to look something like this:

class CreateUsers < ActiveRecord::Migration[5.1]
  def change
    create_table :users do |t|
      t.string :email, null: false
      t.string :password_digest, null: false
      t.boolean :admin, default: false

      t.timestamps
    end
  end
end

Note the NOT NULLs and defaulting to false on the admin flag.

We also need a couple more things in the User model:

class User < ApplicationRecord
  has_secure_password
  validates :email, presence: true
end

And we’re done here!

JWT encoding

Reading through the docs, we can see that to encode a token using HMAC-SHA256 with a particular secret, we need to do something like this:

JWT.encode payload, hmac_secret, 'HS256'

And to decode,

JWT.decode token, hmac_secret, true, { :algorithm => 'HS256' }

Slightly unwieldy! We can wrap this into a service object.

class JwtService
  def self.encode(payload)
    JWT.encode(payload, Rails.application.secrets.secret_key_base, 'HS256')
  end

  def self.decode(token)
    body, = JWT.decode(token, Rails.application.secrets.secret_key_base,
                       true, algorithm: 'HS256')
    HashWithIndifferentAccess.new(body)
  rescue JWT::ExpiredSignature
    nil
  end
end

We’ll just use the secret_key_base as our secret - it’s used for cookie signing in regular apps, so it makes sense here. Notice how in decode we’re skipping the second part of what JWT.decode returns - the second part is the header, and we’re not really interested in that. We’re also wrapping it in a HashWithIndifferentAccess - JWT returns hashes that are string-keyed, but I guarantee you that one of us will forget about that later and spend a good long while getting mad.

Note that we’re rescuing from JWT::ExpiredSignature. JSON Web Tokens have some reserved “claims” (keys) - one of those is the exp claim. When that is set to a timestamp, and it is past that timestamp, JWT will raise this exception. We’ll simply opt to return nil in such a case and utilize it later.

Big thank you to Ylan Segal for pointing reserved claims out to me!

Let’s drop in a quick test to check everything works:

describe JwtService do
  subject { described_class }

  let(:payload) { { 'one' => 'two' } }
  let(:token) { '...' }

  describe '.encode' do
    it { expect(subject.encode(payload)).to eq(token) }
  end

  describe '.decode' do
    it { expect(subject.decode(token)).to eq(payload) }
  end
end

It should pass, of course. Okay, on to the next thing:

Giving out an authentication token

We need to check an email and password against Users, and if everything is fine - give out an access token. We’ll also add an expiration timestamp to our tokens so they aren’t everliving.

We can use a command object for the authentication and token-generation portion. The command object exposes a simple pattern:

CommandObject.call(arguments)

We’ll decide that our command objects should return an instance of themselves after calling a payload function. They will also publish a result, errors and a success? predicate.

We’ll make ourselves a little BaseCommand class so we don’t repeat ourselves:

class BaseCommand
  attr_reader :result

  def self.call(*args)
    new(*args).call
  end

  def call
    @result = nil
    payload
    self
  end

  def success?
    errors.empty?
  end

  def errors
    @errors ||= ActiveModel::Errors.new(self)
  end

  private

  def initialize(*_)
    not_implemented
  end

  def payload
    not_implemented
  end
end

We’ll use ActiveModel::Errors to provide us with a nice clean way to collect errors that might arise in our commands.

Let’s look at the AuthenticateUserCommand now:

class AuthenticateUserCommand < BaseCommand
  private

  attr_reader :email, :password

  def initialize(email, password)
    @email = email
    @password = password
  end

  def user
    @user ||= User.find_by(email: email)
  end

  def password_valid?
    user && user.authenticate(password)
  end

  def payload
    if password_valid?
      @result = JwtService.encode(contents)
    else
      errors.add(:base, I18n.t('authenticate_user_command.invalid_credentials'))
    end
  end

  def contents
    {
      user_id: user.id,
      exp: 24.hours.from_now.to_i
    }
  end
end

It’s a mouthful, but the internals are simple as pie: if an user for the e-mail provided exists and the password matches, return a JSON Web Token with a payload of user ID and an expiration timer. Otherwise, append to errors and return nil.

Note that since the payload is time sensitive with the addition of expiration timestamp, to test it properly we’ll need something like Timecop.

With this command object, our auth controller is very simple:

module Api
  module V1
    class AuthsController < ApplicationController
      def create
        token_command = AuthenticateUserCommand.call(*params.slice(:user, :password).values)

        if token_command.success?
          render json: { token: token_command.result }
        else
          render json: { error: token_command.errors }, status: :unauthorized
        end
      end
    end
  end
end

Add a route and you’re golden:

resource :auth, only: %i[create]

Now when I try to log in with a nonexistent user, I get an Unauthorized status:

But when I create this user in the console,

User.create(email: 'p@p.local', password: 'password123')

and try again,

Sweet! A token!

Locking it down

We need to do a couple more things:

  • decode the token from headers,
  • check it against database for users,
  • check if it’s not expired.

We can confine this to another command:

class DecodeAuthenticationCommand < BaseCommand
  private

  attr_reader :headers

  def initialize(headers)
    @headers = headers
    @user = nil
  end

  def payload
    return unless token_present?
    @result = user if user
  end

  def user
    @user ||= User.find_by(id: decoded_id)
    @user || errors.add(:token, I18n.t('decode_authentication_command.token_invalid')) && nil
  end

  def token_present?
    token.present? && token_contents.present?
  end

  def token
    return authorization_header.split(' ').last if authorization_header.present?
    errors.add(:token, I18n.t('decode_authentication_command.token_missing'))
    nil
  end

  def authorization_header
    headers['Authorization']
  end

  def token_contents
    @token_contents ||= begin
      decoded = JwtService.decode(token)
      errors.add(:token, I18n.t('decode_authentication_command.token_expired')) unless decoded
      decoded
    end
  end

  def decoded_id
    token_contents['user_id']
  end
end

Whew, another mouthful! It extracts the contents of the Authorization header (expecting it to contain something like Bearer token.goes.here, checks whether a user with a given ID exists. We’re also assuming that when JwtService returns nil, it’s because the token has already expired (according to the exp reserved claim). If anything goes wrong at all, it just registers an error and bails.

We can then get ourselves a nice concern to include in our application controller:

class NotAuthorizedException < StandardError; end

module TokenAuthenticatable
  extend ActiveSupport::Concern

  included do
    attr_reader :current_user

    before_action :authenticate_user

    rescue_from NotAuthorizedException, with: -> { render json: { error: 'Not Authorized' }, status: 401 }
  end

  private

  def authenticate_user
    @current_user = DecodeAuthenticationCommand.call(request.headers).result
    raise NotAuthorizedException unless @current_user
  end
end

It’s also important to add

skip_before_action :authenticate_user

to our AuthsController, or we will reject users trying to authenticate because they aren’t authenticated.

Now if I try to get a list of authors in Postman not passing in an authentication header, I get rejected:

But if I do pass this header, I’m approved!

Administrative access

One more thing: currently any logged in user can edit books. That’s not great. We’ll need to check for admin privileges before that.

We’ll add a simple authorization-checking function in another concern:

module AdminAuthorizable
  extend ActiveSupport::Concern

  included do
    rescue_from NotPermittedException, with: -> { render json: { error: 'Not Permitted' }, status: :forbidden }
  end

  def authorize!(action)
    raise NotPermittedException if action != :read && !current_user.admin?
    true
  end
end

Big thank you to Никита Василевский for pointing out that I left a bug in this code!

Now we can use authorize! :read in our index and show actions, and authorize! :create, authorize! :update and authorize! :destroy elsewhere. Simple yet effective!

Closing thoughts

We did a lot of things by hand today. We could have plonked in Devise, simple_command for the command objects (I recommend you look it over if you haven’t already - our commands are heavily modeled after what they’re doing!), and e.g. cancancan for the role-based auth. The point, however, is to learn how to do things yourself, especially in fun-time projects - it removes a lot of magic from the gems you’re using in large, production-level apps.

As always, the state of the project after this part can be found on GitHub at paweljw/bookstore-backend.

We’ve started adding tests in this part, too. We’re not exactly doing TDD here - and that’s totally fine when learning (unless you’re learning TDD, of course!). It was important to get the basic API and auth working in the first place so that we know what we’re testing. We’ll talk about testing Rails API apps next time. See you then!

And once again, special thanks go out to Никита Василевский and Ylan Segal for corrections!


Top image credit: https://pixabay.com/p-406986/ (CC0)