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.