Posts

Automatically add tokens to your ActiveRecord models with a Tokenizable concern

Use case

When exposing an API you most likely don’t want your identifier to be incrementing Ids. To solve this problem you can add a unique token field to your Active Record for identifying your records via the API layer. This brings some more security and removes some unwanted transparency.

Setup

First, we generate, a new model that will use the token field.

$ rails generate model Product title description price:decimal token:string:index:null
$ rails db:migrate

Make sure to add_index the token and set null: false

See example migration file

Or we choose to add it to an existing model and generate tokens for all records in the database:

$ rails generate migration addTokenToUser token:string:index
$ rails db:migrate
$ rails run User.generate_tokens!

It is recommended to do another migration after this to set token to null: false

To setup we include the concern in our model et viola!

class Product < ApplicationRecord
  include Tokenizable

  # Optionally set the token length. Default is 8
  token_length 12
end

Show me the code!

The above magic is achieved by this Tokenizable concern that you can include in your Ruby on Rails projects.

You can also view it on Github

#
# Add the functionality to add tokens to ActiveRecord models.
# Set `token_length 12` in your model if you want not to use the default after including the conern
# You need a database column that is named `token`
#
# @author Kieran Klaassen
#
module Tokenizable
  extend ActiveSupport::Concern

  DEFAULT_LENGTH = 8

  included do
    cattr_accessor :token_length_var
    before_create :generate_token
    validates_presence_of :token, on: :update
  end

  module ClassMethods
    # Generate tokens for all records that do not have them.
    # To be run in migration or deployment task.
    def generate_tokens!
      # Find all IDs that need tokenizing
      ids = where(token:nil).ids
      return if ids.blank?

      # Make sure we check against existing tokens to ensure token uniqueness in case Tokenizable is
      # used with a column that does not have a unique constraint set up
      existing_tokens = pluck(:token).compact

      # Generate tokens for every ID. Make sure we have no duplicates
      tokens = []
      while tokens.length < ids.length
        (ids.length - tokens.length).times do
          tokens << SecureRandom.urlsafe_base64(@token_length_var).downcase
        end
        tokens = tokens.uniq - existing_tokens
      end

      # Collect all SQL parts for use in a VALUES construct
      token_sql_parts = []
      ids.each_with_index { |id, i| token_sql_parts << "(#{id}, '#{tokens[i]}')" }

      # Generate the SQL
      ActiveRecord::Base.connection.execute(<<-sql.gsub(/\s+/, ' ').squish)
        WITH tokens(id, token) AS (VALUES #{token_sql_parts.join(',')})
        UPDATE #{table_name} tbl
          SET token=tokens.token
          FROM tokens
          WHERE tbl.id = tokens.id
      sql
    end

    private

    # Set the length of the token (in bytes converted to base64) to be generated
    #
    # @param [Integer] token_length sets the length of the token to be generated
    def token_length(token_length)
      self.token_length_var = token_length.to_i
      logger.warn "WARN: Redefining token_length from #{token_length_var} to #{token_length}" if token_length_var
      fail 'token_length must be a positive number greater than 0' if token_length_var < 1
    end
  end

  # Creates a token if not set
  def generate_token
    self.token = loop do
      token = SecureRandom.urlsafe_base64(self.class.token_length_var || DEFAULT_LENGTH).downcase
      break token unless self.class.exists?(token: token)
    end
  end
end