Skip to content

How To: Allow users to sign in using their username or email address

Sonia Prévost edited this page May 25, 2016 · 70 revisions

Allow users to Sign In using their username or email address

For this example, we will assume your model is called User.

Create a username field in the users table

Create a migration:

rails generate migration add_username_to_users username:string:uniq

Run the migration:

rake db:migrate

Strong parameters

Modify application_controller.rb and add username, email, password, password confirmation and remember me to configure_permitted_parameters

class ApplicationController < ActionController::Base
  before_action :configure_permitted_parameters, if: :devise_controller?

  protected

  def configure_permitted_parameters
    added_attrs = [:username, :email, :password, :password_confirmation, :remember_me]
    devise_parameter_sanitizer.permit :sign_up, keys: added_attrs
    devise_parameter_sanitizer.permit :account_update, keys: added_attrs
  end
end

see also "strong parameters"

Create a login virtual attribute in the User model

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

or, if you will use this variable somewhere else in the code:

  def login=(login)
    @login = login
  end

  def login
    @login || self.username || self.email
  end

Tell Devise to use :login in the authentication_keys

Modify config/initializers/devise.rb to have:

config.authentication_keys = [ :login ]

If you are using multiple models with Devise, it is best to set the authentication_keys on the model itself if the keys may differ:

  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :trackable, 
         :validatable, :authentication_keys => [:login]

If you need permissions, you should implement that in a before filter. You can also supply a hash where the value is a boolean determining whether or not authentication should be aborted when the value is not present.

  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :trackable, 
         :validatable, :authentication_keys => {email: true, login: false}

Overwrite Devise's find_for_database_authentication method in User model

Because we want to change the behavior of the login action, we have to overwrite the find_for_database_authentication method. The method's stack works like this: find_for_database_authentication calls find_for_authentication which calls find_first_by_auth_conditions. Overriding the find_for_database_authentication method allows you to edit database authentication; overriding find_for_authentication allows you to redefine authentication at a specific point (such as token, LDAP or database). Finally, if you override the find_first_by_auth_conditions method, you can customize finder methods (such as authentication, account unlocking or password recovery).

For ActiveRecord:

MySQL users: the use of the SQL lower function below is most likely unnecessary and will cause any index on the email column to be ignored.

# app/models/user.rb

    def self.find_for_database_authentication(warden_conditions)
      conditions = warden_conditions.dup
      if login = conditions.delete(:login)
        where(conditions.to_h).where(["lower(username) = :value OR lower(email) = :value", { :value => login.downcase }]).first
      elsif conditions.has_key?(:username) || conditions.has_key?(:email)
        where(conditions.to_h).first
      end
    end

If Rails4: use to_hash instead; or the to_h would be empty hash because by default no permitted params are set in passwords_controller.rb.

And if you want email to be case insensitive, you should add

conditions[:email].downcase! if conditions[:email]
where(conditions.to_h).first

Be sure to add case insensitivity to your validations on :username:

# app/models/user.rb

validates :username,
  :presence => true,
  :uniqueness => {
    :case_sensitive => false
  } # etc.

Alternatively, change the find conditions like so:

# when allowing distinct User records with, e.g., "username" and "UserName"...
where(conditions).where(["username = :value OR lower(email) = lower(:value)", { :value => login }]).first

However, we need to be very careful with username validation, because there might be conflict between username and email. For example, given these users:

id username email
1 harrypotter [email protected]
2 [email protected] [email protected]

The problem is at user #2, he is using #1's email as his username. What would happen if #1 try to log in by his email [email protected]? With the above query:

where(conditions).where(["username = :value OR lower(email) = lower(:value)", { :value => login }]).first

User #1 (harrypotter) will never be able to log in to the system, because that query always return #2.

To fix it, we can add one more validation to check format of username to not allow @ character:

# app/models/user.rb

# Only allow letter, number, underscore and punctuation.
validates_format_of :username, with: /^[a-zA-Z0-9_\.]*$/, :multiline => true

Or, even more secure with this validation:

# app/models/user.rb

validate :validate_username

def validate_username
  if User.where(email: username).exists?
    errors.add(:username, :invalid)
  end
end

For Mongoid:

Note: This code for Mongoid does some small things differently than 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

  def self.find_first_by_auth_conditions(warden_conditions)
    conditions = warden_conditions.dup
    if login = conditions.delete(:login)
      self.any_of({ :username =>  /^#{Regexp.escape(login)}$/i }, { :email =>  /^#{Regexp.escape(login)}$/i }).first
    else
      super
    end
  end

The code below also supports Mongoid but uses the where method and the OR operator to choose between username and email.

  # function to handle user's login via email or username
  def self.find_for_database_authentication(warden_conditions)
    conditions = warden_conditions.dup
    if login = conditions.delete(:login).downcase
      where(conditions).where('$or' => [ {:username => /^#{Regexp.escape(login)}$/i}, {:email => /^#{Regexp.escape(login)}$/i} ]).first
    else
      where(conditions).first
    end
  end 

Update your views

Make sure you have the Devise views in your project so that you can customize them

rails g devise:views

or

rails g devise:views users

in case you have multiple models and you want customized views.

Simply modify config/initializers/devise.rb file to have

config.scoped_views = true

Rails 2:

script/generate devise_views

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>

Newer versions have different HTML.

Allow users to recover their password or confirm their account using their username

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

Configure Devise to use username as reset password or confirmation keys

Simply modify config/initializers/devise.rb to have:

config.reset_password_keys = [ :username ]
config.confirmation_keys = [ :username ]

Use find_first_by_auth_conditions instead of find_for_database_authentication

Replace (in your User.rb):

def self.find_for_database_authentication(warden_conditions)
  conditions = warden_conditions.dup
  if login = conditions.delete(:login)
    where(conditions).where(["lower(username) = :value OR lower(email) = :value", { :value => login.downcase }]).first
  else
    where(conditions).first
  end
end

with:

def self.find_first_by_auth_conditions(warden_conditions)
  conditions = warden_conditions.dup
  if login = conditions.delete(:login)
    where(conditions).where(["lower(username) = :value OR lower(email) = :value", { :value => login.downcase }]).first
  else
    if conditions[:username].nil?
      where(conditions).first
    else
      where(username: conditions[:username]).first
    end
  end
end

Update your views

passwords/new.html.erb:

  -  <p><%= f.label :email %><br />
  -  <%= f.email_field :email %></p>
  +  <p><%= f.label :username %><br />
  +  <%= f.text_field :username %></p>

confirmations/new.html.erb:

  -  <p><%= f.label :email %><br />
  -  <%= f.email_field :email %></p>
  +  <p><%= f.label :username %><br />
  +  <%= f.text_field :username %></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 "[email protected]" or just "someone" for short.

  after_initialize :create_login, :if => :new_record?

  def create_login
    if self.username.blank?
      email = self.email.split(/@/)
      login_taken = Pro.where(:username => email[0]).first
      unless login_taken
        self.username = email[0]
      else    
        self.username = self.email
      end   
    end     
  end

  # You might want to use the self.find_first_by_auth_conditions(warden_conditions) above instead
  # instead of using this find_for_database_authentication as this one causes problems.
  # def self.find_for_database_authentication(conditions)
  #   self.where(:yusername => conditions[:email]).first || self.where(:email => conditions[:email]).first
  # end
Clone this wiki locally