Thursday, June 9, 2011

Rails 3 Authentication with Devise

add to GEMFILE
gem 'devise'
Install gems
#bundle install
Install devise
$rails g devise:install
Generate Model
$rails g devise User 
Migrate database
$rake db:migrate
Generate Views
 $rails g devise:views

By default login by Devise use email to identify. if you one to use username follow this step:

Create a username field in Users

  1. Create a migration:
     rails generate migration add_username_to_users username:string
    
  2. Run the migration:
    rake db:migrate
    
  3. Modify the User model and add username to attr_accessible
    attr_accessible :username
    

Create a login virtual attribute in User Model

  1. Add login as an attr_accessor
    # Virtual attribute for authenticating by either username or email
    # This is in addition to a real persisted field like 'username'
    attr_accessor :login
    
  2. Add login to attr_accessible
    attr_accessible :login
    


Tell Devise to use :login in the authentication_keys

  1. Modify config/initializers/devise.rb to have:
    config.authentication_keys = [ :login ]
    

Overwrite Devise’s find_for_database_authentication method in Users

  • For ActiveRecord:
    protected
    
     def self.find_for_database_authentication(warden_conditions)
       conditions = warden_conditions.dup
       login = conditions.delete(:login)
       where(conditions).where(["lower(username) = :value OR lower(email) = :value", { :value => login.downcase }]).first
     end
    
  • For Mongoid:
    Note: This code for Mongoid does some small things differently then the ActiveRecord code above. Would be great if someone could port the complete functionality of the ActiveRecord code over to Mongoid [basically you need to port the ‘where(conditions)’]. It is not required but will allow greater flexibility.
    field :email
    
    protected
    
    def self.find_for_database_authentication(conditions)
      login = conditions.delete(:login)
      self.any_of({ :username => login }, { :email => login }).first
    end
    

Update your views

  1. Make sure you have the Devise views in your project so that you can customize them
    Rails 3:
    rails g devise:views
    

    Rails 2:
    script/generate devise_views
    
  2. Modify the views
    • sessions/new.html.erb:
      -  <p><%= f.label :email %><br />
      -  <%= f.email_field :email %></p>
      +  <p><%= f.label :login %><br />
      +  <%= f.text_field :login %></p>
      
    • registrations/new.html.erb
      +  <p><%= f.label :username %><br />
      +  <%= f.text_field :username %></p>
         <p><%= f.label :email %><br />
         <%= f.email_field :email %></p>
      
    • registrations/edit.html.erb
      +  <p><%= f.label :username %><br />
      +  <%= f.text_field :username %></p>
         <p><%= f.label :email %><br />
         <%= f.email_field :email %></p>
      

Manipulate the :login label that Rails will display

  1. Modify config/locales/en.yml to contain something like:
    Rails 2:
    activemodel:
      attributes:
        user:
          login: "Username or email"
    

    Rails 3:
    en:
      activerecord:
        attributes:
          user:  
            login: "Username or email"
    

Allow users to recover their password using either username or email address

This section assumes you have run through the steps in Allow users to Sign In using their username or password.

Tell Devise to use :login in the reset_password_keys

  1. Modify config/initializers/devise.rb to have:
    config.reset_password_keys = [ :login ]
    

Overwrite Devise’s find_for_database_authentication method in Users

  • For ActiveRecord:
    protected
    
     # Attempt to find a user by it's email. If a record is found, send new
     # password instructions to it. If not user is found, returns a new user
     # with an email not found error.
     def self.send_reset_password_instructions(attributes={})
       recoverable = find_recoverable_or_initialize_with_errors(reset_password_keys, attributes, :not_found)
       recoverable.send_reset_password_instructions if recoverable.persisted?
       recoverable
     end 
    
     def self.find_recoverable_or_initialize_with_errors(required_attributes, attributes, error=:invalid)
       (case_insensitive_keys || []).each { |k| attributes[k].try(:downcase!) }
    
       attributes = attributes.slice(*required_attributes)
       attributes.delete_if { |key, value| value.blank? }
    
       if attributes.size == required_attributes.size
         if attributes.has_key?(:login)
            login = attributes.delete(:login)
            record = find_record(login)
         else  
           record = where(attributes).first
         end  
       end  
    
       unless record
         record = new
    
         required_attributes.each do |key|
           value = attributes[key]
           record.send("#{key}=", value)
           record.errors.add(key, value.present? ? error : :blank)
         end  
       end  
       record
     end
    
     def self.find_record(login)
       where(["username = :value OR email = :value", { :value => login }]).first
     end
    
  • For Mongoid:
def self.find_record(login)
  found = where(:username => login).to_a
  found = where(:email => login).to_a if found.empty?
  found
end

For Mongoid this can be optimized using a custom javascript function
def self.find_record(login)
  where("function() {return this.username == '#{login}' || this.email == '#{login}'}")
end  
I’m sure this approach also works for Mongo Mapper or any other mapper for Mongo DB ;)

Update your views

  1. Modify the views
    • passwords/new.html.erb:
      -  <p><%= f.label :email %><br />
      -  <%= f.email_field :email %></p>
      +  <p><%= f.label :login %><br />
      +  <%= f.text_field :login %></p>
      

Gmail or me.com Style

Another way to do this is me.com and gmail style. You allow an email or the username of the email. For public facing accounts, this has more security. Rather than allow some hacker to enter a username and then just guess the password, they would have no clue what the user’s email is. Just to make it easier on the user for logging in, allow a short form of their email to be used e.g “someone@domain.com” or just “someone” for short.
before_create :create_login

  def create_login             
    email = self.email.split(/@/)
    login_taken = User.where( :login => email[0]).first
    unless login_taken
      self.login = email[0]
    else 
      self.login = self.email
    end        
  end

  def self.find_for_database_authentication(conditions)
    self.where(:login => conditions[:email]).first || self.where(:email => conditions[:email]).first
  end
For the Rails 2 version (1.0 tree): There is no find_for_database_authentication method, so use self.find_for_authentication as the finding method.
def self.find_for_authentication(conditions)
  conditions = ["username = ? or email = ?", conditions[authentication_keys.first], conditions[authentication_keys.first]]
  super
end