API用に別のControllerを作るのが嫌だった(共通化したい)ので、実装済みのControllerでJSONレスポンス、かつ認証での制御(current_user等)が出来るようにしてみました。
Devise Token Authは導入済みの前提です。Devise導入済みのアプリにDevise Token Authを入れて共存させる

気になった事

CSRFトークン検証なくても良いのか?

RailsでAPI作る場合、デフォルトでトークン検証が入っている為、スキップしなと登録や更新はエラーになる。

   skip_before_action :verify_authenticity_token

API専用なら良いが、HTMLと共存している場合、攻撃が成立してしまう。
request.format.json?を条件に入れたとしても、jsonのパス(/xxx.json)にPOSTされたら同じ事。

enable_standard_devise_supportの挙動は?

既存のDevise認証を有効にするには、対象またはApplicationのControllerに下記を入れて、

  include DeviseTokenAuth::Concerns::SetUserByToken

config/initializers/devise_token_auth.rb に下記を追加する。

  config.enable_standard_devise_support = true

既存のDeviseも認証可能になるので、jsonのパスにリクエストされたら、
認証状態でCSRF攻撃が可能になってしまう。
→ jsonのパスではfalseにしたい。

request.format.json?がtrueになる条件は?

RSpecのformatとはちょっと違う。acceptヘッダも見ている。

  get infomations_path

= http://localhost:3000/infomations
request.format.json? = false

  get infomations_path(format: :json)

= http://localhost:3000/infomations.json
request.format.json? = true

  get infomations_path(format: :json), headers: { 'accept' => 'application/json' }

= http://localhost:3000/infomations.json
request.format.json? = true

  get infomations_path, headers: { 'accept' => 'application/json' }

= http://localhost:3000/infomations
request.format.json? = true ← jsonのパスじゃなくてもtrueになる

  get infomations_path, headers: { 'accept' => 'application/json,text/plain,*/*' }

= http://localhost:3000/infomations
request.format.json? = false ← *があるとhtmlが優先される

acceptヘッダの値は?

Chrome

text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9

Safari

text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8

→ jsonのパスはブラウザでは表示させたくない。

その他

curl, wget: */*
ARC(Advanced REST client): <デフォルトは空>

そもそも

APIで更新リクエストを受け付ける場合、フロントページにトークンを入れられない。
API作ってトークン返しても、ブラウザからリクエスト出来るので意味がない。
→ API認証(既存のDevise認証はOFF)であれば良さそう。
(そもそも未認証だったら迷惑だけど、アカウントに紐付かないので実害はない)

XMLに対応しているか?

対応していない。
.xmlにリクエストしても、acceptにapplication/xmlを付けても、JSONが返却されました。
ソースも確認しましたが、xmlの実装はなかった。

実装を直す

今回の対象: app/controllers/infomations_controller.rb

- class InfomationsController < ApplicationController
+ class InfomationsController < ApplicationAuthController
    def index
<省略>
-     return if request.format.json? || @infomations.current_page <= [@infomations.total_pages, 1].max
+     return if @infomations.current_page <= [@infomations.total_pages, 1].max
<省略>
    def show
+     return render json: { success: false, alert: t('errors.messages.infomation.ended') }, status: :not_found if request.format.json?

APIで返却するControllerのみ継承元を変更する。
他のControllerへのAPIリクエストは何もしなくても、406(HTTPステータス)が返却されるので、そのままでOK。

app/views/infomations/index.json.jbuilder を作成

json.success true
json.infomation do
  json.total_count @infomations.total_count
  json.current_page @infomations.current_page
  json.total_pages @infomations.total_pages
  json.limit_value @infomations.limit_value
end
json.infomations do
  json.array! @infomations do |infomation|
    json.id infomation.id
    json.title infomation.title
    json.summary infomation.summary.present? ? infomation.summary : ''
    json.started_at l(infomation.started_at, format: :json)
    json.ended_at infomation.ended_at.present? ? l(infomation.ended_at, format: :json) : ''
    json.target infomation.target
  end
end

app/views/infomations/show.json.jbuilder を作成

json.success true
json.infomation do
  json.title @infomation.title
  json.body @infomation.body.present? ? @infomation.body : ''
  json.started_at l(@infomation.started_at, format: :json)
  json.ended_at @infomation.ended_at.present? ? l(@infomation.ended_at, format: :json) : ''
  json.target @infomation.target
end

app/controllers/application_auth_controller.rb を作成

class ApplicationAuthController < ApplicationController
  include DeviseTokenAuth::Concerns::SetUserByToken
  skip_before_action :verify_authenticity_token, if: :format_api?
  prepend_before_action :not_acceptable_response_not_api_accept, if: :format_api?
  before_action :standard_devise_support

  private

  # URLの拡張子がない場合のみ、Device認証を有効にする(APIでCSRFトークン検証をしない為)
  def standard_devise_support
    DeviseTokenAuth.enable_standard_devise_support = format_html?
  end
end

app/controllers/application_controller.rb に追加

  # URLの拡張子が.jsonか、acceptヘッダにapplication/jsonが含まれるかを返却
  def format_api?
    request.format.json?
  end

  # URLの拡張子がないかを返却
  def format_html?
    request.format.html?
  end

  # acceptヘッダにJSONが含まれるかを返却
  def accept_header_api?
    %r{,application/json[,;]} =~ ",#{request.headers[:ACCEPT]},"
  end

  # acceptヘッダが空か、HTMLが含まれるかを返却
  def accept_header_html?
    request.headers[:ACCEPT].blank? || %r{,text/html[,;]} =~ ",#{request.headers[:ACCEPT]}," || %r{,\*/\*[,;]} =~ ",#{request.headers[:ACCEPT]},"
  end

  # acceptヘッダにJSONが含まれない場合、HTTPステータス406を返却
  def not_acceptable_response_not_api_accept
    head :not_acceptable unless (format_html? || format_api?) && accept_header_api?
  end

  # acceptヘッダにHTMLが含まれない場合、HTTPステータス406を返却
  def not_acceptable_response_not_html_accept
    head :not_acceptable unless format_html? && accept_header_html?
  end

