Module: CryptIdent

Extended by:
Dry::Configurable
Defined in:
lib/crypt_ident/config.rb,
lib/crypt_ident/sign_in.rb,
lib/crypt_ident/sign_up.rb,
lib/crypt_ident/version.rb,
lib/crypt_ident/sign_out.rb,
lib/crypt_ident/reset_password.rb,
lib/crypt_ident/change_password.rb,
lib/crypt_ident/session_expired.rb,
lib/crypt_ident/generate_reset_token.rb,
lib/crypt_ident/update_session_expiry.rb

Overview

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

Constant Summary collapse

VERSION =

Version number for Gem. Uses Semantic Versioning.

'0.2.5'

Instance Method Summary collapse

Instance Method Details

#change_password(user_in, current_password, new_password) {|result| ... }

This method returns an undefined value.

Change an Authenticated User's password.

To change an Authenticated User's password, an Entity for that User, the current Clear-Text Password, and the new Clear-Text Password are required. The method accepts an optional repo parameter to specify a Repository instance to which the updated User Entity should be persisted; if none is specified (i.e., if the parameter has its default value of nil), then the UserRepository specified in the Configuration is used.

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 identical to the user parameter passed in to #change_password except that the :password_hash attribute has been updated to reflect the changed password. The updated value for the encrypted password will also have been saved to the Repository.

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 :bad_password.

If the specified user was other than a User Entity representing a Registered User, then the code: for failure will be :invalid_user.

Note that no check for the Current User is done here; this method trusts the Controller Action Class that (possibly indirectly) invokes it to guard that contingency properly.

Examples:

for a Controller Action Class (refactor in real use; demo only)

def call(params)
  user_in = session[:current_user]
  error_code = :unassigned
  config = CryptIdent.config
  change_password(user_in, params[:password],
                  params[:new_password]) do |result|
    result.success do |user:|
      @user = user
      flash[config.success_key] = "User #{user.name} password changed."
      redirect_to routes.root_path
    end
    result.failure do |code:|
      flash[config.error_key] = error_message_for(code)
    end
  end
end

private

def error_message_for(code)
  # ...
end

Parameters:

  • user_in (User)

    The User Entity from which to get the valid Encrypted Password and other non-Password attributes

  • current_password (String)

    The current Clear-Text Password for the specified User

  • new_password (String)

    The new Clear-Text Password to encrypt and add to the returned Entity, and persist to the Repository

Yield Parameters:

  • result (Dry::Matcher::Evaluator)

    Indicates whether the attempt to create a new 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).

Yield Returns:

  • (void)

    Use the result.success and result.failure method-call blocks to retrieve data from the method.

Since:

  • 0.1.0

Required Authentication Status:

  • Must be Authenticated.

Session Data Interacted With:

  • Implies that :current_user must be an Entity for a Registered User

Ubiquitous Language Terms:

    • Authentication
    • Clear-Text Password
    • Encrypted Password
    • Entity
    • Guest User
    • Registered User
    • Repository

103
104
105
106
107
108
# File 'lib/crypt_ident/change_password.rb', line 103

def change_password(user_in, current_password, new_password)
  call_params = [current_password, new_password]
  ChangePassword.new(user: user_in).call(*call_params) do |result|
    yield result
  end
end

#generate_reset_token(user_name, current_user: nil) {|result| ... }

This method returns an undefined value.

Generate a Password Reset Token

Password Reset Tokens are useful for verifying that the person requesting a Password Reset for an existing User is sufficiently likely to be the person who Registered that User or, if not, that no compromise or other harm is done.

