How to Protect Individual Resources with Passwords

User authentication in Rails is a solved problem but how can we protect individual resources with a password? It turns out that all we need is vanilla Rails and not that much code.

Problem Statement

Let’s say we’re building a file sharing website where users can upload files and share links with others. Files can be publicly available or protected with a password. We want to implement the protection scheme in the simplest possible manner. We’ll cover two approaches in the article:

  1. HTTP authentication that is simpler but offers poorer user experience.
  2. Password input that offers better user experience but is a bit more complicated.

But first, we need to lay some groundwork.

Preparations

The Upload model represents a file uploaded by a user. Because passwords are set on a per-upload basis we need to store the password in uploads. It’s important to realize vanilla Rails is enough. Just use has_secure_password and add password_digest to uploads.

password_digest should be NULL-able as passwords are optional. We also need to prevent has_secure_password from generating a password presence validator by passing validations: false. This has an unfortunate side effect of skipping a password length validation that ensures the password isn’t too long. We need to validate this ourselves. The model looks like this:

class Upload < ApplicationRecord
  # We skip the default validations because they make the password mandatory but
  # it's optional in our case.
  has_secure_password validations: false

  # This is normally added by has_secure_password but because we skip
  # validations we need to add it ourselves to avoid violating assumptions that
  # has_secure_password make.
  validates_length_of :password,
                      maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED

  def public?
    password_digest.nil?
  end

  def data
    # Return the associated data here.
    'HELLO, WORLD'
  end
end

HTTP authentication and password input both rely the same model. The differences are in the controller and view layer.

Solution 1: HTTP Authentication

Rails has built-in support for HTTP authentication which we can use in UploadsController. Please have a look at ActionController::HttpAuthentication to learn more about supported authentication methods. We’ll use basic authentication here because it offers the same level of security as a password input so if one is secure enough then so is the other.

HTTP authentication can be implemented with #authenticate_or_request_with_http_basic. It expects two things:

  • The name of an authentication realm.
  • A block performing authentication.

A realm in the context of HTTP authentication is a name of a space of protection, i.e. a set of HTTP resources that are protected by a given credential. For instance, an app can have a customer-facing and admin-facing pages split into two different realms.

In our case, realms consist of single Uploads because providing a password to download one file shouldn’t automatically permit the user to download other files. Thus the name of a realm must mention a specific Upload. To keep things simple, we’ll just use upload-#{upload.id} but you’re free to use anything else that’s unique for Upload.

The authentication block can safely ignore user names as we only care about passwords. It simply needs to call authenticate on Upload. The whole controller looks like this:

class UploadsController < ApplicationController
  before_action :authenticate_before_download

  def show
    send_data upload.data
  end

  private

  def upload
    @upload ||= Upload.find(params[:id])
  end

  def authenticate_before_download
    return if upload.public?

    realm = "upload-#{upload.id}"
    authenticate_or_request_with_http_basic(realm) do |username, password|
      upload.authenticate(password)
    end
  end
end

In a real app, we should cover the controller with automated tests as this feature is critical to securing user-uploaded content. You may take a look at a gist containing more complete implementation.

Solution 2: Password Input

HTTP authentication doesn’t offer good user experience, especially if we’d like to include our own branding, copy, etc. The second solution lifts these restrictions but needs a bit more code.

With HTTP authentication, a single action handled both authorization and download. This solution, though, needs two controller actions: one for displaying the password prompt and one for authorization and download.

Let’s start with route definitions:

resources :uploads, only: :show do
  member do
    post :download
  end
end

The above defines two routes:

  • /uploads/:id which prompts for the password. If the upload is publicly accessible it simply redirects to the download action.
  • /uploads/:id/download which authorizes the user and starts the download.

Let’s start with #show. The implementation is quite self-evident:

def show
  if upload.public?
    redirect_to action: :download
  else
    # Render show.html.erb with the password prompt.
  end
end

#upload is the memoizing accessor implemented the same way as in the previous section.

We can now implement #download:

def download
  if upload.password_digest.nil? || upload.authenticate(params[:password])
    send_data upload.data
  else
    render :show
  end
end

Like in the previous case, we should cover the controller with automated tests to ensure we aren’t leaking user data. If you’d like to see a more complete picture then please take a look at the gist with more implementation details.

Error reporting and a nice password prompt are left as exercises to the reader.

Summary

has_secure_password is mostly associated with user authentication but it can be used in all situations where password protection is required. The result code is short, easily testable, and doesn’t need third-party dependencies.

Enjoyed the article? Follow me on Twitter!

I regularly post about Ruby, Ruby on Rails, PostgreSQL, and Hotwire.

Leave your email to receive updates about articles.