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:
- Registration;
- Signing in;
- Signing out;
- Password change;
- Password reset; and
- Session expiration management.
It does not implement features such as
- Password-strength testing;
- Password occurrence in a list of most popular (and easily hacked) passwords; or
- 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
- Has a class method named
.entity
that returns the class of the Entity used by that Repository (e.g.,User
for aUserRepository
). (This is to matchHanami::Repository
.) - 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 invalidid
attribute); and - Implements the "usual" common methods (
#create
,#update
,#delete
, etc) conforming to an interface compatible withHanami::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.
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.
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.)
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.
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!)
Use-Case Workflows
Registration
Overview
Method involved:
module CryptIdent
def sign_up(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.
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.
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
.
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
.
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
.
Signing In
Overview
Method involved:
module CryptIdent
def sign_in(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.
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.
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
.
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
.
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
.
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 byTime.now
; andsession[: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]
tonil
or to the Guest User (see Configuration).
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
forsession[:current_user]
andHanami::Utils::Kernel.Time(0)
forsession[: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 assigningnil
to them.
The required result.failure
block can simply be skipped, as
result.failure { next }
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 aHash
whose:expires_at
value the current time plus the number of seconds specified by thesession_expiry
configuration value. This can be used to update the correspondingsession
data which defines the session-expiry time;CryptIdent#session_expired?
returnstrue
if the current time is not less than the session-expiry time; it returnsfalse
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]
= 'Your session has expired. You have been signed out.'
flash[config.error_key] =
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:
- The
current_user
setting in the session data is overwritten with theconfig.guest_user
value (defaulting tonil
); - A flash error message is set, which should be rendered within the controller action's view; and
- 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.
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
.
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:
: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; andstart_time
value is aTime
instance based on the current time when called.
API Documentation
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.
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.
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.
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.