Basecamp-like Subdomains with Devise

Dave Kennedy
Share

Building authentication for applications is a run of the mill task we have all encountered at some point or another. In the past, most developers would have reached into their tool belt and pulled out restful authentication. Lately, a new kid on the block has been stealing a lot of thunder where authentication is concerned, and with good reason.

Devise provides a complete authentication solution. Views, mailers and a host of common authentication helpers such as registration, confirm via email, and the ability to lock accounts come as standard features out the box.

Rolling your own authentication system can be a good exercise and, in most cases, is not too big a chore. Having something stable, feature rich and modular like Devise can be a big time saver.

Today, we are going to look at implementing Devise in a real world scenario. While the base features of Devise are well-known, its flexibility is somewhat overlooked. We will look at customizing how users log in.

A common idiom in multi-tenant applications these days is to have subdomains defined as the users account, for example rubysource.basecamp.com could be a fictional Basecamp login for Rubysource. Let’s do this with Devise.

The Application

We will start with the essence of our application first. It will be a Rails 3.1-based note taking application. At this stage, we will keep it really simple and only have three models in our domain, Account, Note and User. Let’s crank up some generators for Account and Note and leave User for Devise.

rails g model Account subdomain:string
rails g scaffold Note title:string content:text user_id:integer account_id:integer

In our Account model we will create the associations,

class Account < ActiveRecord::Base
  has_many :users
  has_many :notes, :through => :users
end

Similarly for Note we build the associations as follows.

class Note > ActiveRecord::Base
  belongs_to :user
  belongs_to :account
end

Nothing special here, just a couple of ActiveRecord models with associations. The only point of interest is the subdomain attribute on the Account model. This is essentially the slug for the account in the request URL (“rubysource” in our previous example).

Introducing Devise

Devise is a gem, so very straight forward to install. Add Devise to your Gemfile, gem devise then run bundle install.

Now on the command line run rails g and check out the generators Devise adds to our application.

Devise:
  devise
  devise:form_for
  devise:install
  devise:shared_views
  devise:simple_form_for
  devise:views

We will use the obvious rails g devise:install to get Devise running inside our application.

After running the generator we should get a couple of instructions telling us to set the root of the application to map somewhere, have a flash area in our layout and setting the default mailer in our development and test environments. Just follow those to the letter. I have set the root of the application to point to notes#index.

We need a user model for Devise to authenticate. As it turns out, we can use devise to generate that for us rails g devise User. This generates the model,corresponding migration and the specialized devise_for route in our config/routes.rb file.

Before running the migrations we need to modify the User model to associate the Account. We also associate Note as a has_many.

I should point out this is the best time to decide what modules Devise will use such as adding :confirmable to the migration, but for now we will just use the default given to us by the generator.

With Devise installed, the easiest way to protect our content is with the blanket before_filter :authenticate_user! placed in the ApplicationController. Starting up the server and navigating to localhost:3000, the generic Devise login form is displayed.

Knowing it Works

Now we have Devise in place, it is not doing quite what we want just yet. We need to authenticate against the subdomain. There are a couple of options in terms of testing. True, we do not really want to test Devise itself. It’s a third party, well- used piece of software, so it’s fair to say it has been well tested. However, we still want to ensure our customizations behave the way we expect. We could do some work starting up a local server and using the brilliant lvh.me domain so we do not have to keep adding subdomains to our hosts file.

But aren’t we are better than that? We want reproducible, automated tests. Let’s use our integration tests and the awesomeness that is Capybara. Capybara, by default, runs our suite under the domain www.example.com. However by simply using the app_host helper we can explicitly specify the domain the test runs under. Here is a basic test of the behavior we are looking for using RSpec requests.

describe "LoginToAccounts" do
  before do
    other_account = Factory.create(:account)
    @invalid_user = Factory.create(:user, account: other_account)
    @account = Factory.create(:account, subdomain: "test-account")
    @user = Factory.create(:user, account: @account)
    Capybara.app_host = "http://test-account.example.com"
    visit '/'
  end

  describe "log in to a valid account" do
    before do
      fill_in 'Email', with: @user.email
      fill_in 'Password', with: @user.password
      click_button 'Sign in'
    end

    it "will notify me that I have logged in successfully" do
      page.should have_content "Signed in successfully"
    end

  end

  describe "fail login for valid user wrong account" do
    before do
      fill_in 'Email', with: @invalid_user.email
      fill_in 'Password', with: @invalid_user.password
      click_button 'Sign in'
    end

    it "will not notify me that I have logged in successfully" do
      page.should_not have_content "Signed in successfully"
    end

  end
end

Devise Flexibility

Now that we have a test, it’s time to implement the functionality of capturing the subdomain and making sure the user is authenticating into the correct account.

When we installed Devise, it generated an initializer, aptly named devise.rb in the config/initializers directory. This file is brilliantly commented and should be the first point of call when looking to add functionality to the vanilla install of Devise.

You will see on line 33 of this file (based on installing version 1.4.7 of Devise) that we can add request keys for authentication. Simply uncomment the line and add :subdomain to the array of keys. This will basically push this request parameter to Devises authentication method to test against.

Even though that was simple enough, we are not quite there yet. Remember that Devise authenticates on User and the subdomain attribute is with the Account. Now, we could simply add a subdomain attribute to user and be done, but that just seems plain wrong to me.

Instead, we will override the find_for_authentication method used by Devise. This method is our dropping off point before Devise takes over. To find the Account we are looking for, we will grab the subdomain from the request and pass it up the chain to be authenticated. Using Rails 3+ makes this really easy. In the User model simply add

def self.find_for_authentication(conditions={})
    conditions[:account_id] = Account.find_by_subdomain(conditions.delete(:subdomain)).id
    super(conditions)
  end

Here we are popping out the subdomain parameter and doing a find by on the Account and pass it up to the find_for_authentication method.

With all this in place we can run the tests and make sure we have a passing suite. The demo code can be found on Github.

Where to Next?

Our naive little application has authentication and no unauthorized users can access our all-important notes. We still have some work to do regarding what notes are displayed for what login. That is solved with the common multi-tenant idiom of making all our data in the controller extract down from the “current tenant”, like this on by DHH.

That is not really what we were looking at here today. Normally, I would avoid testing third party code, but the behavior of our application and the changes we made require that we at least tip our hat to Devise. Wrapping this as an integration test is the best place to do so and using a tool like Capybara makes it easy, so it would be criminal not to be thorough.

Authentication is a pattern we will do over and over again when it comes to Rails applications, and something as feature rich as Devise can be intimidating. Hence, why some look to simpler gems such as Restful Authentication, Sorcery or just roll our own. I recommend you invest some time in Devise. With a good understanding you can almost write off the authentication element out of your application and use Devise as a drop in replacement. It’s design is so great and personally I have yet to find myself in the situation where I am fighting against Devise. It’s usually a case of hooking into it to create exactly what I need.