Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Rails: Authenticate before routing

The What.

I'm trying to implement token based Basic authentication on my Rails API. It works fine for existing routes, but here's the catch:

When an unauthenticated user visits a route that does NOT exist, it displays the 404 - Not found page, and not the 401 - Unauthorized. How do I get Rails to check authentication before validating the routes?

Here's my application_controller.rb:

class Api::V1::ApiController < ApplicationController
  # Main controller inherited by all API controllers

  # For APIs, you may want to use :null_session instead.
  protect_from_forgery with: :null_session

  # Enforce site-wide authentication
  before_action :authenticate

  def authenticate
    authenticate_token || render_unauthorized
  end

  def authenticate_token
    # Can we find a user with the authentication token used?
    authenticate_with_http_basic do |u, p|
      # Only search active, private keys
      @api_key = ApiKey.active.find_by(api_key: u, is_secret: true)
      @user = @api_key.user if @api_key

      return @api_key
    end
  end

  def render_unauthorized
    # Displays the Unauthorized message
    render json: JSON.pretty_generate({ 
      error: { 
        type: "unauthorized",
        message: "This page cannot be accessed without a valid API key."
        } 
      }), status: 401
  end
end

The Why

Think of it this way: someone stops you at the office to ask for directions. Do you ask them to present some ID first or do you just show them the way?

If it were a public office, I'd just show them the way .. but if we were in the restricted Special Projects division at the CIA, I don't care where you're going (even especially if you tell me you're looking for Office 404, which I know doesn't exist): I want to see some ID first.

Edit: Using Basic Auth

I originally mentioned Token based authentication, but it's actually Basic Auth (with "username" = "token").

like image 983
FloatingRock Avatar asked Dec 14 '22 21:12

FloatingRock


2 Answers

Using the built-in rails authentication you can't achieve what you want for the very reason @rich-peck explained. Rails first evaluate your routes, then passes the request for the controller.

You have two options. First, do exactly what the Stripe guys are doing. Delegate the authentication to the web server in front of your app server (nginx in their case).

$ curl -I https://api.stripe.com/this/route/doesnt/exist
HTTP/1.1 401 Unauthorized
Server: nginx
Date: Fri, 08 Aug 2014 21:21:49 GMT
Content-Type: application/json;charset=utf-8
Www-Authenticate: Basic realm="Stripe"
Cache-Control: no-cache, no-store
Stripe-Version: 2014-08-04
Strict-Transport-Security: max-age=31556926; includeSubDomains

Or, if you want/need to keep it in ruby-land, you can use a rack middleware which will authenticate before rails loads your routes. You can try https://github.com/iain/rack-token_auth

$ bin/rake middleware
...snip a bunch of middleware...
use Rack::TokenAuth
run Dummy::App::Application.routes

Update: Using HTTP Basic Authentication

In that case, you don't need another library at all, because rack has built-in support for http basic auth through the Rack::Auth::Basic middleware, e.g.

config.middleware.use Rack::Auth::Basic do |username, password|
  username == "bob" && password == "secret"
end

Update: Can I apply the authentication to the API namespace?

It depends. Because the rack middleware runs before rails loads your routes, controllers, etc. it has no knowledge of your namespaces. Nonetheless, if your namespaced controllers are also namespaced at the URL level, e.g. all under /api, you can check request.path to whether apply the authentication.

You can create a new rack middlware to decorate Rack::Auth::Basic with this behaviour, e.g.

class MyAPIAuth < Rack::Auth::Basic
  def call(env)
    request = Rack::Request.new(env)

    if request.path =~ /\A\/api\/?/
      super
    else
      @app.call(env)
    end
  end
end

# change your configuration to use your middleware
config.middleware.use MyAPIAuth do |username, password|
  username == "bob" && password == "secret"
end
like image 67
wicz Avatar answered Jan 02 '23 13:01

wicz


All you need to do is to append a catch-all route at the end of your routes.rb:

YourApp::Application.routes.draw do
  # Standard routes
  # ...

  # Catch-all
  get '*other', to: 'dummy#show'
end

Then have a dummy controller just render 404 - Not Found in show action. Should anyone visit a non-existing route it will be handled by dummy, which will perform authentication first in a before filter and if it fails 401 - Unauthorized is rendered.

like image 44
Kombajn zbożowy Avatar answered Jan 02 '23 14:01

Kombajn zbożowy