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

Allen edited this page Jul 24, 2017 · 13 revisions
Clone this wiki locally

Allow users to Sign In using their username or email address

For this example, we will assume your model is called Users

Create a username field in Users

  1. Create a migration:
rails generate migration add_username_to_users username:string
  1. Run the migration:
rake db:migrate

Create a login virtual attribute in Users

  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
  1. Add login and username in app/controllers/application_controller.rb
before_action :configure_permitted_parameters, if: :devise_controller?
protected
def configure_permitted_parameters
 devise_parameter_sanitizer.permit(:sign_up, keys: [:username, :email, :password, :password_confirmation])
 devise_parameter_sanitizer.permit(:sign_in, keys: [:login, :password, :password_confirmation])
 devise_parameter_sanitizer.permit(:account_update, keys: [:username, :email, :password, :password_confirmation, :current_password])
end

Tell Devise to use :login in the authentication_keys

  1. Modify config/initializers/devise.rb to have:
config.authentication_keys = [:login]
  1. Overwrite Devise’s find_for_database_authentication method in Users
  • For ActiveRecord:
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.strip.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

def self.find_for_database_authentication conditions
  login = conditions.delete(:login)
  any_of({username: login}, email: login).first
end
  • For MongoMapper:
def self.find_for_database_authentication conditions
  login = conditions.delete(:login).downcase
  where('$or' => [{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 g devise:views
  1. 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:
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

config.reset_password_keys = [:login]

Overwrite Devise’s finder methods 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.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
  • For MongoMapper:
def self.find_record login
  (where(email: login[:login]).first || where(username: login[:login]).first)
rescue
  nil
end

Update your views

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
  self.login = if login_taken
                 self.email
               else
                 email[0]
               end
end

def self.find_for_database_authentication conditions
  where(login: conditions[:email]).first || where(email: conditions[:email]).first
end