using a user_name for authentication

Showing 1-11 of 11 messages
using a user_name for authentication Eric Harris-Braun 12/21/10 9:07 AM
Hi folks,

I have an app which I'd like to convert to devise but it uses
user_names not e-mails as the authentication key, and it also allows
different users_names to have the same email.  This means, for
example, that when you ask for a password-reset by e-mail, the message
that is sent has to allow you to choose to reset between the different
accounts that all have the same e-mail.

I know that devise is supposed to allow you to choose
authentication_key (though I note that even though you set this value,
the view generator produces a session#new view with e-mail as the
authentication field).  And it does work for log-in if you manually
change the field parameter to user_name.

However I've tested the reset-password option and it simply sends you
an e-mail for the first user it finds with the given e-mail.

Has anyone worked on a solution to this?

Thanks,

-Eric
Re: using a user_name for authentication Eric Harris-Braun 1/3/11 12:00 PM
I've now implemented a version of recoverable that can handle user models with user_names as the authentication key, such that e-mails aren't unique accross user records.  I.e. it sends a password reset message with a link for each of the accounts a user has with the given e-mail.  Implementing this was a bit tricky because a number of chunks of code in Devise make the assumption that e-mail is unique.  If anybody else is interested in this, holler and I can work up a patch to submit.
Re: [devise] Re: using a user_name for authentication Garrett Lancaster 1/3/11 12:21 PM
I am starting this exact task at the moment, and would greatly appreciate any insight / patches you could provide.
Re: [devise] Re: using a user_name for authentication Eric Harris-Braun 1/3/11 12:40 PM
FYI I'm using 1.0.9, so if you are on the 3.0 branch things might be a little different.  But...

First I changed  Devise::Models:Recoverable:ClassMedhods#send_reset_password_instructions

To:

        def send_reset_password_instructions(attributes={})
          recoverables = find_or_initialize_with_error_by(:email, attributes[:email], :not_found,true)
          unless recoverables[0].new_record?
            recoverables.each {|r| r.prepare_to_reset_password}
            ::DeviseMailer.deliver_reset_password_instructions(recoverables)
          end
          recoverables
        end

Then I modified Devise::Models:Recoverable#send_reset_password_instructions to:

      # Resets reset password token and send reset password instructions by email
      def send_reset_password_instructions
        prepare_to_reset_password
        ::DeviseMailer.deliver_reset_password_instructions(self)
      end

And added Devise::Models:Recoverable#prepare_to_reset_password: 

      # Prepares for password reset
      def prepare_to_reset_password
        generate_reset_password_token!
      end

Then I edited: DeviseMailer#setup_mail to:

    def setup_mail(records, key)
      if records.is_a?(Array) 
        record = records[0]
      else
        record = records
      end

      scope_name = Devise::Mapping.find_scope!(record)
      mapping    = Devise.mappings[scope_name]

      subject      translate(mapping, key)
      from         mailer_sender(mapping)
      recipients   record.email
      sent_on      Time.now
      content_type Devise.mailer_content_type
      body         render_with_scope(key, mapping, mapping.name => record, :resource => records)
    end

Devise::Models#find_or_initialize_with_error_by to: 

    # Find an initialize a record setting an error if it can't be found.
    def find_or_initialize_with_error_by(attribute, value, error=:invalid,many = false)
      if value.present?
        conditions = { attribute => value }
        records = find(:all, :conditions => conditions)
      else
        records = []
      end
      
      if !records.empty?
        records = records[0] if !many
      else
        record = new

        if value.present?
          record.send(:"#{attribute}=", value)
        else
          error, skip_default = :blank, true
        end

        add_error_on(record, attribute, error, !skip_default)
        many ? records << record : records = record
      end

      records
    end

PasswordsController#create to:

  # POST /resource/password
  def create
    @resources = resource_class.send_reset_password_instructions(params[resource_name])

    self.resource = @resources.is_a?(Array) ? @resources[0] : @resources
    if resource.errors.empty?
      set_flash_message :notice, :send_instructions
      redirect_to new_session_path(resource_name)
    else
      render_with_scope :new
    end
  end

And finally the devise_mailer/reset_password_instructions.html.erb to:

<% if @resource.size == 1%>

<p>To reset your password, please click the link below (or copy and paste it into your browser):</p>

<%= link_to 'Change my password', edit_password_url(@resource[0], :reset_password_token => @resource[0].reset_password_token) %>

