adamcooke/authie

View on GitHub
lib/authie/session.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
# frozen_string_literal: true

require 'authie/session_model'
require 'authie/error'
require 'authie/config'
require 'active_support/core_ext/module/delegation'

module Authie
  class Session
    # The underlying session model instance
    #
    # @return [Authie::SessionModel]
    attr_reader :session

    # A parent class that encapsulates all session validity errors.
    class ValidityError < Error
      attr_reader :session

      def initialize(message, session = nil)
        super(message)
        @session = session
      end
    end

    # Raised when a session is used but it is no longer active
    class InactiveSession < ValidityError; end

    # Raised when a session is used but it has expired
    class ExpiredSession < ValidityError; end

    # Raised when a session is used but the browser ID does not match
    class BrowserMismatch < ValidityError; end

    # Raised when a session is used but the hostname does not match
    # the session hostname
    class HostMismatch < ValidityError; end

    # Initialize a new session object
    #
    # @param controller [ActionController::Base] any controller
    # @param session [Authie::SessionModel] an Authie session model instance
    # @return [Authie::Session]
    def initialize(controller, session)
      @controller = controller
      @session = session
    end

    # Validate that the session is valid and raise and error if not
    #
    # @raises [Authie::Session::BrowserMismatch]
    # @raises [Authie::Session::InactiveSession]
    # @raises [Authie::Session::ExpiredSession]
    # @raises [Authie::Session::HostMismatch]
    # @return [Authie::Session]
    def validate
      validate_browser_id
      validate_active
      validate_expiry
      validate_inactivity
      validate_host
      self
    end

    # Mark the current session as persistent. Will set the expiry time of the underlying
    # session and update the cookie.
    #
    # @raises [ActiveRecord::RecordInvalid]
    # @return [Authie::Session]
    def persist
      @session.expires_at = Authie.config.persistent_session_length.from_now
      @session.save!
      set_cookie
      self
    end

    # Invalidates the current session by marking it inactive and removing the current cookie.
    #
    # @raises [ActiveRecord::RecordInvalid]
    # @return [Authie::Session]
    def invalidate
      @session.invalidate!
      cookies.delete(:user_session)
      self
    end

    # Touches the current session to ensure it is currently valid and to update attributes
    # which should be updatd on each request. This will raise the same errors as the #validate
    # method. It will set the last activity time, IP and path as well as incrementing
    # the request counter.
    #
    # @raises [Authie::Session::BrowserMismatch]
    # @raises [Authie::Session::InactiveSession]
    # @raises [Authie::Session::ExpiredSession]
    # @raises [Authie::Session::HostMismatch]
    # @raises [ActiveRecord::RecordInvalid]
    # @return [Authie::Session]
    def touch
      @session.last_activity_at = Time.now
      if @controller.request.ip != @session.last_activity_ip
        @session.last_activity_ip_country = Authie.config.lookup_ip_country(@controller.request.ip)
      end
      @session.last_activity_ip = @controller.request.ip

      @session.last_activity_path = @controller.request.path
      @session.requests += 1
      extend_session_expiry_if_appropriate
      @session.save!
      Authie.notify(:touch, session: self)
      self
    end

    # Mark the session's password as seen at the current time
    #
    # @raises [ActiveRecord::RecordInvalid]
    # @return [Authie::Session]
    def see_password
      @session.password_seen_at = Time.now
      @session.save!
      Authie.notify(:see_password, session: self)
      self
    end

    # Mark this request as two factored by setting the time and the current
    # IP address.
    #
    # @raises [ActiveRecord::RecordInvalid]
    # @return [Authie::Session]
    def mark_as_two_factored(skip: nil)
      @session.two_factored_at = Time.now
      @session.two_factored_ip = @controller.request.ip
      @session.two_factored_ip_country = Authie.config.lookup_ip_country(@controller.request.ip)
      @session.skip_two_factor = skip unless skip.nil?
      @session.save!
      Authie.notify(:mark_as_two_factor, session: self)
      self
    end

    # Starts a new session by setting the cookie. This should be invoked whenever
    # a new session begins. It usually does not need to be called directly as it
    # will be taken care of by the class-level start method.
    #
    # @return [Authie::Session]
    def start
      set_cookie
      Authie.notify(:session_start, session: self)
      self
    end

    # Resets the token for the currently active session to a new string
    #
    # @return [Authie::Session]
    def reset_token
      @session.reset_token
      set_cookie
      self
    end

    private

    def set_cookie(value = @session.temporary_token)
      cookies[:user_session] = {
        value: value,
        secure: @controller.request.ssl?,
        httponly: true,
        expires: @session.expires_at
      }
      Authie.notify(:cookie_updated, session: session)
      true
    end

    def cookies
      @controller.send(:cookies)
    end

    def validate_browser_id
      if cookies[:browser_id] != @session.browser_id
        Authie.notify(:browser_id_mismatch_error, session: self)
        invalidate
        raise BrowserMismatch.new('Browser ID mismatch', self)
      end

      self
    end

    def validate_active
      unless @session.active?
        invalidate
        Authie.notify(:invalid_session_error, session: self)
        raise InactiveSession.new('Session is no longer active', self)
      end

      self
    end

    def validate_expiry
      if @session.expired?
        invalidate
        Authie.notify(:expired_session_error, session: self)
        raise ExpiredSession.new('Persistent session has expired', self)
      end

      self
    end

    def validate_inactivity
      if @session.inactive?
        invalidate
        Authie.notify(:inactive_session_error, session: self)
        raise InactiveSession.new('Non-persistent session has expired', self)
      end

      self
    end

    def validate_host
      if @session.host && @session.host != @controller.request.host
        invalidate
        Authie.notify(:host_mismatch_error, session: self)
        raise HostMismatch.new("Session was created on #{@session.host} but accessed using #{@controller.request.host}",
                               self)
      end

      self
    end

    def extend_session_expiry_if_appropriate
      return if @session.expires_at.nil?
      return unless Authie.config.extend_session_expiry_on_touch

      # If enabled, sessions with an expiry time will automatiaclly be incremented
      # whenever a page is touched. The cookie will also be updated as appropriate.
      @session.expires_at = Authie.config.persistent_session_length.from_now
      set_cookie
    end

    class << self
      # Create a new session within the given controller for the
      #
      # @param controller [ActionController::Base]
      # @param user [ActiveRecord::Base] user
      # @param persistent [Boolean] create a persistent session
      # @return [Authie::Session]
      def start(controller, user:, persistent: false, see_password: false, **params)
        cookies = controller.send(:cookies)
        SessionModel.active.where(browser_id: cookies[:browser_id]).each(&:invalidate!)

        session = SessionModel.new(params)
        session.user = user
        session.browser_id = cookies[:browser_id]
        session.login_at = Time.now
        session.login_ip = controller.request.ip
        session.login_ip_country = Authie.config.lookup_ip_country(session.login_ip)
        session.host = controller.request.host
        session.user_agent = controller.request.user_agent
        session.expires_at = Time.now + Authie.config.persistent_session_length if persistent
        session.password_seen_at = Time.now if see_password
        session.save!

        new(controller, session).start
      end

      # Lookup a session for a given controller and return the session
      # object.
      #
      # @param controller [ActionController::Base]
      # @return [Authie::Session]
      def get_session(controller)
        cookies = controller.send(:cookies)
        return nil if cookies[:user_session].blank?

        session = SessionModel.find_session_by_token(cookies[:user_session])
        return nil if session.blank?

        session.temporary_token = cookies[:user_session]
        new(controller, session)
      end

      delegate :hash_token, to: SessionModel
      delegate :cleanup, to: SessionModel
    end

    # Backwards compatibility with Authie < 4.0. These methods were all available on sessions
    # in previous versions of Authie. They have been maintained for backwards-compatibility but
    # will be removed entirely in Authie 5.0.
    alias check_security! validate
    alias persist! persist
    alias invalidate! invalidate
    alias touch! touch
    alias set_cookie! set_cookie
    alias see_password! see_password
    alias mark_as_two_factored! mark_as_two_factored

    # Delegate key methods back to the underlying session model. Previous behaviour in Authie
    # exposed all methods on the session model. It is useful that these methods can be accessed
    # easily from this session proxy model so these are maintained as delegated methods.
    delegate :active?, to: :session
    delegate :browser_id, to: :session
    delegate :expired?, to: :session
    delegate :expires_at, to: :session
    delegate :first_session_for_browser?, to: :session
    delegate :first_session_for_ip?, to: :session
    delegate :get, to: :session
    delegate :inactive?, to: :session
    delegate :invalidate_others!, to: :session
    delegate :last_activity_at, to: :session
    delegate :last_activity_ip, to: :session
    delegate :last_activity_ip_country, to: :session
    delegate :last_activity_path, to: :session
    delegate :login_at, to: :session
    delegate :login_ip, to: :session
    delegate :login_ip_country, to: :session
    delegate :password_seen_at, to: :session
    delegate :persisted?, to: :session
    delegate :persistent?, to: :session
    delegate :recently_seen_password?, to: :session
    delegate :requests, to: :session
    delegate :set, to: :session
    delegate :temporary_token, to: :session
    delegate :token_hash, to: :session
    delegate :two_factored_at, to: :session
    delegate :two_factored_ip, to: :session
    delegate :two_factored_ip_country, to: :session
    delegate :two_factored?, to: :session
    delegate :skip_two_factor?, to: :session
    delegate :update, to: :session
    delegate :update!, to: :session
    delegate :user_agent, to: :session
    delegate :user, to: :session
  end
end