Make devise work with Rails application/API at the same time
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 modified the
respondfunction to have thejson_auth?check:
def respond
if http_auth?
http_auth
elsif warden_options[:recall]
recall
elsif json_auth?
unauthenticated_json
else
redirect
end
end
- Implementation of
json_auth?:
def json_auth?
request.headers['Content-Type'] == 'application/json'
end
- Implementation of
unauthenticated_json:
def unauthenticated_json
self.status = 401
self.content_type = 'application/json'
self.response_body = i18n_message.to_json
end
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.
I'm agree with you, why ? :)