Skip to content

Instantly share code, notes, and snippets.

@bill-mfv
Last active July 1, 2022 01:18
Show Gist options
  • Save bill-mfv/e966ea90ce1eb5b9ac407716420f1689 to your computer and use it in GitHub Desktop.
Save bill-mfv/e966ea90ce1eb5b9ac407716420f1689 to your computer and use it in GitHub Desktop.

Starting point: We build a feature that helps users unsubcribe the email subscription

Like: URL: /user/unsubcribe_email/:token

We don't want to store the token in the database, so we come with the approach that uses a dynamic token by encrypt_and_sign the user's ID with a key

Like:

class User < ApplicationRecord
  ENCRYPT_UNSUBCRIBE_TOKEN_KEY = "11111111111111111111111111111111" # 32

  def self.find_by_unsubcribe_token token
    id = begin
      ActiveSupport::MessageEncryptor.new(ENCRYPT_UNSUBCRIBE_TOKEN_KEY).decrypt_and_verify(token)
    rescue StandardError
      ""
    end

    find_by_id(id)
  end

  def unsubcribe_token
    ActiveSupport::MessageEncryptor.new(ENCRYPT_UNSUBCRIBE_TOKEN_KEY).encrypt_and_sign(id.to_s)
  end
end

We have:

user = User.find_by_unsubcribe_token "token"
# or 
user = User.first
user.unsubcribe_token # => return token

It works well.

Later, We build other feature that allow users see a page without login. This page shows the user's diagnosis result. So we have to think about the way to identify the user and show user's data (It can be public) => We continue to use the dynamic encrypted token like above

URL: /diagnosis/result?token=xxx

We add the code to the model:

class DiagnosisInfo < ApplicationRecord
  ENCRYPT_RESULT_TOKEN_KEY = "22222222222222222222222222222222" # 32

  def self.find_by_result_token token
    id = begin
      ActiveSupport::MessageEncryptor.new(ENCRYPT_RESULT_TOKEN_KEY).decrypt_and_verify(token)
    rescue StandardError
      ""
    end

    find_by_id(id)
  end

  def result_token
    ActiveSupport::MessageEncryptor.new(ENCRYPT_RESULT_TOKEN_KEY).encrypt_and_sign(id.to_s)
  end
end

As we see, the code is duplicated in two models, and we go to refactor the code like:

# Define a module
module EncryptToken
  extend ActiveSupport::Concern # we put this module in the models/concerns folder

  class_methods do
    def find_by_encrypt_token token, encrypt_token_key
      id = begin
        ActiveSupport::MessageEncryptor.new(encrypt_token_key).decrypt_and_verify(token)
      rescue StandardError
        ""
      end

      find_by_id(id)
    end
  end

  def get_encrypt_token encrypt_token_key
    ActiveSupport::MessageEncryptor.new(encrypt_token_key).encrypt_and_sign(id.to_s)
  end
end

# And include it in the models
class User < ApplicationRecord
  include EncryptToken
  ENCRYPT_UNSUBCRIBE_TOKEN_KEY = "11111111111111111111111111111111" # 32

  def self.find_by_unsubcribe_token token
    find_by_encrypt_token(token, ENCRYPT_UNSUBCRIBE_TOKEN_KEY)
  end

  def unsubcribe_token
    get_encrypt_token(ENCRYPT_UNSUBCRIBE_TOKEN_KEY)
  end
end

class DiagnosisInfo < ApplicationRecord
  include EncryptToken
  ENCRYPT_RESULT_TOKEN_KEY = "22222222222222222222222222222222" # 32

  def self.find_by_result_token token
    find_by_encrypt_token(token, ENCRYPT_RESULT_TOKEN_KEY)
  end

  def result_token
    get_encrypt_token(ENCRYPT_RESULT_TOKEN_KEY)
  end
end

It's better than before, but it is not a best way, I think. Especially it's just OK for the case that we have one token per a model. It is not good if we want to use another encrypt_and_sign token in User model like a contact token (token to access the contact page)

class User < ApplicationRecord
  include EncryptToken
  ENCRYPT_UNSUBCRIBE_TOKEN_KEY = "11111111111111111111111111111111" # 32
  ENCRYPT_CONTACT_TOKEN_KEY = "33333333333333333333333333333333" # 32

  # For unsubcribe_token
  def self.find_by_unsubcribe_token token
    find_by_encrypt_token(token, ENCRYPT_UNSUBCRIBE_TOKEN_KEY)
  end

  def unsubcribe_token
    get_encrypt_token(ENCRYPT_UNSUBCRIBE_TOKEN_KEY)
  end

  # For contact_token
  def self.find_by_contact_token token
    find_by_encrypt_token(token, ENCRYPT_CONTACT_TOKEN_KEY)
  end

  def contact_token
    get_encrypt_token(ENCRYPT_CONTACT_TOKEN_KEY)
  end
end

The code is still not good. So we have to refactor the code by making it more dynamic and easy to extend. => We rewrite the module

module EncryptToken
  extend ActiveSupport::Concern

  # allow us to configure a simple token field in a model
  # Example:
  # class A
  #   extend EncryptToken
  #   encrypt_token :simple_token, "key"
  # end
  #
  # We will add two methods:
  # 1. A.find_by_simple_token(token) => a record / nil
  # 2. a_instance.simple_token       => the encrypt token of this record a_instance
  def encrypt_token field_name, encrypt_key
    # Define the class method
    define_singleton_method "find_by_#{field_name}" do |token|
      id = begin
        ActiveSupport::MessageEncryptor.new(encrypt_key).decrypt_and_verify(token)
      rescue StandardError
        ""
      end

      find_by_id(id)
    end

    # Define the instance method
    define_method field_name do
      ActiveSupport::MessageEncryptor.new(encrypt_key).encrypt_and_sign(id.to_s)
    end
  end
end

And in the model

class User < ApplicationRecord
  include EncryptToken
  ENCRYPT_UNSUBCRIBE_TOKEN_KEY = "11111111111111111111111111111111" # 32
  ENCRYPT_CONTACT_TOKEN_KEY = "33333333333333333333333333333333" # 32

  encrypt_token :unsubcribe_token, ENCRYPT_UNSUBCRIBE_TOKEN_KEY
  encrypt_token :contact_token, ENCRYPT_CONTACT_TOKEN_KEY
end
class DiagnosisInfo < ApplicationRecord
  include EncryptToken
  ENCRYPT_RESULT_TOKEN_KEY = "22222222222222222222222222222222" # 32

  encrypt_token :result_token, ENCRYPT_RESULT_TOKEN_KEY
end

Then we still have:

user = User.find_by_unsubcribe_token "token"
user = User.find_by_contact_token "token"

diagnosis_info = DiagnosisInfo.find_by_result_token "token"

# or 
user = User.first
user.unsubcribe_token # => return token
user.contact_token # => return token

diagnosis_info = DiagnosisInfo.first
diagnosis_info.result_token

It's better. And now it's very simple to add many kinds of token in a model as we want.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment