See more posts

Securing a Ruby Web App With JSON Web Tokens

For securing APIs I usually turn to JSON Web Tokens. However, when setting up quick projects that are not APIs, it's tempting to just use basic auth.

In Rack, that's really simple

But there are some issues with Basic auth:

  • Credentials are sent in plain text with every request
  • More vulnerable to brute force attacks or DOS
  • Encourages storing credentials in plain text
  • Authentication doesn't expire

Eventually you get to a point where you need to migrate to a more robust solution.

Implementing JWT Authentication:

JSON Web tokens provide a good way to authenticate users once, store some basic (publicly available) information, set expiration, and send a token which is signed by a secret and provided on subsequent requests.

This is much more flexible than Basic Auth, for instance, a persistent token that remembers the user could be implemented, and permissions could be elevated for a short amount of time when a user wants to access more sensitive areas of the application.

Having signed information provided about the authenticated user also means that you can avoid loading a user on every request from the database.

JWTs are typically sent with every authenticated request in the Authorization header similarly to Basic Auth. However the browser doesn't provide a baked in authentication form in the same way it does with Basic Auth.

Setting up the dependencies

The main dependency is the jwt gem. In my case, I'm also using Sinatra, and the sinatra-activerecord extensions.

ruby
# Gemfile

# In my case, I'm using active record with Sinatra
gem "sinatra-activerecord"
gem "sqlite3"

gem 'bcrypt', '~> 3.1.7' # Use ActiveModel has_secure_password
gem 'jwt' # JSON web token parsing

Then, of course: bundle install

Authentication with a JWT

I like to abstract the Authentication Token into its own class which takes care of the JWT settings, encoding, signing, decoding, and verification. The good news is that this code is very portable, and I find myself reusing it for many projects.

ruby
# authentication.rb

# Abstracts JWTs storing headers and body (payload) in hashes
# Takes care of signature verification based on JWT_OPTIONS
class Authentication
  class Error < StandardError;end
  class UnsupportedAlgError < Error;end

  ISSUER = "your-domain".freeze
  JWT_OPTIONS = {
    algorithms: ['HS256'],
    nbf_leeway: 10,
    verify_iat: true,
    verify_iss: true,
    iss: [ISSUER]
  }.freeze

  # Sets the secret used for signing the token at the class level
  def self.hmac_secret=(value)
    @secret = value
  end

  # Retrieves the secret used for signing. defaults to the `SECRET_KEY_BASE` env variable.
  def self.hmac_secret
    @secret || ENV['SECRET_KEY_BASE']
  end

  # Accepts a url safe base64, signed token. The token is decoded and the key is found to verify the sig
  def self.from_token(auth_token)
    body, headers = JWT.decode(auth_token, nil, true, JWT_OPTIONS) do |headers|
      headers.transform_keys(&:to_sym)
      case headers['alg']
      when 'HS256'
        Authentication.hmac_secret
      else
        raise UnsupportedAlgError, 'This algorithm is not supported'
      end
    end
    new(body: body.transform_keys(&:to_sym), headers: headers.transform_keys(&:to_sym))
  end

  attr_accessor :body, :headers

  def initialize(body: {}, headers: {})
    @body = default_body.merge(body)
    @headers = default_headers.merge(headers)
  end

  # Encodes to base64, signed, '.' delimated token
  def to_token
    case @headers[:alg]
    when 'HS256'
      JWT.encode(@body, Authentication.hmac_secret, @headers[:alg], @headers)
    else
      raise UnsupportedAlgError, 'This algorithm is not supported'
    end
  end

  alias_method :to_s, :to_token

  def expires_at
    Time.at(@body[:exp])
  end

  private

  def default_headers
    {
      alg: 'HS256'
    }
  end

  def default_body
    {
      exp: 1.days.from_now.to_i,
      iss: ISSUER,
      nbf: Time.now.to_i,
      iat: Time.now.to_i
    }
  end
end

One important thing to note is that the JWT spec allows for flexibility in key finding, algorithm definition, and other things which could open your app to a variety of exploitation vectors. The main thing to look out for is alg: 'none' which could be used to bypass verification altogether.

Writing tests is especially important for authentication. Let's write a test to cover this issue:

ruby
# authentication_spec.rb

RSpec.describe Authentication do
  it "does not decode unsafe 'none' alg" do
    forged_token = [{ alg: 'none' }, { sub: 'Hacker' }]
      .map{ |h| Base64.urlsafe_encode64(JSON.dump(h), padding: false).chomp }
      .join('.') << '.fakesig'

    expect{ Authentication.from_token(forged_token) }.to raise_error Authentication::UnsupportedAlgError
  end
