require 'token_master/error'
require 'securerandom'
module TokenMaster
# `TokenMaster::Core` provides the core functionality of the TokenMaster gem. The `Core` module performs all of the logic of completing tokenable actions, and provides descriptive messages of the status or abilities of calls made.
module Core
class << self
# Completes the tokenable action for a tokenable model instance using a token, setting `tokenable_completed_at` to the time at completion
# @param [Object] klass the tokenable Class
# @param [String, Symbol] key the tokenable action
# @param token [String] the tokenable's token used to complete the action
# @param params [Symbol=>String] keyword arguments required to complete the tokenable action
# @raise [NotTokenableError] if the provided Class does not have the correct tokenable column
# @raise [TokenNotFoundError] if a tokenable instance cannot be found by the given token
# @raise [TokenCompletedError] if the tokenable action has already been completed, i.e., the tokenable instance has a timestamp in `tokenable_completed_at`
# @raise [TokenExpiredError] if the token is expired, i.e., the date is beyond the token's `created_at` plus `token_lifetime`
# @raise [MissingRequiredParamsError] if the params required by a tokenable are not provided
# @return [Object] tokenable Class instance
def do_by_token!(klass, key, token, **params)
check_manageable! klass, key
token_column = { token_col(key) => token }
model = klass.find_by(token_column)
check_token_active! model, key
check_params! key, params
model.update!(
params.merge(completed_at_col(key) => Time.now)
)
model
end
# Completes the token action for a tokenable instance _without_ a token, setting the `tokenable_completed_at` to the time at completion.
Usually implemented when you want to complete multiple tokenable actions at once, e.g., a user completes the invite action by setting up passwords, by default also completes the confirm action
# @example Force a Tokenable Action (Confirm)
# user.force_confirm! =>
#
# @param [Object] model the tokenable model instance
# @param [String, Symbol] key the tokenable action
# @param [Symbol=>String] params keyword arguments required to complete the tokenable action
# @raise [NotTokenableError] if the provided Class does not have the correct tokenable column
# @raise [MissingRequiredParamsError] if the params required by a tokenable are not provided
# @return [Object] tokenable Class instance
def force_tokenable!(model, key, **params)
check_manageable! model.class, key
check_params! key, params
model.update!(
params.merge(completed_at_col(key) => Time.now)
)
model
end
# Generates a tokenable action token, sets the token and the time of creation on the tokenable model instance
# @param [Object] model the tokenable model instance
# @param [String, Symbol] key the tokenable action
# @param [Integer] token_length the length of the generated token, method will use configuration token_length if not provided otherwise
# @raise [NotTokenableError] if the provided Class does not have the correct tokenable column
# @return [String] token
def set_token!(model, key, token_length = nil)
check_manageable! model.class, key
token_length ||= TokenMaster.config.get_token_length(key.to_sym)
token = generate_token token_length
model.update({
token_col(key) => token,
created_at_col(key) => Time.now,
sent_at_col(key) => nil,
completed_at_col(key) => nil
})
model.save(validate: false)
token
end
# Accepts a block to pass on a generated token through a block, such as a mailer method, and sets `tokenable_sent_at` to the time the method is called
# @example Send Reset Instructions
# user.send_reset_instruction! { user.send_email } =>
#
# @param [Object] model the tokenable model instance
# @param [String, Symbol] key the tokenable action
# @raise [NotTokenableError] if the provided Class does not have the correct tokenable column
# @raise [TokenNotSetError] if the tokenable model instance does not have a token for the tokenable action
# @raise [TokenSentError] if this has already been called for the instance and tokenable action, i.e., `tokenable_sent_at` is not `nil`
# @return [Object] tokenable model instance
def send_instructions!(model, key)
check_manageable! model.class, key
check_token_set! model, key
check_instructions_sent! model, key
yield if block_given?
model.update(sent_at_col(key) => Time.now)
model.save(validate: false)
end
# Calls set_token! and send_instructions! to generate a new token and send instructions again (accepts a block, such as a mailer method, for sending instructions).
Note, any previously generated token for the user will be invalid.
# @example Resend Reset Instructions
# user.resend_reset_instruction! { user.send_email } =>
#
# @param [Object] model the tokenable model instance
# @param [String, Symbol] key the tokenable action
# @param [Integer] token_length the length of the generated token, method will use configuration token_length if not provided otherwise
# @raise [NotTokenableError] if the provided Class does not have the correct tokenable column
# @return [Object] tokenable model instance
def resend_instructions!(model, key, token_length = nil)
set_token!(model, key, token_length)
send_instructions!(model, key)
end
# Provides the status of the tokenable action, whether the action has been completed, the token has been sent, the token is expired, or the token has only been created
# @param [Object] model the tokenable model instance
# @param [String, Symbol] key the tokenable action
# @raise [NotTokenableError] if the provided Class does not have the correct tokenable column
# @return [String] status of the tokenable action:
# * completed
# * sent
# * expired
# * created
# * no token
def status(model, key)
check_manageable! model.class, key
return 'completed' if completed?(model, key)
return 'sent' if instructions_sent?(model, key)
if token_set?(model, key)
return 'expired' unless token_active?(model, key)
return 'created'
end
'no token'
end
private
def token_col(key)
"#{key}_token".to_sym
end
def created_at_col(key)
"#{key}_created_at".to_sym
end
def sent_at_col(key)
"#{key}_sent_at".to_sym
end
def completed_at_col(key)
"#{key}_completed_at".to_sym
end
def token_lifetime(key)
TokenMaster.config.get_token_lifetime(key.to_sym)
end
def check_manageable!(klass, key)
raise Errors::NotTokenable, "#{klass} not #{key}able" unless manageable?(klass, key)
end
def manageable?(klass, key)
return false unless klass.respond_to? :column_names
column_names = klass.column_names
%W(
#{key}_token
#{key}_created_at
#{key}_completed_at
#{key}_sent_at
).all? { |attr| column_names.include? attr }
end
def check_params!(key, params)
required_params = TokenMaster.config.get_required_params(key.to_sym)
raise Errors::MissingRequiredParams, 'You did not pass in the required params for this tokenable' unless required_params.all? do |k|
params.keys.include? k
end
end
def check_token_active!(model, key)
raise Errors::TokenNotFound, "#{key} token not found" unless model
raise Errors::TokenCompleted, "#{key} already completed" if completed?(model, key)
raise Errors::TokenExpired, "#{key} token expired" unless token_active?(model, key)
end
def token_active?(model, key)
model.send(token_col(key)) &&
model.send(created_at_col(key)) &&
Time.now <= (model.send(created_at_col(key)) + ((token_lifetime(key)) * 60 * 60 * 24))
end
def check_instructions_sent!(model, key)
raise Errors::TokenSent, "#{key} already sent" if instructions_sent?(model, key)
end
def instructions_sent?(model, key)
model.send(sent_at_col(key)).present?
end
def token_set?(model, key)
model.send(token_col(key)).present?
end
def check_token_set!(model, key)
raise Errors::TokenNotSet, "#{key}_token not set" unless token_set?(model, key)
end
def completed?(model, key)
model.send(completed_at_col(key)).present?
end
def generate_token(length)
rlength = (length * 3) / 4
SecureRandom.urlsafe_base64(rlength).tr('lIO0', 'sxyz')
end
end
end
end