Tuesday, December 20, 2011

Rails authentication using devise and omniauth-ldap

Recently I've been doing some development in Ruby on Rails and I wanted to be able to have users log into the application using Active Directory credentials and I wanted some user information to persist in a database so that I could make objects belong_to a User.  In this case the application is for IT Change Management so I have two models, User and Change.  User has_many changes and Change belongs_to user.  I started to code something up myself, but then I thought I would take some time to figure out how to do it using some of the gems out there that make life simple.  So I wanted to combine devise with omniauth-ldap.  It took me a while to get it all working, and so I want to document my process for everyone (especially me) to come back and reference.  So here is my walkthrough of creating a very simple application using devise and omniauth-ldap for user management.

Adding your gems
For this project I am going to add the following lines to my gemfile:

gem "nifty-generators", :git => 'https://github.com/ryanb/nifty-generators.git'
gem 'devise', :git => 'https://github.com/plataformatec/devise.git'
gem 'omniauth-ldap', :git => 'https://github.com/intridea/omniauth-ldap.git'
gem 'annotate', :git => 'git://github.com/ctran/annotate_models.git'
The annotate gem and the nifty-generators gem aren't necessary, but I like to use them.  Nifty-generators will make the flash notices that you'll need later on in the project so you might consider using it if you're just following along.  Now run bundle to get everything set up.

Setting up nifty-generators
There are a couple steps that I'm going to go through to set up nifty-generators which are optional if you decide that you don't mind having your rails project look ugly.
rails generate nifty:config
rails generate nifty:layout
rails generate nifty:scaffold Change title:string description:string
cp public/stylesheets/application.css app/assets/stylesheets/nifty.css
rm public/index.html
cat app/views/layouts/application.html.erb | sed \
's/javascript_include_tag :defaults/javascript_include_tag "application"/' \
> /tmp/what.txt
 cp /tmp/what.txt app/views/layouts/application.html.erb
Also, make sure you edit config/routes.rb and add a path to root:
root :to => 'changes#index'
At this point you should have a working application that will allow you to create, edit, and delete changes.  It should look nice and neat.  Make sure that you have the ability to delete objects, and if you do not check the line in app/views/layouts/application.html.erb to make sure that javascript_include_tag is including "application" not :defaults.  A tell-tale sign that javascript is the source of your delete problem is that when you try to delete a change you're not even given the "are you sure" prompt.

You have probably also seen some devise warnings popping up when you run some of these commands.  Don't worry, we're going to take care of that in the next section.

Configuring devise for user management
 Now we need to create a model for our users, and we'll call that model User.  We will use devise because then we'll have all of the helpers created for things like checking if the current user is authenticated.  This part is amazingly simple.
rails generate devise:install
rails generate devise User 
rake db:migrate 
Bam!  That's it.  Now you have a user model with all kinds of neat options.  If you didn't want to perform Active Directory authentication you would be almost done now with just some work to customize the views.  Now we're going to start on the stuff that took me the longest.

Integrating omniauth-ldap into devise
There are two things that really caused me a lot of headache and once I learned them this whole process went a lot faster.  The first thing you should know is that omniauth-ldap does not use the credentials that the user enters to bind to LDAP.  So unless your Active Directory is configured to allow anonymous LDAP then you're going to need to provide credentials for binding.  I wasn't doing that and I couldn't figure out why I kept getting LDAP errors.  The second thing that is useful to know is that the configuration for omniauth-ldap is done in the devise initialization file: config/initializers/devise.rb.  If you go to the github site for omniauth-ldap, Ping (the author) has some instructions and a sample application (look in the wiki for the sample application).  In that sample application, the omniauth-ldap configuration is in a file called config/initializers/omniauth.rb.  In our application, we're going to ignore that and instead put that into config/initializers/devise.rb.  Look for the string config.omniauth and include this in the configuration right after the comments:

config.omniauth :ldap, :host => 'YOUR_LDAP_SERVER,
      :uid => 'sAMAccountName',
      :port => 389,
      :method => :plain,
      :password => 'THE_PASSWORD_OF_THE_BIND_USER'
