heartcombo / devise

Flexible authentication solution for Rails with Warden.
http://blog.plataformatec.com.br/tag/devise/
MIT License
23.89k stars 5.54k forks source link

Make devise work with Rails application/API at the same time #5585

Open saied2035 opened 1 year ago

saied2035 commented 1 year ago

Hello Everyone,

Devise is one of my favorite gems. It has a lot of features, and it makes authentication easier for Rails applications. However, some code should be overridden to make it work with Rails API (JSON requests). My question is that why doesn't devise have the ability to handle API requests by default? Would it cause security issues?

I spent some time adding the ability to respond to API requests without changing any core logic. Could you take a look at some samples of code below?:

app/helpers/devise/sessions_helper.rb

module Devise::SessionsHelper
  def html_new
    self.resource = resource_class.new(sign_in_params)
    clean_up_passwords(resource)
    yield resource if block_given?
    respond_with(resource, serialize_options(resource))
  end

  def json_new
    render "There is no GET API request"
  end

  def html_create
    self.resource = warden.authenticate!(auth_options)
    set_flash_message!(:notice, :signed_in)
    sign_in(resource_name, resource)
    yield resource if block_given?
    respond_with resource, location: after_sign_in_path_for(resource)
  end

  def json_create
    self.resource = warden.authenticate!(auth_options)
    sign_in(resource_name, resource)
    render json: find_message(:signed_in)
  end

end

app/controllers/devise/sessions_controller.rb

# frozen_string_literal: true

class Devise::SessionsController < DeviseController
  prepend_before_action :require_no_authentication, only: [:new, :create]
  prepend_before_action :allow_params_authentication!, only: :create
  prepend_before_action :verify_signed_out_user, only: :destroy
  prepend_before_action(only: [:create, :destroy]) { request.env["devise.skip_timeout"] = true }

  include Devise::SessionsHelper
  # GET /resource/sign_in
  def new
    respond_to do |format|
      format.json { json_new }
      format.html { html_new }
    end 
  end

  # POST /resource/sign_in
  def create 
    respond_to do |format|
      format.json { json_create }
      format.html { html_create }
    end 
  end

  def failure
    auth_keys = resource_class.authentication_keys
    keys = (auth_keys.respond_to?(:keys) ? auth_keys.keys : auth_keys).map { |key| resource_class.human_attribute_name(key) }
    authentication_keys = keys.join(I18n.translate(:"support.array.words_connector"))
    render json: I18n.t('devise.failure.invalid', authentication_keys: authentication_keys)
  end

  # DELETE /resource/sign_out
  def destroy
    signed_out = (Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name))
    set_flash_message! :notice, :signed_out if signed_out
    yield if block_given?
    respond_to_on_destroy  
  end

  protected

  def sign_in_params
    devise_parameter_sanitizer.sanitize(:sign_in)
  end

  def serialize_options(resource)
    methods = resource_class.authentication_keys.dup
    methods = methods.keys if methods.is_a?(Hash)
    methods << :password if resource.respond_to?(:password)
    { methods: methods, only: [:password] }
  end

  def auth_options
    respond_to do |format|
      format.json { { scope: resource_name, recall: "#{controller_path}#failure" } }
      format.html { { scope: resource_name, recall: "#{controller_path}#new" } }
    end
  end

  def translation_scope
    'devise.sessions'
  end

  private

  # Check if there is no signed in user before doing the sign out.
  #
  # If there is no signed in user, it will set the flash message and redirect
  # to the after_sign_out path.
  def verify_signed_out_user
    if all_signed_out?
      set_flash_message! :notice, :already_signed_out

      respond_to_on_destroy
    end
  end

  def all_signed_out?
    users = Devise.mappings.keys.map { |s| warden.user(scope: s, run_callbacks: false) }

    users.all?(&:blank?)
  end

  def respond_to_on_destroy
    # We actually need to hardcode this as Rails default responder doesn't
    # support returning empty response on GET request
    respond_to do |format|
      format.json { render json: find_message(:signed_out) }
      format.all { head :no_content }
      format.any(*navigational_formats) { redirect_to after_sign_out_path_for(resource_name), status: Devise.responder.redirect_status }
    end
  end
end

app/helpers/devise/registrations_helper.rb

module Devise::RegistrationsHelper
  def html_new
    build_resource
    yield resource if block_given?
    respond_with resource 
  end

  def json_new
    render "There is no GET API request" 
  end

  def html_create
    build_resource(sign_up_params)
    resource.save
    yield resource if block_given?
    if resource.persisted?
      if resource.active_for_authentication?
        set_flash_message! :notice, :signed_up
        sign_up(resource_name, resource)
        respond_with resource, location: after_sign_up_path_for(resource)
      else
        set_flash_message! :notice, :"signed_up_but_#{resource.inactive_message}"
        expire_data_after_sign_in!
        respond_with resource, location: after_inactive_sign_up_path_for(resource)
      end
    else
      clean_up_passwords resource
      set_minimum_password_length
      respond_with resource
    end
  end

  def json_create
    build_resource(sign_up_params)
    resource.save
    if resource.persisted?
      if resource.active_for_authentication?
        sign_up(resource_name, resource)
        render json: find_message(:signed_up)
      else
        expire_data_after_sign_in!
        render json: find_message(:"signed_up_but_#{resource.inactive_message}")
      end
    else
      render json: resource.errors.full_messages
    end 
  end

  def html_update
    self.resource = resource_class.to_adapter.get!(send(:"current_#{resource_name}").to_key)
    prev_unconfirmed_email = resource.unconfirmed_email if resource.respond_to?(:unconfirmed_email)

    resource_updated = update_resource(resource, account_update_params)
    yield resource if block_given?
    if resource_updated
      set_flash_message_for_update(resource, prev_unconfirmed_email)
      bypass_sign_in resource, scope: resource_name if sign_in_after_change_password?

      respond_with resource, location: after_update_path_for(resource)
    else
      clean_up_passwords resource
      set_minimum_password_length
      respond_with resource
    end
  end

  def json_update
    self.resource = resource_class.to_adapter.get!(send(:"current_#{resource_name}").to_key)
    prev_unconfirmed_email = resource.unconfirmed_email if resource.respond_to?(:unconfirmed_email)

    resource_updated = update_resource(resource, account_update_params)
    if resource_updated
      key = if update_needs_confirmation?(resource, prev_unconfirmed_email)
              :update_needs_confirmation
            elsif sign_in_after_change_password?
              :updated
            else
              :updated_but_not_signed_in
            end
      bypass_sign_in resource, scope: resource_name if sign_in_after_change_password?
       render json: find_message(key)
    else
      render json: resource.errors.full_messages
    end
  end

  def html_destroy
    resource.destroy
    Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name)
    set_flash_message! :notice, :destroyed
    yield resource if block_given?
    respond_with_navigational(resource){ redirect_to after_sign_out_path_for(resource_name), status: Devise.responder.redirect_status }
  end

  def json_destroy
    resource.destroy
    Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name)
    render json: find_message(:destroyed), status: Devise.responder.redirect_status
  end

end

app\controllers\devise\registrations_controller.rb

# frozen_string_literal: true

class Devise::RegistrationsController < DeviseController
  prepend_before_action :require_no_authentication, only: [:new, :create, :cancel]
  prepend_before_action :authenticate_scope!, only: [:edit, :update, :destroy]
  prepend_before_action :set_minimum_password_length, only: [:new, :edit]

  include Devise::RegistrationsHelper
  # GET /resource/sign_up
  def new
    respond_to do |format|
      format.json { json_new }
      format.html { html_new }
    end 
  end

  # POST /resource/sign_in
  def create 
    respond_to do |format|
      format.json { json_create }
      format.html { html_create }
    end 
  end

  # GET /resource/edit
  def edit
   render :edit
  end

  # PUT /resource
  # We need to use a copy of the resource because we don't want to change
  # the current user in place.
  def update
    respond_to do |format|
      format.json { json_update }
      format.html { html_update }
    end
  end

  # DELETE /resource
  def destroy
    respond_to do |format|
      format.json { json_destroy }
      format.html { html_destroy }
    end
  end

  # GET /resource/cancel
  # Forces the session data which is usually expired after sign
  # in to be expired now. This is useful if the user wants to
  # cancel oauth signing in/up in the middle of the process,
  # removing all OAuth session data.
  def cancel
    expire_data_after_sign_in!
    redirect_to new_registration_path(resource_name)
  end

  protected

  def update_needs_confirmation?(resource, previous)
    resource.respond_to?(:pending_reconfirmation?) &&
      resource.pending_reconfirmation? &&
      previous != resource.unconfirmed_email
  end

  # By default we want to require a password checks on update.
  # You can overwrite this method in your own RegistrationsController.
  def update_resource(resource, params)
    resource.update_with_password(params)
  end

  # Build a devise resource passing in the session. Useful to move
  # temporary session data to the newly created user.
  def build_resource(hash = {})
    self.resource = resource_class.new_with_session(hash, session)
  end

  # Signs in a user on sign up. You can overwrite this method in your own
  # RegistrationsController.
  def sign_up(resource_name, resource)
    sign_in(resource_name, resource)
  end

  # The path used after sign up. You need to overwrite this method
  # in your own RegistrationsController.
  def after_sign_up_path_for(resource)
    after_sign_in_path_for(resource) if is_navigational_format?
  end

  # The path used after sign up for inactive accounts. You need to overwrite
  # this method in your own RegistrationsController.
  def after_inactive_sign_up_path_for(resource)
    scope = Devise::Mapping.find_scope!(resource)
    router_name = Devise.mappings[scope].router_name
    context = router_name ? send(router_name) : self
    context.respond_to?(:root_path) ? context.root_path : "/"
  end

  # The default url to be used after updating a resource. You need to overwrite
  # this method in your own RegistrationsController.
  def after_update_path_for(resource)
    sign_in_after_change_password? ? signed_in_root_path(resource) : new_session_path(resource_name)
  end

  # Authenticates the current scope and gets the current resource from the session.
  def authenticate_scope!
    send(:"authenticate_#{resource_name}!", force: true)
    self.resource = send(:"current_#{resource_name}")
  end

  def sign_up_params
    devise_parameter_sanitizer.sanitize(:sign_up)
  end

  def account_update_params
    devise_parameter_sanitizer.sanitize(:account_update)
  end

  def translation_scope
    'devise.registrations'
  end

  private

  def set_flash_message_for_update(resource, prev_unconfirmed_email)
    return unless is_flashing_format?

    flash_key = if update_needs_confirmation?(resource, prev_unconfirmed_email)
                  :update_needs_confirmation
                elsif sign_in_after_change_password?
                  :updated
                else
                  :updated_but_not_signed_in
                end
    set_flash_message :notice, flash_key
  end

  def sign_in_after_change_password?
    return true if account_update_params[:password].blank?

    Devise.sign_in_after_change_password
  end
end

In lib\devise\failure_app.rb :

I would like to discuss if it would be possible to add this code to the main branch to make the devise handle Rails applications/APIs the same way.

gloufy commented 2 months ago

I'm agree with you, why ? :)

d22046 commented 1 week ago

I'm also working on a Rails API with devise and spent a long time trying to find documentation to override helpers like respond_with and respond_to_on_destroy. Feel like this needs more attention.