CryptIdent

Yet another fairly basic authentication Gem. (Authorisation, and batteries, sold separately.)

This is initially tied to Hanami 1.3.0+; specifically, it assumes that user entities have an API compatible with Hanami::Entity for accessing the field/attribute values listed below in Database/Repository Setup (which itself assumes a Repository API compatible with that of Hanami 1.3's Repository classes). The Gem is mostly a thin layer around BCrypt that, in conjunction with Hanami entities or work-alikes, supports the most common use cases for password-based authentication:

  1. Registration;
  2. Signing in;
  3. Signing out;
  4. Password change;
  5. Password reset; and
  6. Session expiration management.

It does not implement features such as

  1. Password-strength testing;
  2. Password occurrence in a list of most popular (and easily hacked) passwords; or
  3. Password ageing (requiring password changes after a period of time).

These either violate current best-practice recommendations from security leaders (e.g., NIST and others no longer recommend password ageing as a defence against cracking) or have other Gems that focus on the features in question (e.g., bdmac/strong_password).

Installation

Add this line to your application's Gemfile:

gem 'crypt_ident'

And then execute:

$ bundle

Or install it yourself as:

$ gem install crypt_ident

Usage

Database/Repository Setup

In this README and in the API Documentation, the Repository used to read and update the underlying database table is named UserRepository and, per Hanami conventions, the Entity is named User. The code itself enforces no such assumption; so long as you assign your Repository class to CryptIdent.config.repository (see Configuration below), and it follows Hanami conventions for Entity names, this should Just Work. (Please open an issue if you prove otherwise.)

We assume that the Repository object

  1. Has a class method named .entity that returns the class of the Entity used by that Repository (e.g., User for a UserRepository). (This is to match Hanami::Repository.)
  2. Has a class method named .guest_user that returns an Entity with a descriptive name (e.g., "Guest User") and is otherwise invalid for persistence (e.g., it has an invalid id attribute); and
  3. Implements the "usual" common methods (#create, #update, #delete, etc) conforming to an interface compatible with Hanami::Repository. This interface is suitably generic and sufficiently widely implemented, even in ORMs such as ActiveRecord that make no claim to implementing the Repository Pattern.

The database table for that Repository must have the following fields, in any order within the schema:

Field Type Description
name string The name of an individual User to be Authenticated
email string The Email Address for that User, to be used for Password Recovery, for example.
password_hash text The encrypted Password associated with that User.
password_reset_expires_at timestamp without time zone Defaults to nil; set this to the Expiry Time (Time.now + config.reset_expiry) when responding to a Password Reset request (e.g., by email). The token (below) will expire at this time (see Configuration, below).
token text Defaults to nil. A Password Reset Token; a URL-safe secure random number (see standard-library documentation) used to uniquely identify a Password Reset request.

For examples of this, examine the test/support/fake_repository.rb and test/support/unit_test_model_and_repo_classes.rb files, and the unit (test/crypt_ident/*) and integration (test/integration/*) tests.

Back to Top

User Entity

As mentioned in the Database/Repository Setup section above, CryptIdent makes no assumption about the class constant/name of the Entity persisted to and retrieved from the Repository, other than it follow Hanami conventions. (In this and related documents, we refer to that Entity class as User.) In addition to attributes matching the fields specified in the previous section, (which Hanami::Entity and most analogous ORM Entities expose by default), the Entity must respond to the #guest? message, returning true if it is the Guest User (as returned by UserRepository#guest_user), or false otherwise. It may have other methods as appropriate to the client code.

Back to Top

Configuration

The currently-configurable details for CryptIdent are as follows:

Key Default Description
:error_key :error Modify this setting if you want to use a different key for flash messages reporting unsuccessful actions.
:guest_user Return value from repository .guest_user method This value is used for the session variable session[:current_user] when no User has signed in, or after a previously Authenticated User has signed out. If your application does not make use of the Null Object pattern, you would assign nil to this configuration setting. (See this Thoughtbot post for a good discussion of Null Objects in Ruby.)
:hashing_cost 8 This is the hashing cost used to encrypt a password and is applied at the hashed-password-creation step; it does not modify the default cost for the encryption engine. Note that any change to this value will invalidate and make useless all existing Encrypted Password stored values.
:repository UserRepository.new Modify this if your user records are in a different (or namespaced) class.
:reset_expiry 86400 (24 hours in seconds) Number of seconds from the time a password-reset request token is stored before it becomes invalid.
:session_expiry 900 (15 minutes, in seconds) Number of seconds from either the time that a User is successfully Authenticated or the update_session_expiry method is called before a call to session_expired? will return true.
:success_key :success Modify this setting if you want to use a different key for flash messages reporting successful actions.
:token_bytes 24 Number of bytes of random data to generate when building a password-reset token. See token in the Database/Repository Setup section, above.

For example

  include CryptIdent

  CryptIdent.configure do |config|
    config.repository = MainApp::Repositories::User.new # note: *not* a Hanami recommended practice!
    config.error_key = :alert
    config.hashing_cost = 6 # less secure and less resource-intensive
    config.token_bytes = 20
    config.reset_expiry = 7200 # two hours; "we run a tight ship here"
    config.guest_user = MainApp::Repositories::User.new.guest_user # Likewise, *not* conventional
  end

would change the configuration as you would expect whenever that code was run. (We recommend that this be done inside the controller.prepare block of your Hanami web (or equivalent) app's application.rb file.)

Back to Top

Introductory Notes on Workflows

Interfaces

The methods employed directly by these use cases use Result matchers and Result monads to provide a consistent, fluent, explicit, and understandable mechanism for detecting and handling success and failure.

Each method (with two exceptions, noted in their documentation) requires a block, to which a result indicating success or failure is yielded. That block must in turn define blocks for both result.success and result.failure to handle success and failure results, respectively. Each of the two blocks takes parameters which the method uses to communicate either the successful result (and possible supporting information), or the reason for failure, along with supporting information. Not all failure cases use all parameters to the result.failure block. Any that are not relevant may be safely ignored (and should by convention have a value of :unassigned yielded to the result.failure block).

The active configuration is not passed as a parameter to either the success or failure blocks; it is always accessible as CryptIdent.config, and is based on the dry-configurable Gem.

For further discussion of this, see the documentation of the individual methods in the API Reference.

Back to Top

Session Handling Not Automatic

If you've set up your controller.prepare block as recommended in the preceding section, CryptIdent is loaded and configured but does not implement session-handling "out of the box"; as with other libraries, it must be implemented by you as described in the Session Expiration description below.

Code Samples in Integration Tests are Authoritative

Integration tests, in test/integration/*_test.rb, are the authoritative documentation-through-working-code of each method and workflow supported by this Gem. Only minimal code snippets are included here to help explain use cases. However, the API Reference provides a more conventionally-documented reference to each CryptIdent method; any discrepancies between the integration tests, documented API, and/or code snippets there and here should be regarded as a bug (and a report filed if not already filed.

Terminology and the project Ubiquitous Language

Finally, a note on terminology. Terms that have meaning (e.g., Guest User) within this module's domain language, or Ubiquitous Language, must be capitalised, at least on first use within a paragraph. This is to stress to the reader that, while these terms may have "obvious" meanings, their use within this module and its related documents (including this one) must be consistent, specific, and rigorous in their meaning. In the API Documentation, each of these terms must be listed in the Ubiquitous Language Terms section under each method description in which they are used. (If you find any omissions, inconsistencies, or other errors, please open a new issue if it has not already been reported.)

After the first usage in a paragraph, the term may be used less strictly; e.g., by referring to a Clear-Text Password simply as a password if doing so does not introduce ambiguity or confusion. The reader should feel free to open an issue report for any lapses of consistency or clarity. (Thank you!)

Back to Top

Use-Case Workflows

Registration

Overview

Method involved:

  module CryptIdent
    def (attribs, current_user:)
      # ...
    end
  end

This is the first of our use cases that involves calling a function which expects a block to be supplied. If one isn't, then a LocalJumpError will be raised. If either the success or failure blocks are omitted within that block, then a Dry::Matcher::NonExhaustiveMatchError will be raised. (It is permissible to completely omit the parameters to a success or failure block; e.g., for the #sign_out method which does not support reporting a failure.)

The attribs parameter is a Hash-like object such as a Hanami::Action::Params instance. It must have a :name entry, as well as any other keys and matching field values required by the Entity which will be created from the params values, other than a :password_hash key. It also must not have a :password entry; if one is supplied, it will be ignored. This is to support our standard workflow of having newly-Registered Users be initially assigned a Clear-Text Password of random text, then immediately starting the Password Reset workflow to further validate their supplied email address.

Pass in the value of the session[:current_user] session variable as the :current_user parameter. This must be an Entity value rather than an id value. Supplying a value of nil is permitted, and is equivalent to specifying the Guest User (see Database/Repository Setup).

As described earlier, this method requires a block which accepts a result parameter. The block must define both result.success and result.failure blocks, passing each a block which itself takes appropriate parameters. These will be further described below.

Back to Top

Success, aka Golden Path

If the params include all values required by the underlying schema, including a valid name attribute that does not exist in the underlying data store, then it (with a password_hash attribute created from a random-text Clear-Text Password) will be persisted to the Repository specified by repo: (or to the Repository specified by the Configuration if the repo: value is nil). That User Entity will be passed to the result.success block as the user: parameter.

Back to Top

Error Conditions

Authenticated User as current_user: Parameter

If the specified current_user: parameter is a valid User Entity other than the Guest User, then that is presumed to be the Current User of the application. Authenticated Users are prohibited from creating other Users, and so the result.failure block will be called with a code: of :current_user_exists.

Back to Top

Specified :name Attribute Already Used for an Existing User

If there is no improper value for the current_user: parameter, and if the specified :name attribute exists in a record within the Repository, then the result.failure block will be called with a :code of :user_already_created.

Back to Top

Record Could Not be Created Within Repository

If neither of the earlier conditions apply, but the Repository method #create returned an error, then the result.failure block will be called with a :code of :user_creation_failed.

Back to Top

Signing In

Overview

Method involved:

  module CryptIdent
    def (user, password, current_user: nil)
      # ...
    end
  end

Once a User has been Registered and Reset their Password, Signing In is a matter of retrieving that user's Entity (containing a password_hash attribute) and calling #sign_in passing in that Entity, the purported Clear-Text Password, and the currently Authenticated User (if any), then using the result passed to the yielded block to determine and respond to the success or failure of the call.

Back to Top

Successfully Signing In

So long as no User is currently Authenticated in the Session (as shown by the session[:current_user] having a value of either nil or the Guest User), supplying a User Entity and the correct Clear-Text Password for that User to a call to #sign_in will cause the block for the #sign_in method call to yield the same User Entity to the result.success block, indicating success.

Note that this process is unchanged if the passed-in current_user is the same as the User Entity attempting Authentication. It is up to client code to determine how to proceed if Authentication fails in this case.

Back to Top

Error Conditions

Incorrect Password Supplied

While no Authenticated Member currently exists (as shown by the session[:current_user] having a value of either nil or the Guest User), supplying a User Entity and an incorrect Clear-Text Password for that User to a call to #sign_in will yield a call to the block's result.failure block with a code: of :invalid_password.

Back to Top

Authenticated User Exists

If the passed-in current_user is a User Entity other than the specified user Entity or the Guest User, no match will be attempted, and the method will yield a call to the block's result.failure block with a code: value of :illegal_current_user.

Back to Top

Guest User Attempts Authentication

While no Authenticated Member currently exists (as shown by the session[:current_user] having a value of either nil or the Guest User), supplying the Guest User as the User Entity to be Authenticated will yield a call to the block's result.failure block with a code: value of :user_is_guest.

Back to Top

Other Notes

This method does not interact with a Repository, and therefore doesn't need to account for an invalid User Name parameter, for instance. Nor does it directly modify session data, although the associated Controller Action Class code must set session[:current_user] and session[:start_time] as below. This is to support extraction of this code (along with anything else not using Hanami::Controller-dependent input validation, redirects, flash messages, etc) to an Interactor, into which would be explicitly passed session[:current_user].

On success, the Controller Action Class calling code must set:

  • session[:start_time] to the current time as returned by Time.now; and
  • session[:current_user] to the Entity (not the ID value from the Repository) for the newly-Authenticated User. This is to eliminate repeated reads of the Repository.

On failure, the Controller Action Class calling code must set:

  • session[:start_time] 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 nil or to the Guest User (see Configuration).

Back to Top

Signing Out

Overview

Method involved:

module CryptIdent
  def sign_out(current_user:)
  end
end

Signing out any previously Authenticated User is straightforward: call the sign_out method, passing in that User as the current_user: parameter. As with the earlier methods, this method also requires a block which accepts a result parameter and has result.success and result.failure calls/blocks. No parameters are yielded to either block.

Note that, as of Release 0.2.0, the method simply passes control to the (required) block, in whose result.success call block you can delete or reset session[:current_user] and session[:start_time]. We recommend reset values of:

  • CryptIdent.config.guest_user for session[:current_user] and
  • Hanami::Utils::Kernel.Time(0) for session[:start_time], which will set the timestamp to 1 January 1970 at midnight — a value which should far exceed your session-expiry limit if you decide not to simply delete the previous values by assigning nil to them.

The required result.failure block can simply be skipped, as

    result.failure { next }

Back to Top

Password Change

Overview

Method involved:

  module CryptIdent
    def change_password(user, current_password, new_password)
      # ...
    end
  end

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.

Back to Top

Successfully Changing the Password

If all parameters are valid and 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. From that point, the User is able to Sign In using the User Name and updated Clear-Text Password.

Client code must take care not to try to Authenticate using the Encrypted Password in the Entity passed in to this method, as it is no longer current. Either retain the returned User Entity from the method, or read it again from the Repository.

Back to Top

Error Conditions

Specified User is Guest User

If the passed-in user is the Guest User (or nil), the method calls the required block with a result whose result.failure matcher is yielded a code: of :invalid_user. No new Entity with updated values is created; no changes are made to the Repository.

Back to Top

Invalid Current Clear-Text Password

If the specified Current Clear-Text Password cannot Authenticate against the encrypted value within the user Entity, the method calls the required block with a result whose result.failure matcher is yielded a code: of :bad_password. No new Entity with updated values is created; no changes are made to the Repository.

Back to Top

Generate Password Reset Token and Password Reset: Introduction

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 such a message, often in the form of an HTML anchor link within an email that you construct. It also provides 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 cannot be later reused by unauthorised persons).

Note that multiple successful calls to generate a new Password Reset Token for a single User overwrite the data generated by previous calls, invalidating the previously-generated Tokens and resetting the expiry.

Back to Top

Generate Password Reset Token

Method involved:

module CryptIdent
  def generate_reset_token(user_name, current_user: nil)
    # ...
  end
end

Successfully Generating a Token

Given a user_name parameter that specifies an existing User Name, and a current_user: parameter that is either nil or the Guest User, the method calls the required block with a result whose result.success matcher is yielded a user: parameter with a User Entity as its value. That User will be an Entity whose name matches the specified user_name parameter, with (new) values for the token and password_reset_expires_at attributes. The token attribute uniquely identifies the Password Reset request, and the password_reset_expires_at attribute is based on both the current (server-local) time when the updated User Entity was persisted to the Repository, and the :reset_expiry attribute of the configuration.

Back to Top

Error Conditions

Authenticated User Exists

If the specified current_user: parameter is a valid User Entity other than the Guest User, then that is presumed to be the Current User of the Application. Authenticated Users are prohibited from requesting Password Resets for other Users; if they wish to change their own Clear-Text Password, there's a method for that.

In this case, the required block will be passed a result whose result.failure matcher is yielded a code: parameter of :user_logged_in, a current_user: parameter matching the passed-in User Entity, and a name: parameter of :unassigned (which must be included in the block parameters but can be ignored thereafter).

Named User Not Found in Repository

If the specified user_name parameter value does not match the name of any User in the Repository, then the required block will be passed a result whose result.failure matcher is yielded a code: parameter of :user_not_found, a current_user: parameter of the Guest User, and a name: parameter whose value is the passed-in user_name value.

Back to Top

Password Reset

Overview

Method involved:

module CryptIdent
  def reset_password(token, new_password, current_user: nil)
    # ...
  end 
end

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.

Back to Top

Successfully Resetting a Password

To successfully perform a Password Reset, supply a valid, non-expired Token along with a new Clear-Text Password to the #reset_password method. Once the Token is found in the configuration-default Repository, and is verified not to have Expired, then the Repository will be updated with a record for that User where the password_hash field has been updated to reflect the new Clear-Text Password, and the token and password_reset_expires_at fields will be set to nil.

If all the preceding is successful and 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. From that point, the User is able to Sign In using the User Name and updated Clear-Text Password.

Client code must take care not to try to Authenticate using the Encrypted Password in the Entity passed in to this method, as it is no longer current. Either retain the returned User Entity from the method, or read it again from the Repository.

Back to Top

Error Conditions

Expired Token

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 this method calls the required block with a result whose result.failure matcher is yielded a code: parameter of :expired_token and a token: parameter that has the same value as the passed-in token parameter.

Back to Top

Token Not Found

If the passed-in token parameter does not match the token field of any record in the Repository, then this method calls the required block with a result whose result.failure matcher is yielded a code: parameter of :token_not_found and a token: parameter that has the same value as the passed-in token parameter.

Invalid Current User

If the passed-in current_user: parameter is not either the default nil or the Guest User, then this method calls the required block with a result whose result.failure matcher is yielded a code: parameter of :invalid_current_user and a token: parameter that has the same value as the passed-in token parameter.

Back to Top

Session Management Overview

Session management is a necessary part of implementing authentication (and authorisation) within an app. However, it's not something that an authentication library can fully implement without making the client application excessively inflexible and brittle.

CryptIdent has two convenience methods which help in implementing session-expiration logic; these make use of the session_expiry configuration value.

  • CryptIdent#update_session_expiry returns a Hash whose :expires_at value the current time plus the number of seconds specified by the session_expiry configuration value. This can be used to update the corresponding session data which defines the session-expiry time;
  • CryptIdent#session_expired? returns true if the current time is not less than the session-expiry time; it returns false otherwise.

Example code which uses these methods is illustrated below, as a shared-code module that may be included in your controllers' action classes:

# apps/web/controllers/handle_session.rb

module Web
  module HandleSession
    include CryptIdent

    def self.included(other)
      other.class_eval do
        before :validate_session
      end
    end

    private

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

      @redirect_url ||= routes.root_path
      config = CryptIdent.config
      session[:current_user] = config.guest_user
      session[:expires_at] = updates[:expires_at]
      error_message = 'Your session has expired. You have been signed out.'
      flash[config.error_key] = error_message
      redirect_to @redirect_url
    end
  end
end

This code should be fairly self-explanatory. Including the module adds the private #validate_session method to the client controller action class, adding a call to that method before the action class' #call method is entered. (One can argue that this violates the spirit if not the letter of the Hanami Guide's warning not to "use callbacks for model domain logic operations". We would argue that this callback's functionality is common to essentially all client applications and, by providing a reference example, allows individual project teams to modify it as required for their use.) If the session-expiry time has been previously set and is not before the current time, then that session-expiry time is reset based on the current time, and no further action is taken. Otherwise:

  1. The current_user setting in the session data is overwritten with the config.guest_user value (defaulting to nil);
  2. A flash error message is set, which should be rendered within the controller action's view; and
  3. Control is redirected to the path or URL specified by @redirect_url, defaulting to the root path (/).

This code will be instantly familiar to anyone coming from another framework like Rails, where the conventional way to ensure authentication before a controller action is executed is to add a :before hook. Adding this module to the controller action class is also justifiable Hanami, since it depends on and interacts with session data. (Just don't let any actual domain logic taint your controller callbacks; that's begging for difficult-to-debug problems going forward.

Back to Top

Session Expired

Method involved:

module CryptIdent
  def session_expired?(session_data={})
    # ...
  end 
end

This is one of two methods in CryptIdent (the other being #update_session_expiry, below) which does not follow the result/success/failure monad workflow. Like that method:

  • there is no success/failure division in the workflow;
  • calling this method only makes sense if there is an Authenticated User;
  • it is intended for use in session-management code as described in the Overview above.

This method checks the passed-in session_data[:start_time] value against the current time. If the difference is greater than the configured Session Expiry value, then the method returns true; otherwise, it returns false. No change is attempted to the contents of the passed-in session_data.

Back to Top

Update Session Expiry

Overview

Method involved:

module CryptIdent
  def update_session_expiry(session_data={})
    # ...
  end 
end

This is one of two methods in CryptIdent (the other being #session_expired?, above) 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 an Authenticated User, but all this method does is return a Hash as defined below.

It is intended for use in session-management code as described in the Overview above.

Parameter

The parameter, session_data, is a Hash-like object which should have existing entries for :current_user (defaulting to the Guest User if not found) and for :expires_at (defaulting to the epoch if not found).

Return

The return value is a Hash which:

  1. :current_user value is the same as the passed-in parameter's :current_user value if that is a Registered User, or the Guest User if it isn't; and
  2. start_time value is a Time instance based on the current time when called.

Back to Top

API Documentation

See the Documentation Index.

Back to Top

Development

After checking out the repo, run bin/setup to install dependencies. If you use rbenv and rbenv-gemset, the setup script will create a new Gemset (in ./tmp/gemset) to keep your system Gem repository pristine. Then, run bin/rake test to run the tests, or bin/rake without arguments to run tests and all static-analysis tools (Flog, Flay, Reek, and RuboCop). Running bin/rake inch will let Inch comment on the amount of internal documentation in the project.

You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bin/rake install or bundle exec rake install. To release a new version, update the version number in version.rb, and then run bin/rake release or bundle exec rake release, which will create a Git tag for the version, push Git commits and tags, and push the .gem file to rubygems.org.

Back to Top

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/jdickey/crypt_ident. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.

Back to Top

Copyright and License

This Gem, its source code, and all supporting documents and artefacts are Copyright ©2019 by Jeff Dickey. They are available as open source under the terms of the MIT License.

Back to Top

Code of Conduct

Everyone interacting in the CryptIdent project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.

Back to Top