0

I was able to allow users to sign-in with either their username or email by implementing this bit of code in my model concerns.

module ClassMethods
    def authenticate(username, email, password)
      user = User.find_by(username: username)
      user_email = User.find_by(email: email)
      return unless user || user_email

      if user
        user.send :new_token
        user.authenticate password
      elsif user_email
        user_email.send :new_token
        user_email.authenticate password
      end
    end
  end

  included do
    has_secure_password
    before_create :set_token
    after_find :fix_up_token
    validates :email, uniqueness: true
    validates :email, presence: true
    validates :username, uniqueness: true
    validates :username, presence: true
    validates :password_confirmation, presence: true, on: :create
  end

I originally was trying to create a conditional for 'user' with the find_by method but I didn't have any luck. I ended up with the idea of creating 'user_email' as a work around and it works, but I feel like there has to be a way to do this with one variable.

Update

I was able to get it working with two of the answers below.

This one here from max:

def authenticate(username, email, password)
      user = User.where(email: email)
                 .or(User.where(username: username)).take
      return unless user

      user.send :new_token
      user.authenticate password
    end

And this one here from Sajjad Umar:

def authenticate(username, email, password)
      user = User.find_by(username: username) || User.find_by(email: email)
      return if user.blank?

      user.send :new_token
      user.authenticate password
    end

Update 2

Sean recommended using a regex to scan the input for email format. I have the requirement for email format covered already, but I did want to make sure that a user can not create a username that matches an email of a different user. So, I placed format validations on the username using regex.

included do
    has_secure_password
    before_create :set_token
    after_find :fix_up_token
    validates :email, uniqueness: true
    validates :email, presence: true
    validates :username, uniqueness: true
    validates :username, presence: true
    validates :username, format: { with: /\A[a-zA-Z0-9]+\z/,
                                   message: 'only allows letters and numbers' }
    validates :password_confirmation, presence: true, on: :create
  end

Thanks to everyone for helping me out!

GMorse19
  • 61
  • 1
  • 8

3 Answers3

3

Just use where to create a query with a or clause:

def authenticate(identifier, password)
  user = User.where(email: identifier)
             .or(User.where(username: identifier)).take
  return unless user
  user.new_token 
  user.authenticate password
end
max
  • 96,212
  • 14
  • 104
  • 165
  • Thanks for this answer. I tried this solution out, but ran into trouble with the arguments. It definitely looks like it should work, I just need to dig in a little deeper and figure out how to replace 'email' and 'username' with 'identifier' in the controller... I think. – GMorse19 Apr 17 '20 at 16:22
  • 1
    Well you could change the signature back and add`identifier = username || email` to the top of the method. I just thought the signature was kind of silly since you only need one parameter. – max Apr 17 '20 at 16:31
  • Thanks again. This is definitely a failure on my part, not on your answer. I fully expect this will work and I plan to try it out. – GMorse19 Apr 17 '20 at 18:44
3

You can simplify the authenticate method as follows

def authenticate(username, email, password)
   user = User.find_by(username: username) || User.find_by(email: email)
   return if user.blank?

   user.send :new_token
   user.authenticate password
end

This has two benefits over your code

  1. If User is found with username, no query will be made to find a user based on the email address
  2. No code repetition

And I don't recommend using the where clause because it will search the whole table and return a collection. Whereas the find_by method will return the first matching object.

Sajjad Umar
  • 131
  • 8
  • Thank you! This works perfectly. I originally tried something similar, but I was missing the `.blank?` part. – GMorse19 Apr 17 '20 at 14:41
  • 1
    "And I don't recommend using the where clause because it will search the whole table and return a collection. Whereas the find_by method will return the first matching object." This is nonsense. When you use where with `take` or `first` it adds a LIMIT clause to the query which is exactly how `.find_by` limits the fetched records. – max Apr 17 '20 at 15:31
  • Thanks, @max that adds to my knowledge. – Sajjad Umar Apr 17 '20 at 20:16
1

Assuming that your usernames cannot be formatted like emails, I would recommend using a regex to scan the input for email format, and following the "input is email" logic if so, otherwise follow the "input is username".

Example taken from this SO answer:

def authenticate(username_or_email, password)
  user = get_user(username_or_email)

  return if user.nil?

  user.send :new_token
  user.authenticate password
end

def get_user(username_or_email)
  if is_email?(username_or_email)
    User.find_by(email: username_or_email)
  else
    User.find_by(username: username_or_email)
  end
end

def is_email?(string)
  email_regex = /\A(\S+)@(.+)\.(\S+)\z/

  return true if string ~= email_regex

  false
end
Sean
  • 11
  • 2
  • Awesome. Thank you. I was planning on figuring this out next. I have some restrictions on the client, but I assume it's better practice to have the restrictions and requirements on the back end. – GMorse19 Apr 17 '20 at 13:45
  • Edited to place in context of the authenticate method you had in your question. Also @max not sure what you mean there. – Sean Apr 17 '20 at 17:46
  • https://softwareengineering.stackexchange.com/questions/223634/what-is-meant-by-now-you-have-two-problems. – max Apr 17 '20 at 17:49