Basecamp-like Subdomains with Devise
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.