end

Great! now we can create and verify JWTs.

Adding authentication to a Ruby app

Now it's just a matter of getting the token from the Authorization header, and verifying it.

ruby
class Application < Sinatra::Base
  # The `Authorization` header comes through as HTTP_AUTHORIZATION
  def auth_header
    env['HTTP_AUTHORIZATION']
  end

  # Strip the `Bearer` from the token part, and return the token
  def raw_auth_token
    auth_header&.split(/\s+/, 2)&.last
  end

  # Initialize and verify the token. This wall raise an error if anything is amiss
  def verified_auth_token
    Authentication.from_token(raw_auth_token)
  end

  # This isn't strictly necessary, but reads nicer when defining routes
  def protected!
    verified_auth_token
  end
end

Let's create a route which is authenticated:

ruby
  # application.rb

  get '/protected' do
    protected!
    "Properly authenticated!"
  end

In the case where a user is not authenticated, it should return an Unauthorized response. Sinatra makes this easy with error handling routes.

ruby
  # application.rb

  error JWT::DecodeError do
    status 401
    response['WWW-Authenticate'] = %(Bearer realm="The secret clubhouse")
    erb :login # Show the login screen
  end

The login screen should look something like this:

html
<%# login.erb %>

<section id="main">
  <form action="/login" method="POST">
    <input type="hidden" name="redirect" value="<%= env['REQUEST_URI'] %>">

    <label>
      Username: <input name="user[name]">
    </label>

    <label>
      Password: <input type="password" name="user[password]">
    </label>

    <button type="submit">Log in!</button>
  </form>
</section>

Now, let's add a test so we don't have to do this manually

ruby
  # application_spec.rb

  it "secures the /protected route" do
    get '/protected'
    expect(last_response.status).to eq(401)
  end

  it "allows access with a proper token" do
    token = Authentication.new(body: { sub: 'alex' }).to_token
    header 'Authorization', "Bearer #{token}"
    get '/protected'
    expect(last_response).to be_ok
  end

Adding a user

We also want our application to supply the user with a token which can be used for authentication. Adding a users table is simple. Create a migration, and define 2 columns:

  • name
  • password_digest

Note: using password digest is important - We're not storing the actual password. Luckily the bcrypt gem which was added earlier takes care of authentication, hashing, and storing the password with ActiveRecord.

ruby
# db/migrate/0000_create_users.rb

class CreateUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :users do |t|
      t.string :name, null: false
      t.string :password_digest, null: false
    end
  end
end
ruby
# user.rb

class User < ActiveRecord::Base
  has_secure_password # Provided by the bycrpt gem
end

Now we can migrate, and add a user for development. the racksh gem gives access to a rails like console for rack applications. This could also be done in some sort of seed, or rake task.

zsh
$ rake db:migrate
...
$ racksh
> User.create name: 'Alex', password: '...'

Create a login action

To send a token back to the user, they must provide us with the correct credentials. Let's create an action that accepts a user with the two fields defined earlier, except, they will be sending the password.

I've also added a redirect parameter which is provided by the login form. It works like this

  1. User tries to visit /protected
  2. Auth token is checked, and fails
  3. login.erb is rendered with the redirect url (/protected )
  4. Credentials are posted to /login
  5. A token is created and returned to the user
  6. The user is redirected to /protected
ruby
post '/login' do
  @user = User.find_by(name: params.dig('user', 'name'))

  if @user&.authenticate(params.dig('user', 'password'))
    auth = Authentication.new(body: { sub: params.dig('user', 'name') })
    response.set_cookie('Authorization', value: "Bearer #{auth.to_token}", expires: auth.expires_at)
  end

  redirect params['redirect']
end

There's still one issue with this. This isn't an api and the browser won't include the token with the Authorization header like it would with basic authentication. This is easily solved by storing the token in a cookie:

ruby
response.set_cookie('Authorization', value: "Bearer #{auth.to_token}", expires: auth.expires_at)

But our application expects an Authorization header and I'd prefer not to modify my existing code.

This is a perfect scenario for some simple Rack middleware, which will take a cookie named 'Authorization' and convert it into a header.

ruby
# cookie_to_auth.rb

class CookieToAuth
  def initialize(app)
    @app = app
  end

  def call(env)
    request = Rack::Request.new(env)
    auth_value = request.cookies['Authorization']
    env['HTTP_AUTHORIZATION'] ||= auth_value if auth_value
    status, headers, body = @app.call(env)
  end
end

This can be added to the config.ru

ruby
# config.ru

use CookieToAuth
# ...
run MainApp

Now you should have a fully working and flexible authentication system!

Thanks for reading! See more posts Leave feedback