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
Generate a single use token without needing an extra database field to track it.
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
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:
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"
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) if payload model if model && payload_for(model) == payload end
So, if the user has updated his password, the payload will have changed:
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.