Devise Token AuthのControllerも修正

こっちもjsonのパスか、acceptヘッダにapplication/jsonがない場合、406(HTTPステータス)を返すように変更。

下記、before_actionではなく、prepend_before_actionにしたのはPasswordsControllerで先に404になってしまったからですが、この方が無駄が少なくて良いですね。

app/controllers/users/auth/confirmations_controller.rb

 class Users::Auth::ConfirmationsController < DeviseTokenAuth::ConfirmationsController
   include DeviseTokenAuth::Concerns::SetUserByToken
   skip_before_action :verify_authenticity_token
+  prepend_before_action :not_acceptable_response_not_api_accept, only: %i[create]
+  prepend_before_action :not_acceptable_response_not_html_accept, only: %i[show]

app/controllers/users/auth/passwords_controller.rb

 class Users::Auth::PasswordsController < DeviseTokenAuth::PasswordsController
   include DeviseTokenAuth::Concerns::SetUserByToken
   skip_before_action :verify_authenticity_token
+  prepend_before_action :not_acceptable_response_not_api_accept, only: %i[create update]
+  prepend_before_action :not_acceptable_response_not_html_accept, only: %i[edit]

app/controllers/users/auth/registrations_controller.rb

 class Users::Auth::RegistrationsController < DeviseTokenAuth::RegistrationsController
   include Users::RegistrationsConcern
   include DeviseTokenAuth::Concerns::SetUserByToken
   skip_before_action :verify_authenticity_token
+  prepend_before_action :not_acceptable_response_not_api_accept

app/controllers/users/auth/sessions_controller.rb

 class Users::Auth::SessionsController < DeviseTokenAuth::SessionsController
   include DeviseTokenAuth::Concerns::SetUserByToken
   skip_before_action :verify_authenticity_token
+  prepend_before_action :not_acceptable_response_not_api_accept

app/controllers/users/auth/token_validations_controller.rb

 class Users::Auth::TokenValidationsController < DeviseTokenAuth::TokenValidationsController
   include DeviseTokenAuth::Concerns::SetUserByToken
   skip_before_action :verify_authenticity_token
+  prepend_before_action :not_acceptable_response_not_api_accept

app/controllers/users/auth/unlocks_controller.rb

 class Users::Auth::UnlocksController < DeviseTokenAuth::UnlocksController
   include DeviseTokenAuth::Concerns::SetUserByToken
   skip_before_action :verify_authenticity_token
+  prepend_before_action :not_acceptable_response_not_api_accept, only: %i[create]
+  prepend_before_action :not_acceptable_response_not_html_accept, only: %i[show]

RSpecを直す

spec/routing/infomations_routing_spec.rb

 RSpec.describe InfomationsController, type: :routing do
   describe 'routing' do
     it 'routes to #index' do
       expect(get: '/infomations').to route_to('infomations#index')
+      expect(get: '/infomations.json').to route_to('infomations#index', format: 'json')
     end
     it 'routes to #show' do
       expect(get: '/infomations/1').to route_to('infomations#show', id: '1')
+      expect(get: '/infomations/1.json').to route_to('infomations#show', id: '1', format: 'json')

spec/routing/users/auth/confirmations_routing_spec.rb

     it 'routes to #create' do
       expect(post: '/users/auth/confirmation').to route_to('users/auth/confirmations#create')
+      expect(post: '/users/auth/confirmation.json').to route_to('users/auth/confirmations#create', format: 'json')
     end
     it 'routes to #show' do
       expect(get: '/users/auth/confirmation').to route_to('users/auth/confirmations#show')
+      expect(get: '/users/auth/confirmation.json').to route_to('users/auth/confirmations#show', format: 'json')
     end

他のrouting specとrequest specはコミット内容を参照してください。


今回のコミット内容
https://dev.azure.com/nightonly/rails-app-origin/_git/rails-app-origin/commit/2e14c16453799198d242caba06e67f53fa01616d

default format指定のコミット内容
https://dev.azure.com/nightonly/rails-app-origin/_git/rails-app-origin/commit/b8307d0d66b45be28e52874bdb357a2d49358b84

既存のControllerでDevise Token Auth認証してJSONを返す” に対して1件のコメントがあります。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です