heartcombo / devise

Flexible authentication solution for Rails with Warden.
http://blog.plataformatec.com.br/tag/devise/
MIT License
24k stars 5.55k forks source link

Validatable should allow email uniqueness to be scoped #4767

Open kylefox opened 6 years ago

kylefox commented 6 years ago

A common scenario is to scope email to a parent model (i.e. Account), but the Validatable module does not easily allow this.

Judging by the posts on StackOverflow, many people struggle with this issue. It would be a Good Thing if Devise could support this common functionality out of the box.

The How to: Scope login to subdomain article recommends completely removing :validatable from the model. This results in all email and password validations being removed β€” seems like throwing the baby out with the bathwater.

The wiki goes on to recommend that if you want to keep any of the validations, you should cherry-pick the ones you want β€” presumably by copy/pasting portions of validatable.rb. This approach seems error-prone, unmaintainable, and goes against the "flexible" philosophy in Devise's tagline πŸ™‚

I propose validatable support a new option called email_scope that allows an :email uniqueness scope to be specified. For example:

class User < ApplicationRecord
  belongs_to :account

  devise :database_authenticatable, :validatable, email_scope: :account
end

(The caveats about indexes would still apply. I don't think it's necessary to change how Devise's migrations work β€” Rails already makes these schema changes trivial, after all).

I'm happy to help out with this feature if there's general agreement that this functionality should be included, and totally open to discussing other ways this scenario could be better addressed by Devise. Thanks!

argent-smith commented 6 years ago

πŸ‘ … makes real sense in case of Β«soft-deletedΒ» authenticatables as well.

one-more-alex commented 6 years ago

+1 to having scope option.

While have no scope I am doing some terrible recreation of validator:

# redefine uniqueness validator to confirm soft deletion
  self._validators[:email].reject!{|v| v.class == ActiveRecord::Validations::UniquenessValidator}
  self._validate_callbacks.delete self._validate_callbacks.find{|v| v.filter.class == ActiveRecord::Validations::UniquenessValidator && v.filter.attributes == [:email]}
  validates_uniqueness_of :email, allow_blank: true, if: :will_save_change_to_email?, scope: :deleted_at

Probably somebody knows proper way ;)... But sure the Devise code change (not monkey patch or substitution I did) has priority.

one-more-alex commented 6 years ago

I even made commit request for this: #4793 Welcome to merge ;) But probably some test addition maybe required to be clear and a number of Wiki pages correction (subdomain auth, auth bu login or email, etc).

tegon commented 6 years ago

Hi everyone, thanks for the feedback.

Although I can see the value of this, I'm not sure it's a good idea to add a configuration option to support it. If we merge #4793, with the email_scope option, other people may want to pass other options to the email validator. For example, conditions and case_sensitive can be useful sometimes. I feel like we might be rewriting the Rails validations API, which is not good.

The validatable module is meant to include some useful defaults for validations, but I don't see a way it can be flexible enough to be extended for multiple contexts. That's why the application has different requirements for validations, we ask to remove the module and implement them by hand - the module isn't that complicated, and is something that isn't likely to change that often.

kylefox commented 6 years ago

If we merge #4793, with the email_scope option, other people may want to pass other options to the email validator

With all due respect, I think you might be worrying about a problem you don't have πŸ˜‡ Can you defer deciding whether (or not) to support options for the other validators until someone actually asks for it? It seems to me like customizing case_sensitive would be much less common than wanting to scope email uniqueness (a very common use-case in multi-tenant apps, which is where Devise shines πŸ‘Œ).

(BTW β€” validatable allows an email_regexp option, which seems even more edge-case than scoping emails)

Devise supports multiple User models, making it great for multi-tenant apps β€” expect for the lack of a scope on the uniqueness constraint. Devise's own documentation even has an icky workaround for this specific use-case, which IMO is a pretty good indicator of missing functionality β€” if you're scoping logins to subdomains, when wouldn't you want to also scope email uniqueness?

The validatable module is meant to include some useful defaults for validations, but I don't see a way it can be flexible enough to be extended for multiple contexts.

Scoping emails by some "container" seems like a very reasonable use-case to support. I think it's fine to support this (extremely common) scenario without having to support customization of the other validators. It seems overly troublesome to toss out the entire validatable module just to make multi-tenancy work.

the module isn't that complicated

This is true, but the module contains validation besides just email. For example, it seems needlessly error-prone to ask developers to re-implement (or copy/paste) their own password validation simply because they want to scope emails by :account. Because I need to scope emails, I will now miss out on any improvements (or security fixes) related to how passwords are validated. IMO it should be extremely difficult to opt-out of Devise's default password validation β€” it shouldn't happen as a side effect of having to scope emails.

Anyway, that's just my 2Β’ πŸ™‚ Thanks again for the discussion.

one-more-alex commented 6 years ago

When I made the PR I thought about passing not only email_scope. Just was not sure if it will be acceptable. It may be like: validates_uniqueness_of :email, {allow_blank: true, if: :will_save_change_to_email?}.merge(other_options_scope_case_etc)

