-
Notifications
You must be signed in to change notification settings - Fork 5.5k
How To: Allow users to sign in using their username or email address
For this example, we will assume your model is called User
.
rails generate migration add_username_to_users username:string:uniq
rake db:migrate
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 :sign_in, keys: [:login, :password]
devise_parameter_sanitizer.permit :account_update, keys: added_attrs
end
end
see also "strong parameters"
Add login
as an User
:
# app/models/user.rb
attr_writer :login
def login
@login || self.username || self.email
end
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}
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).
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 }
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 | |
---|---|---|
1 | harrypotter | harry_potter@gmail.com |
2 | harry_potter@gmail.com | real_email@gmail.com |
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 harry_potter@gmail.com
? 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
You could also check if the same email as the username already exists in the database:
# app/models/user.rb
validate :validate_username
def validate_username
if User.where(email: username).exists?
errors.add(:username, :invalid)
end
end
However, note that relying ONLY on this second validation still allows a user to choose someone else's valid email address as a username, as long as that email is not yet associated with an account. This should therefore be used only as a backup validation.
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
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
sessions/new.html.erb
:
- <p><%= f.label :email %><br />
- <%= f.email_field :email %></p>
+ <p><%= f.label :login %><br />
+ <%= f.text_field :login %></p>
Use :login and not :username in the sessions/new.html.erb
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.
This section assumes you have run through the steps in Allow users to Sign In using their username or email.
Simply modify config/initializers/devise.rb
to have:
config.reset_password_keys = [ :username ]
config.confirmation_keys = [ :username ]
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
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>
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.
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 of using this find_for_database_authentication as this one causes problems.
# def self.find_for_database_authentication(conditions)
# where(:username => conditions[:email]).first || where(:email => conditions[:email]).first
# end