# frozen_string_literal: true require 'bcrypt' require 'dry/monads/result' require 'dry/matcher/result_matcher' require 'hanami/utils/kernel' require_relative './config' # Include and interact with `CryptIdent` to add authentication to a # Hanami controller action. # # Note the emphasis on *controller action*; this module interacts with session # data, which is quite theoretically possible in an Interactor but practically # *quite* the PITA. YHBW. # # @author Jeff Dickey # @version 0.2.2 module CryptIdent # Reset the password for the User associated with a Password Reset Token. # # After a Password Reset Token has been # [generated](#generate_reset_token-instance_method) and sent to a User, that # User would then exercise the Client system and perform a Password Reset. # # Calling `#reset_password` is different than calling `#change_password` in # one vital respect: with `#change_password`, the User involved **must** be # the Current User (as presumed by passing the appropriate User Entity in as # the `current_user:` parameter), whereas `#reset_password` **must not** be # called with *any* User other than the Guest User as the `current_user:` # parameter (and, again presumably, the Current User for the session). How can # we assure ourselves that the request is legitimate for a specific User? By # use of the Token generated by a previous call to `#generate_reset_token`, # which is used _in place of_ a User Name for this request. # # Given a valid set of parameters, and given that the updated User is # successfully persisted, the method calls the **required** block with a # `result` whose `result.success` matcher is yielded a `user:` parameter with # the updated User as its value. # # NOTE: Each of the error returns documented below calls the **required** # block with a `result` whose `result.failure` matcher is yielded a `code:` # parameter as described, and a `token:` parameter that has the same value # as the passed-in `token` parameter. # # If the passed-in `token` parameter matches the `token` field of a record in # the Repository *and* that Token is determined to have Expired, then the # `code:` parameter mentioned earlier will have the value `:expired_token`. # # If the passed-in `token` parameter *does not* match the `token` field of any # record in the Repository, then the `code:` parameter will have the value # `:token_not_found`. # # If the passed-in `current_user:` parameter is a Registered User, then the # `code:` parameter will have the value `:invalid_current_user`. # # In no event are session values, including the Current User, changed. After a # successful Password Reset, the User must Authenticate as usual. # # @yieldparam result [Dry::Matcher::Evaluator] Indicates whether the attempt # to generate a new Reset Token succeeded or failed. The lock # **must** call **both** `result.success` and `result.failure` # methods, where the block passed to `result.success` accepts a # parameter for `user:`, which is a User Entity with the # specified `name` value as well as non-`nil` values for its # `:token` and `:password_reset_expires_at` attributes. The # block passed to `result.failure` accepts parameters for # `code:`, `current_user:`, and `name` as described above. # @yieldreturn (void) Use the `result.success` and `result.failure` # method-call blocks to retrieve data from the method. # # @since 0.1.0 # @authenticated Must not be Authenticated. # @param [String] token The Password Reset Token previously communicated to # the User. # @param [String] new_password New Clear-Text Password to encrypt and add to # return value # @return (void) # @example # def call(params) # reset_password(params[:token], params[:new_password], # current_user: session[:current_user]) do |result # result.success do |user:| # @user = user # message = "Password for #{user.name} successfully reset." # flash[CryptIdent.config.success_key] = message # redirect_to routes.root_path # end # result.failure do |code:, token:| # failure_key = CryptIdent.config.failure_key # flash[failure_key] = failure_message_for(code, token) # end # end # end # # private # # def failure_message_for(code, token) # # ... # end # @session_data # `:current_user` **must not** be a Registered User. # @ubiq_lang # - Authentication # - Clear-Text Password # - Encrypted Password # - Password Reset Token # - Registered User # def reset_password(token, new_password, current_user: nil) other_params = { current_user: current_user } ResetPassword.new.call(token, new_password, other_params) do |result| yield result end end # Reset Password using previously-sent Reset Token for non-Authenticated User # # This class *is not* part of the published API. # @private class ResetPassword include Dry::Monads::Result::Mixin include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher) LogicError = Class.new(RuntimeError) def initialize @current_user = :unassigned end # rubocop:disable Naming/RescuedExceptionsVariableName def call(token, new_password, current_user: nil) init_ivars(current_user) verify_no_current_user(token) user = verify_token(token) Success(user: update(user, new_password)) rescue LogicError => err report_failure(err) end # rubocop:enable Naming/RescuedExceptionsVariableName private attr_reader :current_user def encrypted(password) BCrypt::Password.create(password) end def expired_token?(entity) prea = entity.password_reset_expires_at # Calling this on a non-reset Entity is treated as expiring at the epoch Time.now > Hanami::Utils::Kernel.Time(prea.to_i) end # Reek sees a :reek:ControlParameter. Yep. def init_ivars(current_user) guest_user = CryptIdent.config.guest_user current_user ||= guest_user @current_user = guest_user.class.new(current_user) end def matching_record_for(token) Array(CryptIdent.config.repository.find_by_token(token)).first end def new_attribs(password) { password_hash: encrypted(password), password_reset_expires_at: nil, token: nil } end def raise_logic_error(code, token) payload = { code: code, token: token } raise LogicError, Marshal.dump(payload) end def report_failure(error) # rubocop:disable Security/MarshalLoad error_data = Marshal.load(error.message) # rubocop:enable Security/MarshalLoad Failure(error_data) end def update(user, password) CryptIdent.config.repository.update(user.id, new_attribs(password)) end def validate_match_and_token(match, token) raise_logic_error(:token_not_found, token) unless match raise_logic_error(:expired_token, token) if expired_token?(match) match end def verify_no_current_user(token) return if current_user.guest? payload = { code: :invalid_current_user, token: token } raise LogicError, Marshal.dump(payload) end def verify_token(token) match = matching_record_for(token) validate_match_and_token(match, token) end end # Leave the class visible durinig Gem development and testing; hide in an app private_constant :ResetPassword if Hanami.respond_to?(:env?) end