Skip to content

The Common Authentication Base Module, Annotated

Jeff Dickey edited this page Mar 1, 2019 · 1 revision

This is an annotation of the code of a possible CommonAuthn module, suitable for use by a Web app. It presumes that the Hanami [Controller Action Class|(https://guides.hanamirb.org/actions/overview/) (or CAC) which (indirectly) includes this module:

  1. responds to the #flash method;
  2. reponds to the #routes method;
  3. has a :root route

This module also makes use of a @redirect_url instance variable, which will be defined if it does not already esist. Finally, it exposes a @current_user variable from the including CAC.

NOTE that the annotations will be as comments beginning with NOTE:. Other comments have (mostly) been removed from the in-use module from which this was created.

Without further ado...

# frozen_string_literal: true

require 'crypt_ident'

module Web
  # @api private
  # NOTE: The above is a [YARD](https://yardoc.org/) annotation which by default
  #   removes this module from the documentation generated for your app. (You
  #   *are* documenting your code, aren't you?)
  module CommonAuthn
    # NOTE: This method is called from the `self.included` method of the various
    #   modules (e.g., `ProhibitGuest`) which provide the high-level "drop-in"
    #   functionality you'd normally use in a CAC.
    def self.setup_callbacks(other)
      other.class_eval do
        include CryptIdent
        before :authenticate!
        expose :current_user
      end
    end

    private

    # NOTE: An actual [halt](https://guides.hanamirb.org/actions/control-flow/#halt)
    #   is pretty abrupt for a Web app, though it's common in JSON-serving
    #   back-end API implementations. Instead, the usual practice is to simply
    #   expire the session (if any) and redirect back to the landing page or
    #   wherever else you've pointed `@redirect_url` to.
    def authenticate!
      expire_unless_authenticated
      # Alternatively...
      # halt_unless_authenticated
    end

    # NOTE: This is the core question of the module: is the User properly
    #   Authenticated and the session still unexpired?
    def authenticated?
      return false unless session_current_user_valid?

      session_still_valid?
    end

    # NOTE: If you're feeling properly modular, you may want to break this
    #   method and the three methods it calls out into its own class, of which
    #   the current `#expire_session` need be the only public method. Having
    #   done so here, however, would complicate this annotation.
    def expire_session
      config = CryptIdent.config
      update_expired_session_data(config)
      flash_for_expiry(config)
      redirect_for_session_expiry
    end

    # NOTE: One of the two candidate methods called from `#authenticate!` to
    #   verify that all is still as it should be with the Current User and the
    #   session-expiry data. (The other is `#halt_unless_authenticated`, below.)
    def expire_unless_authenticated
      expire_session unless authenticated?
    end

    # NOTE: Yes, this is a hard-coded, non-i18n message string. You can do
    #   better, but this gets the point across for the current purpose. Also
    #   note that we make use of the `:error_key` configuration value.
    def flash_for_expiry(config)
      error_message = 'Your session has expired. You have been signed out.'
      flash[config.error_key] = error_message
    end

    # NOTE: One of the two candidate methods called from `#authenticate!` to
    #   verify that all is still as it should be with the Current User and the
    #   session-expiry data. (See also `#expire_unless_authenticated`, above.)
    def halt_unless_authenticated
      halt 401 unless authenticated?
    end

    # NOTE: Called from `#expire_session`, above. Does what it says on the tin.
    def redirect_for_session_expiry
      @redirect_url ||= routes.root_path
      redirect_to @redirect_url
    end

    # NOTE: Also called from `expire_session`; setting the entries in the
    #   `session` Hash-like object is what tells us we don't have a Current User
    #   (if we'd had one previously). Note that `#update_session_expiry` is a
    #   method defined in the `CryptIdent` API itself; we haven't seen too many
    #   of those as yet.
    def update_expired_session_data(config)
      session[:current_user] = config.guest_user
      updates = update_session_expiry(session)
      session[:expires_at] = updates[:expires_at]
    end

    def session_current_user_valid?
      # NOTE: Retrieving an Entity from session data? If you stored an Entity
      #   value, what you'll get back is a *Hash* of the Entity's attribute
      #   values. Probably not what you want, especially if you're going to
      #   immediately call a method on the purported object (to which `Hash`
      #   does not respond).
      attrs = user_attrs_from_session
      valid_current_user?(attrs).tap do |ret|
        @current_user = User.new(attrs) if ret
      end
    end

    # NOTE: If we get here, we're an Authenticated Member; has our session
    #   expired? If we don't have a `session[:expires_at]` value, that means
    #   this is the first time `#authenticated?` has been called since the
    #   Member successfully Authenticated. If the expiry timestamp has *not*
    #   passed, then we update it (restarting the countdown, as it were). Only
    #   if we had previously set the expiry timestamp *and* it has expired,
    #   will the method return false.
    def session_still_valid?
      updates = update_session_expiry(session)
      return false if _session_expiry_check(session)

      session[:expires_at] = updates[:expires_at] # update previously-set value
      true
    end

    def _session_expiry_check(updates)
      session[:expires_at] ||= updates[:expired_at] # initialise if not set
      session_expired?(session)
    end

    # NOTE:We store a *Hash of attributes* in `session[:current_user]` rather
    #   than the object itself. That is because `Rack::Session` doesn't support
    #   arbitrary type serialisation/de-serialisation; it apparently predates
    #   [marshaling](https://ruby-doc.org/core-2.6.1/Marshal.html) in Ruby core.
    def user_attrs_from_session
      repo = CryptIdent.config.repository
      session[:current_user] || repo.guest_user.to_h
    end
  end # module Web::CommonAuthn
end