We deploy rails edge to production, so are constantly reading PRs that were merged. I just stumbled on a cool one and though it would be nice share it and maybe other future PRs
Link
What does it do?
Generate a single use token without needing an extra database field to track it.
Sample Code:
class User < ActiveRecord::Base
has_secure_password
generates_token_for :password_reset do
# BCrypt salt changes when password is updated
BCrypt::Password.new(password_digest).salt[-10..]
end
end
user = User.first
token = user.generate_token_for(:password_reset)
User.find_by_token_for(:password_reset, token) # => user
user.update!(password: "new password")
User.find_by_token_for(:password_reset, token) # => nil
How does generating the token works?
The method generate_token_for
works the same way as signed_id
. The main difference is what each of them passes down to the MessageVerifier
to sign.
# signed_id:
verifier.generate id, expires_in: expires_in, purpose: self.class.combine_signed_id_purposes(purpose)
# generate_token_for:
verifier.generate payload_for(model), expires_in: expires_in, purpose: full_purpose
As we can see, signed_id
, as the name implies, relies only the model’s ID while generate_token_for
relies the model’s payload. But what is the payload? It is a combination of the ID and the JSON of whatever was returned by the block given to the generates_token_for
macro in the model.
def payload_for(model)
block ? [model.id, model.instance_eval(&block).as_json] : [model.id]
end
So in the example above, the result would be an array made of the model’s ID and the salt of the last 10 characters of the password digest. For example:
[37, "KyIsEqFBh."]
After it’s passed to the verifier, the final result is something like this. Notice that generate_token_for
is longer due to the extra information it contains.
# signed_id
"eyJfcmFpbHMiOnsibWVzc2FnZSI6Ik16Yz0iLCJleHAiOm51bGwsInB1ciI6InVzZXIifX0=--a6aab77fee1ccc945e3fc19426799de062063e3db9b0c631a98693475a5e4b11"
# generate_token_for
"eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaGJCMmtxU1NJUFMzbEpjMFZ4UmtKb0xnWTZCa1ZVIiwiZXhwIjpudWxsLCJwdXIiOiJVc2VyXG5wYXNzd29yZF9yZXNldFxuIn19--845ed7d939cbe28bb565334352f6ac7884ddc8d0"
How does finding a record using the token works?
Once again signed_id
and and generate_token_for
rely on MessageVerifier
, but the latter has an extra step. Before using the ID to find the record, it compares the payload that was embedded in the token with the current payload:
def resolve_token(token)
payload = message_verifier.verified(token, purpose: full_purpose)
model = yield(payload[0]) if payload
model if model && payload_for(model) == payload
end
So, if the user has updated his password, the payload will have changed:
[37, "3BN0ZQ5DGe"]
Without this match Rails will not perform the find
and simply return nil
. Which is how you get the feature: One time tokens without extra columns in the database.