dynsne
dynsne

Reputation: 341

Devise: Authenticate one model through another

Say I have a simple User model:

class User < ApplicationRecord    
  devise :database_authenticatable, :registerable, :recoverable, :rememberable,
         :trackable, :validatable

  belongs_to :company
end

As well as a model for companies:

class Company < ApplicationRecord
  has_many :users

  validates :admin, :name, presence: true

  delegate :email, to: :admin

  def email=(email)
    self.admin = User.find_by(email: email) ||
                 User.new(email: email)
  end
end

With routes like:

Rails.application.routes.draw do
  # API

  namespace :api, defaults: { format: "json" } do
    namespace :v1 do
      # Companies
      resources :companies

      # Users
      resources :users, except: :destroy

      devise_scope :user do
        post "/users/sign_in", to: "sessions#create"
      end
    end
  end
end

With a SessionsController that looks like:

module Api
  module V1
    class SessionsController < Devise::SessionsController
      rescue_from ActionController::ParameterMissing, with: :invalid_login

      skip_before_action :verify_authenticity_token, only: :create
      protect_from_forgery with: :null_session
      respond_to :json

      def create
        @user = Forms::SigninForm.new(signin_params)

        if @user.save
          @user = @user.user
          render "api/v1/users/show"
        else
          invalid_login(@user.errors)
        end
      end

      def destroy
        sign_out(resource_name)
      end

      def invalid_login(messages)
        render json: { errors: messages }
      end

      private

      def signin_params
        user_params.merge(auth_options: auth_options)
      end

      def user_params
        params.require(:user)
              .permit(:email, :password)
              .merge(warden: warden)
      end
    end
  end
end

Where the SigninForm is defined like:

module Forms
  class SigninForm < BaseFormObject
    validate :user_exists

    attr_reader :email, :password, :warden

    def save
      return false if invalid?

      ActiveRecord::Base.transaction do
        user.auth_tokens.create(portal: portal_request?)
        unless portal_request?
          user
            .auth_tokens
            .second_to_last&.
            destroy
        end
      end

      true
    end

    def user
      @user ||=
        begin
          warden.authenticate(auth_options) ||
            User.find_by(admin_password_query, email, password)
        end
    end

    private

    def admin_password_query
      "email ilike ? and admin_password = ?".freeze
    end

    def user_exists
      return if user.present?
      errors.add(:user, "Invalid email or password.")
    end

    def portal_request?
      @portal_request
    end

    def user_params
      {
        email: email,
        password: password
      }
    end

    def auth_options
      {
        scope: @scope,
        recall: @recall
      }
    end
  end
end

Is there a simple and straight forward way to delegate authentication for a Company to it's admin? In such a way that, when a company is created, the associated User admin is the one who holds the auth token and password, and when the a user would like to sign in as a company - the password is checked against the user model, not the company.

Is there a way to, perhaps, use a custom devise strategy?

I hope my question is clear. Thanks for reading.

Upvotes: 1

Views: 766

Answers (2)

dynsne
dynsne

Reputation: 341

To update, I came up with a solution that was simple enough, at least for me. During that time I learned about warden's use of the strategy pattern, which allowed me to refactor the SigninForm by pulling out any of means of authentication seen in the form object (i.e: finding user by email and an "admin password") into actual warden strategies, registering them with warden's Manager middleware, and then simply listing the authentication strategies used in the SigninForm that we finally pass to warden's Proxy class which runs these strategies until one succeeds (or they all fail).

So, we can define strategies like:

# in /config/initializers/auth_strategies.rb

require "devise/strategies/authenticatable"

module Devise
  module Strategies
    class AdminAuthenticatable < Authenticatable
      def valid?
        params[:user] && params[:email] || params[:password]
      end

      def authenticate!
        pass unless params[:user]

        user = User.find_by("email ilike ? and admin_password = ?",
                            params[:email],
                            params[:password])

        success!(user) if user
        fail!
      end
    end

    class CompanyAuthenticatable < Authenticatable
      def valid?
        params[:company]
      end

      def authenticate!
        user = User.find_by_email(params[:company][:email])
        if user&.valid_password?(params[:company][:password])
          if Company.find_by(admin_id: user.id)
            success!(user)
          else
            fail!
          end
        else
          fail!
        end
      end
    end
  end
end

Register them with the Manager middleware:

# in /config/initializers/devise.rb
Devise.setup do |config|
   config.warden do |manager|
     manager.strategies.add(
       :company_authenticatable,
       Devise::Strategies::CompanyAuthenticatable
     )
     manager.strategies.add(
       :admin_authenticatable,
       Devise::Strategies::AdminAuthenticatable
     )
   end
end

Finally, refactoring the form object and simply passing in used strategies to warden there:

module Forms
  class SigninForm < BaseFormObject
    validate :user_exists
    validate :user_administrates_company, if: :company_signin?

    attr_reader :email, :password, :warden

    def save
      return false if invalid?

      ActiveRecord::Base.transaction do
        user.auth_tokens.create(portal: portal_request?)
        unless portal_request?
          user
            .auth_tokens
            .second_to_last&.
            destroy
        end
      end

      true
    end

    def user
      @user ||=
        begin
          warden.authenticate(*strategies, auth_options)
        end
    end

    def company
      @company ||=
        user && Company.find_by(admin_id: user&.id)
    end

    private

    def user_exists
      return if user.present?
      errors.add(:user, "Invalid email or password.")
    end

    def user_administrates_company
      return if company.present?
      errors.add(:company, "Invalid email or password.")
    end

    def portal_request?
      @portal_request
    end

    def company_signin?
      @resource.eql?("company")
    end

    protected

    def strategies
      %i[database_authenticatable admin_authenticatable company_authenticatable]
    end

    def auth_options
      {}
    end
  end
end

Upvotes: 0

Simon Franzen
Simon Franzen

Reputation: 2727

It is possible to add more authenticated models to devise. https://github.com/plataformatec/devise/wiki/How-to-Setup-Multiple-Devise-User-Models

You could than create a custom action, where you call sign_in(@company). Only allow this action for admins of the company. Thats it!

Upvotes: 1

Related Questions