DEV Community

Igor Alexandrov for JetRockets

Posted on • Originally published at jetrockets.pro

How to add HTTP Basic auth to Amber application

As you already may know, one of our projects have a Crystal application in production. It is created with Amber framework and works just perfect.

The only thing that I don't personally like in Amber is not clear and sometimes outdated documentation, after about 11 years with Rails I still think that Rails Guides are the number one developer documentation in the world.

I had a task to add HTTP Basic auth to a couple of URLs in Amber application, and after studying documentation found that Amber doesn't provide necessary Pipe out of the box. Ok, next place to search for the answer was Gitter and after about an hour Dru Jensen helped me with a code example.

Amber uses internally HTTP::Handler for Pipes as well as Kemal does for Middlewares, so we can easily use code from Basic Auth for Kemal.

# src/pipes/http_basic_auth_pipe.cr

require "crypto/subtle"

class HTTPBasicAuthPipe
  include HTTP::Handler
  BASIC = "Basic"
  AUTH = "Authorization"
  AUTH_MESSAGE = "Could not verify your access level for that URL.\nYou have to login with proper credentials"
  HEADER_LOGIN_REQUIRED = "Basic realm=\"Login Required\""

  property credentials : Credentials?

  def initialize(@credentials : Credentials)
  end

  def initialize(username : String, password : String)
    initialize({ username => password })
  end

  def initialize(hash : Hash(String, String))
    initialize(Credentials.new(hash))
  end

  def initialize
    if ENV["HTTP_BASIC_USERNAME"]? && ENV["HTTP_BASIC_PASSWORD"]?
      initialize(ENV["HTTP_BASIC_USERNAME"], ENV["HTTP_BASIC_PASSWORD"])
    end
  end

  def call(context)
    if credentials
      if context.request.headers[AUTH]?
        if value = context.request.headers[AUTH]
          if value.size > 0 && value.starts_with?(BASIC)
            return call_next(context) if authorized?(value)
          end
        end
      end
      headers = HTTP::Headers.new
      context.response.status_code = 401
      context.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
      context.response.print AUTH_MESSAGE
    else
      call_next(context)
    end
  end

  private def authorized?(value)
    username, password = Base64.decode_string(value[BASIC.size + 1..-1]).split(":")
    credentials.not_nil!.authorize?(username, password)
  end

  class Credentials
    def initialize(@entries : Hash(String, String) = Hash(String, String).new)
    end

    def authorize?(username : String, given_password : String) : String?
      test_password = find_password(username, given_password)
      if Crypto::Subtle.constant_time_compare(test_password, given_password)
        username
      else
        nil
      end
    end

    private def find_password(username, given_password)
      # return a password that cannot possibly be correct if the username is wrong
      pw = "not #{given_password}"

      # iterate through each possibility to not leak info about valid usernames
      @entries.each do |(user, password)|
        if Crypto::Subtle.constant_time_compare(user, username)
          pw = password
        end
      end

      pw
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

And in routes.cr:

  # ...
  pipeline :api do
    # ...
    plug HTTPBasicAuthPipe.new
  end
  # ...
Enter fullscreen mode Exit fullscreen mode

Oldest comments (1)

Collapse
 
alexanderadam profile image
Alexander Adam

Awesome post! I would love to read more about Amber.