-
Notifications
You must be signed in to change notification settings - Fork 21.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add has_secure_token to Active Record #18217
Conversation
ee740be
to
6fb899e
Compare
quoting @dhh from #16879 (comment):
I think this introduces a lot of magic in contrast of
I think it is good idea to follow that simplicity so usecase will be: class User < ActiveRecord::Base
# add token column in migration
has_secure_token
end
user = User.new
user.save
user.token # => "973acd04bc627d6a0e31200b74e2236"
user.regenerate_token! # => true If this this simple solution is not enough for you, you'll need to roll your own then. |
Allowing you to name the token being added or to have more than one does not qualify as "heavy configuration" in my book. I support both of those. What I wasn't interested in was configuration of the token itself. That should just work.
|
Although I do actually like the option of passing no options to has_secure_token and then having it default to a token name of "token".
|
Speaking of options. What justification are we using for key_length? Why would you want to change that number? Unless we have a very compelling reason, we should mix that.
|
I don't think multiple tokens is such a common use case that we need to do a And as it's apparently necessary, I'll join the existing chorus: this needs to be part of ActiveRecord, not ActiveModel. |
I’d be happy with it being one call per token. Don’t have a strong opinion on AR vs AM, but if others do, that’s fine.
|
I think this should be part of ActiveModel and just that part checking existing token should be part of ActiveRecord. It will be easy to integrate this into another ActiveModel based libraries like Mongoid. |
# Load securerandom only when has_secure_key is used. | ||
require 'securerandom' | ||
include InstanceMethodsOnActivation | ||
cattr_accessor :token_columns, :options |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the token_columns variable for?
Actually, I'd say that as it was mentioned elsewhere, that this SecureRandom implementation is unlikely enough to generate a duplicate, then we don't need the #exists? check. Someone could just implement that as a column constraint or similar. Then this thing becomes even simpler and doesn't require any dependency on AR at all. |
@dhh 👍 |
I boiled this down to its essence: module ActiveModel
module SecureToken
extend ActiveSupport::Concern
module ClassMethods
# Example using Active Record (which automatically includes ActiveModel::SecurePassword):
#
# # Schema: User(auth_token:string, invitation_token:string)
# class User < ActiveRecord::Base
# has_secure_token :auth_token
# has_secure_token :invitation_token
# end
#
# user = User.create
# user.auth_token # => "44539a6a59835a4ee9d7b112"
# user.invitation_token # => "226dd46af6be78953bde1648"
# user.regenerate_auth_token # => true
# user.regenerate_invitation_token # => true
#
# SecureRandom is used to generate the 24-character unique token, so collisions are highly unlikely.
# But you're still encouraged to enforce uniqueness, if that's required, by something like a unique index
# in the database or through a validation check.
def has_secure_token(attribute)
require 'securerandom'
define_method("regenerate_#{attribute}") { update! attribute => SecureRandom.hex(12) }
before_create { self[attribute] = SecureRandom.hex(12) }
end
end
end
end |
Sorry, that should be |
And I think it is better to call that regenerate method @dhh 👍 |
We already use the word |
Still on the fence about whether leaning exclusively on a unique index or the like to ensure no collisions is the right move here. The whole point is to make this as simple as possible. If it needs a db unique index to work, it loses a fair amount of that simplicity (even if the implantation obviously becomes much simpler). |
Here's another stab at a version that includes collision checking/mitigation: def has_secure_token(attribute)
require 'securerandom'
define_method("regenerate_#{attribute}") { update! attribute => self.class.generate_unique_secure_token(attribute) }
before_create { self[attribute] = self.class.generate_unique_secure_token(attribute) }
end
def generate_unique_secure_token(attribute)
(1..10).detect { SecureRandom.hex(12) unless exists?(attribute => random_token) } ||
raise "Couldn't generate a unique token in 10 attempts!"
end (All untested code that I just mocked out). |
I did a little experiment with On Windows there wasn't collision in 100_000_000 iterations with 24 key length. But it is still random and collision can happen anytime. 😞 |
Yeah, I think we’ll just deal with it as proposed in the second code example I shared. This should be plug’n’forget.
|
0236e04
to
d4b91c6
Compare
Guys I moved this to AR and keeping guideline of the second @dhh's code example |
random_token = SecureRandom.hex(12) | ||
return random_token unless exists?(attribute => random_token) | ||
end | ||
raise "Couldn't generate a unique token in 10 attempts!" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not sure if we should have a custom exception here wdyt?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
something like raise CollisionLimitReached, "Couldn't generate a unique token in 10 attempts!"
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't think we need a custom exception. This is extremely unlikely to happen, so not something anyone would custom catch anyway.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't like this return / raise flow, though. What was wrong with the detect approach?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The issue with detect is that this methods finds the element on an enumerable, so basically in most of the cases will returns 1 or 2 instead of the token needed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe another approach more clear would be
10.times do
if random_token = SecureRandom.hex(12) && exists?(attribute => random_token)
raise "Couldn't generate a unique token in 10 attempts!"
else
return random_token
end
end
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, right, here's a variation that might work:
10.times do |i|
SecureRandom.hex(12).tap do |token|
if exists?(attribute => token) && i == 9
raise "Couldn't generate a unique token in 10 attempts!"
else
return token
end
end
end
Don't love that the exceptional state is the first conditional, but ok.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This isn't right. This will raise an exception on the first collision. It's supposed to not raise an exception until the 10th time it still produces a collision.
On Dec 28, 2014, at 09:47, Roberto Miranda notifications@github.com wrote:
In activerecord/lib/active_record/secure_token.rb:
# user.save
# user.token # => "44539a6a59835a4ee9d7b112"
# user.regenerate_token # => true
def has_secure_token(attribute = :token)
# Load securerandom only when has_secure_key is used.
define_method("regenerate_#{attribute}") { update! attribute => self.class.generate_unique_secure_token(attribute) }
before_create { self.send("#{attribute}=", self.class.generate_unique_secure_token(attribute)) }
end
def generate_unique_secure_token(attribute)
require 'securerandom'
10.times do
random_token = SecureRandom.hex(12)
return random_token unless exists?(attribute => random_token)
end
Doneraise "Couldn't generate a unique token in 10 attempts!"
—
Reply to this email directly or view it on GitHub.
e6c2d75
to
a00f230
Compare
10.times do |i| | ||
SecureRandom.hex(12).tap do |token| | ||
if exists?(attribute => token) | ||
raise "Couldn't generate a unique token in 10 attempts!" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can raise on every iteration.
What about?
raise "Couldn't generate a unique token in 10 attempts!" if i == 9
6ad53c2
to
7296d96
Compare
|
||
def test_raise_and_exception_when_there_is_a_collision | ||
User.stubs(:exists?).returns(true) | ||
assert_raises(RuntimeError) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed the issue of each iterations that raise an exception, but not sure with change this test is failing, seems to be everything covered. I was debugging into the test case code and 10.times
only makes to iterations and is always returning 2 through the variable i
, really weird
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure I understand? We should have a test case that covers exists? returning true, say, three times, and then returns false to cover the retry action.
I think you can use User.expects(:exists?).times(3).returns(true); User.expects(:exists?).returns(false) or something like that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The real reason is described in https://github.com/rails/rails/pull/18217/files#r22300209.
end | ||
end | ||
|
||
test "assing unique token after 9 attemps reached" do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@dhh: We still need another test case for "9 collisions, then a success = win" and "10 collisions exactly = fail" to test that the main loop is functioning as it should.
done!
This is looking good to me now 👍. I say we still want to do the base62 upgrade, but we can do that after merging. |
@dhh great, once this PR be already merged I'll open another one with base62 upgrade 👍 |
Add has_secure_token to Active Record
There you go :) |
👏 |
🎉 |
# validates_presence_of can. You're encouraged to add a unique index in the database to deal with | ||
# this even more unlikely scenario. | ||
def has_secure_token(attribute = :token) | ||
# Load securerandom only when has_secure_key is used. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
s/key/token/
?
It seems like @hundredwatt's comment wasn't addressed here. It seems weird to me to call something a secure token if it's stored in the database as plaintext, when the equivalent for passwords, |
The security is in terms of its random generation. See SecureRandom.
|
module ClassMethods | ||
# Example using has_secure_token | ||
# | ||
# # Schema: User(toke:string, auth_token:string) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
missed 'n'
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed via #18537.
We don't need this gem anymore because the functionality has been added to Rails (by the author of the gem). rails/rails#18217 https://api.rubyonrails.org/classes/ActiveRecord/SecureToken/ClassMethods.html https://blog.bigbinary.com/2016/03/23/has-secure-token-to-generate-unique-random-token-in-rails-5.html Removing this gem will take care of the warning: already initialized constant SecureRandom::BASE58_ALPHABET Refs: #2430
We don't need this gem anymore because the functionality has been added to Rails (by the author of the gem). rails/rails#18217 https://api.rubyonrails.org/classes/ActiveRecord/SecureToken/ClassMethods.html https://blog.bigbinary.com/2016/03/23/has-secure-token-to-generate-unique-random-token-in-rails-5.html Removing this gem will take care of the warning: already initialized constant SecureRandom::BASE58_ALPHABET Refs: #2430
* Remove has_secure_token gem We don't need this gem anymore because the functionality has been added to Rails (by the author of the gem). rails/rails#18217 https://api.rubyonrails.org/classes/ActiveRecord/SecureToken/ClassMethods.html https://blog.bigbinary.com/2016/03/23/has-secure-token-to-generate-unique-random-token-in-rails-5.html Removing this gem will take care of the warning: already initialized constant SecureRandom::BASE58_ALPHABET Refs: #2430 * Use BigDecimal instead of BigDecimal.new This will take care of the warning: BigDecimal.new is deprecated; use BigDecimal() method instead Refs: #2430
I take the audacity of include has_scure_token to Active Model since the implementation is using methods that are implemented in most of popular ORMs or ODMs in ruby, like
exists?
andsave
, is just as suggestion and for sure I'm open to move it to Active Record anyway 😄. Is still missing documentation but I'd like to hear first opinions about the implementation/codeUsage
ref #16879
cc @dhh