# frozen_string_literal: true require 'bcrypt' require 'dry/monads/result' require 'dry/matcher/result_matcher' # 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.0 module CryptIdent # Attempt to Authenticate a User, passing in an Entity for that User (which # **must** contain a `password_hash` attribute), and a Clear-Text Password. # It also passes in the Current User. # # If the Current User is not a Registered User, then Authentication of the # specified User Entity against the specified Password is accomplished by # comparing the User Entity's `password_hash` attribute to the passed-in # Clear-Text Password. # # The method *requires* a block, to which a `result` indicating success or # failure is yielded. That block **must** in turn call **both** # `result.success` and `result.failure` to handle success and failure results, # respectively. On success, the block yielded to by `result.success` is called # and passed a `user:` parameter, which is the Authenticated User (and is the # same Entity as the `user` parameter passed in to `#sign_in`). # # On failure, the `result.failure` call will yield a `code:` parameter to its # block, which indicates the cause of failure as follows: # # If the specified password *did not* match the passed-in `user` Entity, then # the `code:` for failure will be `:invalid_password`. # # If the specified `user` was not a Registered User, then the `code:` for # failure will be `:user_is_guest`. # # If the specified `current_user` is *neither* the Guest User *nor* the `user` # passed in as a parameter to `#sign_in`, then the `code:` for failure will be # `:illegal_current_user`. # # On *success,* the Controller-level client code **must** set: # # * `session[:expires_at]` to the expiration time for the session. This is # ordinarily computed by adding the current time as returned by `Time.now` # to the `:session_expiry` value in the current configuration. # * `session[:current_user]` to tne returned *Entity* for the successfully # Authenticated User. This is to eliminate possible repeated reads of the # Repository. # # On *failure,* the Controller-level client code **should** set: # # * `session[:expires_at]` to some sufficiently-past time to *always* trigger # `#session_expired?`; `Hanami::Utils::Kernel.Time(0)` does this quite well # (returning midnight GMT on 1 January 1970, converted to local time). # * `session[:current_user]` to either `nil` or the Guest User. # # @since 0.1.0 # @authenticated Must not be Authenticated as a different User. # @param [User] user_in Entity representing a User to be Authenticated. # @param [String] password Claimed Clear-Text Password for the specified User. # @param [User, nil] current_user Entity representing the currently # Authenticated User Entity; either `nil` or the Guest User if # none. # @return (void) Use the `result` yield parameter to determine results. # @yieldparam result [Dry::Matcher::Evaluator] Indicates whether the attempt # to Authenticate a User succeeded or failed. Block **must** # call **both** `result.success` and `result.failure` methods, # where the block passed to `result.success` accepts a parameter # for `user:` (which is the newly-created User Entity). The # block passed to `result.failure` accepts a parameter for # `code:`, which is a Symbol reporting the reason for the # failure (as described above). # @yieldreturn [void] # @example As in a Controller Action Class (which you'd refactor somewhat): # def call(params) # user = UserRepository.new.find_by_email(params[:email]) # guest_user = CryptIdent.config.guest_user # return update_session_data(guest_user, 0) unless user # # current_user = session[:current_user] # config = CryptIdent.config # sign_in(user, params[:password], current_user: current_user) do |result| # result.success do |user:| # @user = user # update_session_data(user, Time.now) # flash[config.success_key] = "User #{user.name} signed in." # redirect_to routes.root_path # end # # result.failure do |code:| # update_session_data(guest_user, config, 0) # flash[config.error_key] = error_message_for(code) # end # end # # private # # def error_message_for(code) # # ... # end # # def update_session_data(user, time) # session[:current_user] = user # expiry = Time.now + CryptIdent.config.session_expiry # session[:expires_at] == Hanami::Utils::Kernel.Time(expiry) # end # @session_data # `:current_user` **must not** be a Registered User # @ubiq_lang # - Authenticated User # - Authentication # - Clear-Text Password # - Entity # - Guest User # - Registered User # def sign_in(user_in, password, current_user: nil) params = { user: user_in, password: password, current_user: current_user } SignIn.new.call(params) { |result| yield result } end # Reworked sign-in logic for `CryptIdent`, per Issue #9. # # This class *is not* part of the published API. # @private class SignIn include Dry::Monads::Result::Mixin include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher) # As a reminder, calling `Failure` *does not* interrupt control flow *or* # prevent a future `Success` call from overriding the result. This is one # case where raising *and catching* an exception is Useful def call(user:, password:, current_user: nil) set_ivars(user, password, current_user) validate_call_params Success(user: user) rescue LogicError => error Failure(code: error.message.to_sym) end private attr_reader :current_user, :password, :user LogicError = Class.new(RuntimeError) private_constant :LogicError def illegal_current_user? !current_user.guest? && !same_user? end def password_comparator BCrypt::Password.new(user.password_hash) end def same_user? current_user.name == user.name end # Reek complains about a :reek:ControlParameter for `current`. Never mind. def set_ivars(user, password, current) @user = user @password = password guest_user = CryptIdent.config.guest_user current ||= guest_user @current_user = guest_user.class.new(current) end def validate_call_params raise LogicError, 'user_is_guest' if user.guest? raise LogicError, 'illegal_current_user' if illegal_current_user? verify_matching_password end def verify_matching_password match = password_comparator == password raise LogicError, 'invalid_password' unless match end end # Leave the class visible durinig Gem development and testing; hide in an app private_constant :SignIn if Hanami.respond_to?(:env?) end