Typically, this is done by sending a link through email or other such medium to the address previously associated with the User purportedly requesting the Password Reset. CryptIdent does not automate generation or sending of the email message. What it does provide is a method to generate a new Password Reset Token to be embedded into an HTML anchor link within an email that you construct, and then another method (#reset_password) to actually change the password given a valid, correct token.

It also implements an expiry system, such that if the confirmation of the Password Reset request is not completed within a configurable time, that the token is no longer valid (and so cannot be later reused by unauthorised persons).

This 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 identical to the user parameter passed in to #generate_reset_token except that the :token and :password_reset_expires_at attributes have been updated to reflect the token request. An updated record matching that :user Entity will also have been saved to the Repository.

On failure, the result.failure call will yield three parameters: :code, :current_user, and :name, and will be set as follows:

If the :code value is :user_logged_in, that indicates that the current_user parameter to this method represented a Registered User. In this event, the :current_user value passed in to the result.failure call will be the same User Entity passed into the method, and the :name value will be :unassigned.

If the :code value is :user_not_found, the named User was not found in the Repository. The :current_user parameter will be the Guest User Entity, and the :name parameter to the result.failure block will be the user_name value passed into the method.

Examples:

Demonstrating a (refactorable) Controller Action Class #call method


def call(params)
  config = CryptIdent.config
  # Remember that reading an Entity stored in session data will in fact
  #   return a *Hash of its attribute values*. This is acceptable.
  other_params = { current_user: session[:current_user] }
  generate_reset_token(params[:name], other_params) do |result|
    result.success do |user:|
      @user = user
      flash[config.success_key] = 'Request for #{user.name} sent'
    end
    result.failure do |code:, current_user:, name:| do
      respond_to_error(code, current_user, name)
    end
  end
end

private

def respond_to_error(code, current_user, name)
  # ...
end

Parameters:

  • user_name (String)

    The name of the User for whom a Password Reset Token is to be generated.

  • current_user (User, Hash) (defaults to: nil)

    Entity representing the currently Authenticated User Entity. This must be a Registered User, either as an Entity or as a Hash of attributes.

Yield Parameters:

  • 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.

Yield Returns:

  • (void)

    Use the result.success and result.failure method-call blocks to retrieve data from the method.

Since:

  • 0.1.0

Required Authentication Status:

  • Must not be Authenticated.

Session Data Interacted With:

  • :current_user must not be a Registered User.

Ubiquitous Language Terms:

    • Authentication
    • Guest User
    • Password Reset Token
    • Registered User

113
114
115
116
117
118
# File 'lib/crypt_ident/generate_reset_token.rb', line 113

def generate_reset_token(user_name, current_user: nil)
  other_params = { current_user: current_user }
  GenerateResetToken.new.call(user_name, other_params) do |result|
    yield result
  end
end

#reset_password(token, new_password, current_user: nil) {|result| ... }

This method returns an undefined value.

Reset the password for the User associated with a Password Reset Token.

After a Password Reset Token has been generated 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.

Examples:

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

Parameters:

  • token (String)

    The Password Reset Token previously communicated to the User.

  • new_password (String)

    New Clear-Text Password to encrypt and add to return value

Yield Parameters:

  • 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.

Yield Returns:

  • (void)

    Use the result.success and result.failure method-call blocks to retrieve data from the method.

Since:

  • 0.1.0

Required Authentication Status:

  • Must not be Authenticated.

Session Data Interacted With:

  • :current_user must not be a Registered User.

Ubiquitous Language Terms:

    • Authentication
    • Clear-Text Password
    • Encrypted Password
    • Password Reset Token
    • Registered User

111
112
113
114
115
116
# File 'lib/crypt_ident/reset_password.rb', line 111

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

#session_expired?(session_data = {}) ⇒ Boolean

Determine whether the Session has Expired due to User inactivity.

This is one of two methods in CryptIdent (the other being #update_session_expiry?) which does not follow the result/success/failure monad workflow. This is because there is no success/failure division in the workflow. Calling the method determines if the Current User session has Expired. If the passed-in :current_user is a Registered User, then this will return true if the current time is later than the passed-in :expires_at value; for the Guest User, it should always return false. (Guest User sessions never expire; after all, what would you change the session state to?).

The client code is responsible for applying these values to its own actual session data, as described by the sample session-management code shown in the README.

Examples:

As used in module included by Controller Action Class (see README)

def validate_session
  updates = update_session_expiry(session)
  if !session_expired?(session)
    session[:expires_at] = updates[:expires_at]
    return
  end

  # ... sign out and redirect appropriately ...
end

Parameters:

  • session_data (Hash) (defaults to: {})

    The Rack session data of interest to the method. If the :current_user entry is defined, it must be either a User Entity or nil, signifying the Guest User. If the :expires_at entry is defined, its value in the returned Hash will be different.

Returns:

  • (Boolean)

Since:

  • 0.1.0

Required Authentication Status:

  • Must be Authenticated.

Session Data Interacted With:

  • :current_user must be an Entity for a Registered User on entry :expires_at read during determination of expiry status

Ubiquitous Language Terms:

    • Authentication
    • Current User
    • Guest User
    • Registered User
    • Session Expiration

58
59
60
# File 'lib/crypt_ident/session_expired.rb', line 58

def session_expired?(session_data = {})
  SessionExpired.new.call(session_data)
end

#sign_in(user_in, password, current_user: nil) {|result| ... }

This method returns an undefined value.

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.

Examples:

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

Parameters:

  • user_in (User)

    Entity representing a User to be Authenticated.

  • password (String)

    Claimed Clear-Text Password for the specified User.

  • current_user (User, nil) (defaults to: nil)

    Entity representing the currently Authenticated User Entity; either nil or the Guest User if none.

Yield Parameters:

  • 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).

Yield Returns:

  • (void)

Since:

  • 0.1.0

Required Authentication Status:

  • Must not be Authenticated as a different User.

Session Data Interacted With:

  • :current_user must not be a Registered User

Ubiquitous Language Terms:

    • Authenticated User
    • Authentication
    • Clear-Text Password
    • Entity
    • Guest User
    • Registered User

123
124
125
126
# File 'lib/crypt_ident/sign_in.rb', line 123

def (user_in, password, current_user: nil)
  params = { user: user_in, password: password, current_user: current_user }
  SignIn.new.call(params) { |result| yield result }
end

#sign_out(current_user:) {|result| ... }

This method returns an undefined value.

Sign out a previously Authenticated User.

The method requires a block, to which a result indicating success or failure is yielded. (Presently, any call to #sign_out results in success.) That block must in turn call both result.success and result.failure (even though no failure is implemented) to handle success and failure results, respectively. On success, the block yielded to by result.success is called without parameters.

Examples:

Controller Action Class method example resetting values

def call(_params)
  sign_out(session[:current_user]) do |result|
    result.success do
      session[:current_user] = CryptIdent.config.guest_user
      session[:expires_at] = Hanami::Utils::Kernel.Time(0)
    end

    result.failure { next }
  end
end

Controller Action Class method example deleting values

def call(_params)
  sign_out(session[:current_user]) do |result|
    result.success do
      session[:current_user] = nil
      session[:expires_at] = nil
    end

    result.failure { next }
  end
end

Parameters:

  • current_user (User, `nil`)

    Entity representing the currently Authenticated User Entity. This should be a Registered User.

Yield Parameters:

  • result (Dry::Matcher::Evaluator)

    Normally, used to report whether a method succeeded or failed. The block must call both result.success and result.failure methods. In practice, parameters to both may presently be safely ignored.

Yield Returns:

  • (void)

Since:

  • 0.1.0

Required Authentication Status:

  • Should be Authenticated.

Session Data Interacted With:

  • See method description above.

Ubiquitous Language Terms:

    • Authenticated User
    • Authentication
    • Controller Action Class
    • Entity
    • Guest User
    • Interactor
    • Repository

74
75
76
# File 'lib/crypt_ident/sign_out.rb', line 74

def sign_out(current_user:)
  SignOut.new.call(current_user: current_user) { |result| yield result }
end

#sign_up(attribs, current_user:) {|result| ... }

This method returns an undefined value.

Persist a new User to a Repository based on passed-in attributes, where the resulting Entity (on success) contains a :password_hash attribute containing the encrypted value of a random Clear-Text Password; any password value within attribs is ignored.

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 newly-created User Entity.

If the call fails, the result.success block is yielded to, and passed a code: parameter, which will contain one of the following symbols:

  • :current_user_exists indicates that the method was called with a Registered User as the current_user parameter.
  • :user_already_created indicates that the specified name attribute matches a record that already exists in the underlying Repository.
  • :user_creation_failed indicates that the Repository was unable to create the new User for some other reason, such as an internal error.

NOTE that the incoming params are expected to have been whitelisted at the Controller Action Class level.

Examples:

in a Controller Action Class

def call(_params)
  (params, current_user: session[:current_user]) do |result|
    result.success do |user:|
      @user = user
      message = "#{user.name} successfully created. You may sign in now."
      flash[CryptIdent.config.success_key] = message
      redirect_to routes.root_path
    end

    result.failure do |code:|
      # `#error_message_for` is a method on the same class, not shown
      failure_key = CryptIdent.config.failure_key
      flash[failure_key] = error_message_for(code, params)
    end
  end
end

Parameters:

  • attribs (Hash)

    Hash-like object of attributes for new User Entity and record. Must include name and any other attributes required by the underlying database schema. Any password attribute will be ignored.

  • current_user (User, nil)

    Entity representing the current Authenticated User, or the Guest User. A value of nil is treated as though the Guest User had been specified.

Yield Parameters:

  • result (Dry::Matcher::Evaluator)

    Indicates whether the attempt to create a new 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).

Since:

  • 0.1.0

Required Authentication Status:

  • Must not be Authenticated.

Session Data Interacted With:

  • :current_user must not be a Registered User.

Ubiquitous Language Terms:

    • Authentication
    • Clear-Text Password
    • Entity
    • Guest User
    • Registered User

87
88
89
90
91
# File 'lib/crypt_ident/sign_up.rb', line 87

def (attribs, current_user:)
  SignUp.new.call(attribs, current_user: current_user) do |result|
    yield result
  end
end

#update_session_expiry(session_data = {}) ⇒ Hash

Generate a Hash containing an updated Session Expiration timestamp, which can then be used for session management.

This is one of two methods in CryptIdent (the other being #session_expired?) which does not follow the result/success/failure monad workflow. This is because there is no success/failure division in the workflow. Calling the method only makes sense if there is a Registered User as the Current User, but all this method does is build a Hash with :current_user and :expires_at entries. The returned :current_user is the passed-in :current_user if a Registered User, or the Guest User if not. The returned :updated_at value, for a Registered User, is the configured Session Expiry added to the current time, and for the Guest User, a time far enough in the future that any call to #session_expired? will be highly unlikely to ever return true.

The client code is responsible for applying these values to its own actual session data, as described by the sample session-management code shown in the README.

Examples:

As used in module included by Controller Action Class (see README)

def validate_session
  if !session_expired?(session)
    updates = update_session_expiry(session)
    session[:expires_at] = updates[:expires_at]
    return
  end

  # ... sign out and redirect appropriately ...
end

Parameters:

  • session_data (Hash) (defaults to: {})

    The Rack session data of interest to the method. If the :current_user entry is defined, it must be either a User Entity or nil, signifying the Guest User. If the :expires_at entry is defined, its value in the returned Hash will be different.

Returns:

  • (Hash)

    A Hash with entries to be used to update session data. expires_at will have a value of the current time plus the configuration-specified session_expiry offset if the supplied :current_user value is a Registered User; otherwise it will have a value far enough in advance of the current time (e.g., by 100 years) that the #session_expired? method is highly unlikely to ever return true. The :current_user value will be the passed-in session_data[:current_user] value if that represents a Registered User, or the Guest User otherwise.

Since:

  • 0.1.0

Required Authentication Status:

  • Must be Authenticated.

Session Data Interacted With:

  • :current_user must be a User Entity. nil is accepted to indicate the Guest User :expires_at set to the session-expiration time on exit, which will be arbitrarily far in the future for the Guest User.

Ubiquitous Language Terms:

    • Authentication
    • Guest User
    • Registered User
    • Session Expiration
    • User

74
75
76
# File 'lib/crypt_ident/update_session_expiry.rb', line 74

def update_session_expiry(session_data = {})
  UpdateSessionExpiry.new.call(session_data)
end