You'll probably need to get most of this information from an Active Directory administrator.  I don't advise using your own DN and password because then every time you change your password the application will break.  I would suggest running rails server right now just to make sure that you didn't enter anything that will cause the application to fail.  

Setting up your views
In my view, I want to do something really simple.  If a user is logged in, I want to welcome them and provide a logout link.  If a user is not logged in, I want to present a login link.  So I'm going to go into app/views/changes/index.html.erb and add this right under the title:

<% if user_signed_in? %>
    Welcome <%= current_user.email %> (<%= link_to "logout", destroy_user_session_path,  :method => :delete %>)
<% else %>
    Nobody logged in.  <%= link_to "log in here", user_omniauth_authorize_path(:ldap) %>
<% end %>
Those routes to destroy_user_session_path and user_omniauth_authorize_path are all generated by devise and didn't take any additional work on my part.

We also need to make a change to our user model that was generated by devise.  If you look in app/model/users.rb you'll see a line with some devise configuration that makes this user model :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, and :validatable.  To this list we need to add :omniauthable.  If you're seeing an error about user_omniauth_authorize_path not being defined it is because you didn't add :omniauthable to the user model.

At this point your application should run and you should be able to click those links.  You'll be taken to an ugly looking log in screen.  Log in isn't going to work yet because we haven't wired up the callback.  Here are a couple tests you can run to make sure that everything is right so far.  1. Try to log in with valid credentials.  You'll be directed to a sign in page and see an error message that you couldn't log in because of an LDAP error.  2. Try to log in with invalid credentials.  This time you'll be redirected to the same page, but you'll have an error message about invalid credentials.

More changes to the User model
In my case I want to retain some information about the user that is returned from LDAP so that I can have associations between changes and users.  So we need to generate a migration to add a few database fields to our User table.  Run this command:
rails generate migration AddColumnsToUsers firstname:string lastname:string displayname:string username:string
That should generate a migration for you that looks like this:

If you want to make sure that your username is also unique, you should add add_index :users, :username, :unique => true to the migration.  I also want to make sure that the username is not blank.  Now run rake db:migrate to add those fields to your table.  Then we need to edit the file app/models/user.rb to make those fields useful.  First, we need to add our new columns to the attributes_accessible line in the model.  
attr_accessible :firstname, :lastname, :displayname, :username, :email, :password, :password_confirmation, :remember_me
In my application, we are not going to set the users password, we're going to depend on Active Directory to do the authentication.  But I don't like the idea of removing the validation from devise and I don't want to have user accounts with no password in the database.  So I'm going to create a method that I can use to set a random password for each user.  I'm using the self.method notation to make this a class method rather than an instance method.

  def self.generate_random_password
Wiring up the callback
The next thing we need to do is tell devise what controller to use for the omniauth callback.  We're going to do this in our routes file, so edit config/routes.rb and find the line that reads devise_for :users and change it to read:
devise_for :users, :controllers => { :omniauth_callbacks => "users/omniauth_callbacks" }
That tells devise that when we get an answer back from ldap, we should look in app/controllers/users/omniauth_callbacks.  If we were using omniauth without devise, the user would log in by visiting /auth/ldap.  What we've done here is make use of the integration between devise and omniauth.  So users will visit /users/auth/ldap.  We also need to specify what controller is going to handle the callback.  Without devise we do the same thing by adding a line like this to config/routes:

match "/auth/:provider/callback" => "users/omniauth_callbacks#create"

 When you successfully authenticate to LDAP you'll get back a big bundle of information about the user.  We're going to use that information to either log in an existing user or create a new user and log that user in.  The code for this is going to be located in the omniauth_callbacks file which does not exist right now.  We need to create the file and add a method called ldap to handle the way LDAP results are returned.  This ldap method is going to check if there is an existing user and log that person in, otherwise create a new user.  Here is the code for that file:

