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はコミット内容を参照してください。
default format指定のコミット内容
https://dev.azure.com/nightonly/rails-app-origin/_git/rails-app-origin/commit/b8307d0d66b45be28e52874bdb357a2d49358b84
“既存のControllerでDevise Token Auth認証してJSONを返す” に対して1件のコメントがあります。