Rails 7.1: Interesting Feature PRs

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.

3 Likes

I’m trying to go thru PRs as well, but I have missed this one somehow. Nice tip!