class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController

    def ldap
        # We only find ourselves here if the authentication to LDAP was successful.
        ldap_return = request.env["omniauth.auth"]["extra"]["raw_info"]
        firstname = ldap_return.givenname[0].to_s
        lastname = ldap_return.sn[0].to_s
        displayname = ldap_return.displayname[0].to_s
        username = ldap_return.sAMAccountName[0].to_s
        email = ldap_return.proxyaddresses[0][5..-1].to_s

        if @user = User.find_by_username(username)
            sign_in_and_redirect @user
            @user = User.create(:firstname => firstname,
                                :lastname => lastname,
                                :displayname => displayname,
                                :username => username,
                                :email => email,
                                :password => User.generate_random_password)
            sign_in_and_redirect @user

This information could use a bit of explaining.  The results from LDAP are put into an environment variable called omniauth.auth, which is in a format called omniauth.auth format.  If we were using something like Facebook or Twitter for our authenticator omniauth could probably figure out things like the username and this code wouldn't be as ugly.  But the data we need is in an a hash called raw_info, which itself is a hash within a hash within a hash.  Each of the return values that we need are in an array, so my firstname is in an array which is why I have to put the [0] after ldap_return.givenname.  Don't worry too much about the email line, in your organization you probably will be able to use ldap_return.email.  In my case, the email address that I want an application to use is different than what is stored in that value, so I am grabbing an address from a different part of the response and stripping off the SMTP: that appears at the begining.

What's next?
We have a working application that can authenticate users via Active Directory.  There are a few loose ends that I would like to clean up, but this is as far as I was able to get.  For example, if a user's authentication fails he is redirected to a sign up page that I don't want them to see.  However, I wasn't able to figure out how to change the application routing to make them redirect to the sign in page instead.  Also, the login page is not customizable at all.  That really doesn't work for an enterprise environment which is where you're most likely to see Active Directory integration.  It looks unprofessional when your user gets redirected to a login page that looks nothing like the rest of your site.

So while this will work for back end stuff or personal stuff that you aren't going to expose to the end users it isn't going to meet your needs right now for enterprise applications.  If you want Active Directory integration that looks professional you're stuck with rolling your own for now.

I hope someone proves me wrong in the comments.  I really wanted this to work.


Scott Richmond said...

Hi Kevin,

Thanks for the guide. I've been trying to roll a similar system myself.
Two points:
1. I believe you can fully customize Devise views by extracting them with 'rails generate devise:views'.

2. Unfortunately your guide does not appear to work too well for. I'm hoping you can help. I'm not sure I should be spamming this comment with bucket loads of output, but I get the following helpful error:
"Could not authorize you from Ldap because "Ldap error"."
As far as I can tell its not even attempting to contact the LDAP server, so something is amiss.
Would I be able to see what you've done as an example to follow?

kevin thompson said...

Hi Scott,

The ldap login page is not being generated by devise, it is being generated by omniauth-ldap. And the code for omniauth-ldap doesn't have any generators like devise which will allow me to customize the view. I also tried creating a view and putting it in places that I thought might override the generated view but I had no luck. I think you should check out my most recent posting on the topic where I had more success. http://www.blackfistsecurity.com/2011/12/rails-authentication-using-devise-and_27.html

kevin thompson said...

As for the LDAP errors, there was one trick that I used to troubleshoot ldap problems. I was working on a Mac so I used tcpdump to capture the traffic that was going between my machine and the ldap server. Then I used wireshark to view the traffic. When I looked at the traffic dump I was able to get more detailed information about why the authentication was failing. In my case, it was because I wasn't binding to LDAP before attempting to search for the user.

Paneendra said...

If you want to customize the login page for ldap authentication, a simple form in a custom page can do the job.

action: /users/auth/ldap/callback
fields: username, password

Patrick Copeland said...

> For example, if a user's authentication fails he is redirected to a sign up page that I don't want them to see. However, I wasn't able to figure out how to change the application routing to make them redirect to the sign in page...

class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
def failure
redirect_to new_user_session_path