I do not see a need to touch Rails validators code here... If you about some non-common things of validating, then developers still free to substitute the module.

I think people will mostly afraid to substitute the Validatable module. Who knows what will be in future when some updates may broke the authentication ). Just words "authentication", "authorization" and "validations" have close meaning, as for me. Not experienced and newbie developers will fill safer if they will just pass some options instead of support the validation in whole.

Probably you may have two choices for developers: pass some extra options or substitute module in whole. But can't insist here.

tegon commented 6 years ago

@kylefox

With all due respect, I think you might be worrying about a problem you don't have πŸ˜‡ Can you defer deciding whether (or not) to support options for the other validators until someone actually asks for it?

I agree, and I think this is valuable especially for projects. But for libraries, we have to think long-term, because we have to support any API we introduce for future versions. Of course, other use cases seems less common but are still valid (like a regular expression for passwords). But it seems like we already went in this direction - I didn't remember the email_regexp, thanks for pointing that out, btw - so I guess it's ok to continue with #4793.

Devise supports multiple User models, making it great for multi-tenant apps β€” expect for the lack of a scope on the uniqueness constraint. Devise's own documentation even has an icky workaround for this specific use-case, which IMO is a pretty good indicator of missing functionality β€” if you're scoping logins to subdomains, when wouldn't you want to also scope email uniqueness?

The Wiki is maintained by the community, which means someone needed this behavior and created this article, which may be useful for others. I agree with you that this may a signal of a missing functionality. But I have a question: are there advantages of using the same table for both models? I mean, do they share behavior and/or data except for the devise part? I ask this because at least in the applications I've worked on the users where completely distinct (e.g customers and managers).

This is true, but the module contains validation besides just email. For example, it seems needlessly error-prone to ask developers to re-implement (or copy/paste) their own password validation simply because they want to scope emails by :account. Because I need to scope emails, I will now miss out on any improvements (or security fixes) related to how passwords are validated. IMO it should be extremely difficult to opt-out of Devise's default password validation β€” it shouldn't happen as a side effect of having to scope emails.

Like I said, this is something that's very unlikely to change. We only have a length validator for password, and if we do add extra validations at some point, it's probably going to break backward compatibility, so we'd need an upgrade guide or something to help in the update either way.

Thanks for the discussion, a lot of good points were raised here. I'll continue to review @one-more-alex PR, but the index part still bothers me a lot. Since we're going to officially support this, I think we should help users with this in some way. Maybe some comments in the migration template might be enough, but I'm not sure. If you have suggestions, I'd love to hear them.

kylefox commented 6 years ago

@tegon Thanks again for the thoughtful reply.

But for libraries, we have to think long-term, because we have to support any API we introduce for future versions.

Totally understand & appreciate that πŸ‘

The Wiki is maintained by the community, which means someone needed this behavior and created this article

Good point, I hadn't considered this. I guess a wiki article doesn't necessarily mean it's "officially supported" functionality. Maybe I'll take a crack at updating this specific article.

are there advantages of using the same table for both models?

Sorry, I should clarify β€”Β I'm definitely not using the same table for different Devise users.

An online store like Shopify would be an example of what I'm doing. Here's a simplified example. Note the validates_uniqueness_of configuration:

class Shop < ApplicationRecord
  has_many :products
  has_many :customers
end

class Customer < ApplicationRecord
  belongs_to :shop

  devise :database_authenticatable, :registerable # ...etc...

  # This is the heart of the issue.
  # It's possible for "me@example.com" to have many unrelated Customer records.
  # me@example.com at Shop A, me@example.com at Shop B, etc.
  # So the unique constraint is [:email, :shop_id] rather than just :email
  validates_uniqueness_of :email, scope: :shop
end

Another example would be Slack β€” I can use the same email to login to two different teams using two different passwords. Hope that clarifies.

but the index part still bothers me a lot

Hmm, I hadn't considered the indexes β€” does that PR make any changes to the index? πŸ€”

I don't think the migration template should be changed. It should stay like this IMO:

add_index :users, :email, unique: true

It's easy enough to change in the migration, if needed. And worst-case scenario it can be changed to a composite index manually in a later migration. This could all be explained in any documentation that describes how to use the email_scope option.

tegon commented 6 years ago

@kylefox thanks for clarifying the use case for this. It makes a lot more sense now πŸ‘

I agree with the index part, the best we can do is to document how the migration should be changed if the email_scope option is defined.

hudakh commented 6 years ago

I don't see any recent updates on this :( It would be very useful for us!

tegon commented 5 years ago

@hudakh The opened PR needs tests and some other changes before it can be merged.

olbrich commented 5 years ago

FYI, https://github.com/devise-security/devise-security will scope emails by authentication keys.

theshashiverma commented 5 years ago

@kylefox @argent-smith @one-more-alex @tegon @hudakh please have a look on this https://github.com/plataformatec/devise/issues/5017