<% else %>
<p>Multiple accounts are associated with your e-mail address.</p>
<p>To reset the password for one of the accounts, click on the link for that account.</p>
<% for resource in @resource %>
<p><%= link_to 'Change my password for account: '+resource.user_name, edit_password_url(resource, :reset_password_token => resource.reset_password_token) %></p>
<% end %>
<% end %>

<p>If you didn't request this, please ignore this email.</p>
<p>Your password won't change until you access the link above and create a new one.</p>

Re: [devise] Re: using a user_name for authentication Carlos Antonio da Silva 1/3/11 2:22 PM
Just remember that these modules are being included in your model, so you don't need to change Devise, you can just override the methods in your (for instance) User model or wathever model you're using.--
At.
Carlos A. da Silva
Re: [devise] Re: using a user_name for authentication Eric Harris-Braun 1/3/11 3:11 PM
Hmm. Can you explain how I actually do this?  I tried simply to put:

def self.send_reset_password_instructions(attributes={})
 raise "test"
 ....
end

in my user model to override the class method send_reset_password_instructions, but that never gets called.

Also, how do I override DeviseMailer#setup_mail   I tried putting in:

class DeviseMailer
private

  # Configure default email options
  def setup_mail(records, key)
     ...
  end
end

into the devise initializer but that completely overwrites the class.

Thanks,
-e
Re: [devise] Re: using a user_name for authentication Carlos Antonio da Silva 1/3/11 3:29 PM
I believe it should work as it's pure Ruby inheritance. Someone already did sth similar by overriding some other Devise methods.

Now, about the DeviseMailer, maybe you should try doing it inside a config.to_prepare block.
--
At.
Carlos A. da Silva
Re: [devise] Re: using a user_name for authentication Eric Harris-Braun 1/3/11 5:57 PM
Wow.  It's really weird:

my overriding of self.find_or_initialize_with_error_by in my User model  (which is originally defined in lib/devise/models.rb)  does work and is getting called appropriately.  But overriding self.send_reset_password_instructions the same way doesn't work.  The original self.send_reset_password_instructions which is in lib/devise/models/recoverable.rb is what gets called instead!

After a bit of work I figured out how to do it.  So for anyone who is looking for how to do the override of ClassMethods defined in the various models, what you have to is put something like this in your User model file:

module MyDeviseClassMethods
  def send_reset_password_instructions(attributes={})
    puts "self.send_reset_password_instructions #{attributes[:email] } my"
    recoverables = find_or_initialize_with_error_by(:email, attributes[:email], :not_found,true)
    puts "recoverables size #{ recoverables.size } my"
#      raise recoverables.inspect
    unless recoverables[0].new_record?
      recoverables.each {|r| r.prepare_to_reset_password}
      ::DeviseMailer.deliver_reset_password_instructions(recoverables)
    end
    recoverables
  end
end

User.class_eval do
  extend MyDeviseClassMethods
end


Re: [devise] Re: using a user_name for authentication Eric Harris-Braun 1/3/11 5:57 PM
BTW Carlos, thanks so much for all you help and posting.  I really appreciate it.

-e
Re: [devise] Re: using a user_name for authentication Garrett Lancaster 1/3/11 6:00 PM
I've been trying to override the setup_email method.  I have tried including a module/reopening the class in both to_prepare and after_initialize blocks, but with no success.  Is there some reason to use to_prepare (which modifies the action dispatch) vs. after_initialize (which seems more appropriate to me)?  Any help would be appreciated.
Re: [devise] Re: using a user_name for authentication Eric Harris-Braun 1/3/11 7:53 PM
Hey,

I finally figured this one out with some help from:


You have to define a new mailer that inherits from the old.  So what I did was add app/models/my_devise_mailer.rb with:
class MyDeviseMailer < DeviseMailer
  private

    # Configure default email options
    def setup_mail(records, key)
      if records.is_a?(Array)
        record = records[0]
      else
        record = records
      end

      scope_name = Devise::Mapping.find_scope!(record)
      mapping    = Devise.mappings[scope_name]

      subject      translate(mapping, key)
      from         mailer_sender(mapping)
      recipients   record.email
      sent_on      Time.now
      content_type Devise.mailer_content_type
      body         render_with_scope(key, mapping, mapping.name => record, :resource => records)
    end
end

Then in the overriding of the calls to send_reset_password_instructions in my user model I referenced MyDeviseMailer instead of DeviseMailer.

That seems to have done the trick.

-e