xhocquet commented 5 years ago

Hey @tegon . My coworker @flynfish had a need for this patch and opened a PR with tests. Would love to see it reviewed and this issue close out: #5094 Cheers!

tegon commented 5 years ago

@xhocquet @flynfish thanks, I've added it to my list and will review it when I can. Sorry for the delay.

Inkybro commented 5 years ago

Just chiming in to say that I'd find this feature incredibly useful as well :) Thanks everyone!

xhocquet commented 4 years ago

@tegon Do you have an ETA when this can be reviewed and https://github.com/plataformatec/devise/pull/5094 merged?

I would not encourage others to use public forks like above. Devise is heavily maintained and by using forked repos you open yourself up to security vulnerabilities over time.

tegon commented 4 years ago

@xhocquet Unfortunately, I don't have an ETA. I'm struggling to find the time to work on OSS lately, but since a lot of you are asking for this feature I'll try to review it this week.

xhocquet commented 4 years ago

@tegon I know quite a few of us would greatly appreciate that! Thanks for your time, I understand the struggle πŸ˜„

Since this gem is so crucial to the Ruby ecosystem, it may also be wise to consider bringing on some additional maintainers to spread out the load. I don't think you would have a problem finding willing contributors considering how foundational devise is for many Rails applications. Thanks again!

DavidGeismarLtd commented 3 years ago

Any update on this ? This feature would be super usefule

carlosantoniodasilva commented 3 years ago

@DavidGeismarLtd there's an open PR but this is the gist of it at the moment: https://github.com/heartcombo/devise/pull/5094#issuecomment-754927228

barkiiqbal commented 2 years ago

Any Update on this

artur79 commented 1 year ago

also interested if any update planned ?

SteveOscar commented 7 months ago

Still hoping this is happening in 2024

nickpoorman commented 3 months ago

I could use this as well!

fernandollisboa commented 2 months ago

+1! πŸ‘πŸ»

nickpoorman commented 2 months ago

Going to put this here for anyone who is trying to achieve something similar. I don't think the subdomain tutorial has everything. My goal was to achieve something similar to the use case @kylefox mentioned.

Slack β€” I can use the same email to login to two different teams using two different passwords

In addition, I wanted to use this inside an engine.

To accomplish this, I injected tenant into the params where necessary by extending the devise controllers. Then in the User model I copied over the validatable module code directly into it.

# engines/authentication/app/models/authentication/user.rb

module Authentication
  class User < ApplicationRecord
    # Our configured devise modules.
    # Include default devise modules. Others available are:
    # :timeoutable
    devise :confirmable,
           :database_authenticatable,
           :lockable,
           # :omniauthable,
           :recoverable,
           :registerable,
           :rememberable,
           :trackable

    # Each user belongs to a given tenant. The single table maintains users for many
    # tenants, and this allows it to segment users by tenant.
    validates :tenant, presence: true
    validates :email, uniqueness: { scope: :tenant }

    # We have to add the validatable module validations since we cannot use it
    # directly.

    # Email validations
    validates :email, presence: true, if: :email_required?
    validates :email,
              uniqueness: {
                scope: :tenant,
                case_sensitive: false,
              },
              if: :devise_will_save_change_to_email?
    validates :email,
              format: {
                with: Devise.email_regexp,
              },
              if: :devise_will_save_change_to_email?,
              allow_blank: true

    # Password validations
    validates :password, presence: true, if: :password_required?
    validates :password, confirmation: true, if: :password_required?
    validates :password,
              length: {
                in: Devise.password_length,
              },
              allow_blank: true

    # Tenant validation
    validates :tenant, presence: true

    # Override Devise auth finder hook
    #
    # For Authenticatable, Devise uses the hook method
    # Model.find_for_authentication. Override it to include your additional
    # query parameters:
    def self.find_for_authentication(warden_conditions)
      where(email: warden_conditions[:email], tenant: tenant).first
    end

    def self.find_for_database_authentication(warden_conditions)
      where(
        email: warden_conditions[:email],
        tenant: warden_conditions[:tenant],
      ).first
    end

    # By default devise will query the user by email only. This won't work, if
    # you have a user registered with same email for different subdomains.
    #
    # Overwrite Devise send reset password instructions
    def self.send_reset_password_instructions(attributes = {})
      # define extra condition to select the right user
      recoverable =
        where(email: attributes[:email], tenant: attributes[:tenant])

      # just copied from Devise::Models::Recoverable#send_reset_password_instructions
      recoverable =
        recoverable.find_or_initialize_with_errors(
          reset_password_keys,
          attributes,
          :not_found,
        )
      recoverable.send_reset_password_instructions if recoverable.persisted?
      recoverable
    end

    protected

    def password_required?
      !persisted? || !password.nil? || !password_confirmation.nil?
    end

    def email_required?
      true
    end

    private

    def devise_will_save_change_to_email?
      will_save_change_to_email?
    end
  end
end