←Rails 6で認証認可入り掲示板APIを構築する #14 seed実行時間の表示
認証と認可の違い
まずはpunditを入れます。
punditは認可を管理するgemです。
認証と認可の違いは何か?
【認証】とは、言わば運転免許証を見せるようなもの。
あなたが何者であるかを証明する処理です。
【認可】はその免許証に書かれている、どんな車に乗れるかというもの。
免許によっても、原付しか乗れない免許証や、中型・大型等いろいろありますよね。
つまりシステム上でも、あなたが何者かは分かったけれど、この処理は許可されていないよ等を管理する必要があります。
そしてdevise(devise_token_auth)だけで認可ができないのかというと、実はできます。
しかしpunditのような認可gemを使った方が、認可処理だけ担うファイルに分割して管理できる等のメリットが多く、アプリケーションが大きくなるにつれて利点を感じるようになります。
punditのインストール
さて、punditを入れていきましょう。
punditのドキュメント通りですが、以下の通りインストールと初期設定をしていきます。
+ gem “pundit”
$ bundle
 class ApplicationController < ActionController::Base
+  include Pundit
…
 end
$ rails g pundit:install
ここまでインストールしたら1回rails sを止めて再起動しましょう。
postのpolicyを作る
参照:https://github.com/varvet/pundit#generator
$ rails g pundit:policy post
実行するとpolicyファイルとspecファイルができます。
ここで、一般的な掲示板アプリケーションの挙動を想像してみましょう。
- #indexは全員見れていい
 - #showも全員見れていい
 - #createは認証済みの場合のみ実行できる
 - #updateは認証済みかつ自分の投稿だけ編集できる
 - #destroyも認証済みかつ自分の投稿だけ削除できる
 
主に修正が必要なファイルは3つ。
- app/controllers/v1/posts_controller.rb
 - app/policies/application_policy.rb
 - app/policies/post_policy.rb
 
それに加え、初期設定として1度だけ修正が必要なファイルが
- app/controllers/application_controller.rb
 
これら4ファイルを修正しながら、punditの挙動を理解してきましょう。
     def index
       posts = Post.includes(:user).order(created_at: :desc).limit(20)
+      authorize posts
       render json: posts
     end
 
     def show
+      authorize @post
       render json: @post
     end
 
     def create
+      authorize Post
       post = current_v1_user.posts.new(post_params)
       if post.save
… 
     def update
+      authorize @post
       if @post.update(post_params)
… 
     def destroy
+      authorize @post
       @post.destroy
…
ここで注目すべきは、どの位置にauthorizeを入れているか、です。本記事の後半で解説します。
このauthorize {model}とすることで、post_policy.rbの該当するメソッドが呼ばれます。
今はまだpost_policy.rbを直していないので、スーパークラスであるapplication_policy.rbのindex?やshow?が呼ばれます。
  def index?
    false
  end
index?はfalseになっていますね。
{action}?に該当するメソッドの返り値がtrueなら許可、falseなら拒否されます。そのため認証エラーとなります。
{"status":500,"error":"Internal Server Error","exception":"#\u003cNameError: undefined local variable or method `current_user' for #\u003cV1::PostsController:0x00000000036a49a8\u003e\nDid you mean?  current_v1_user
このエラーがなかなか曲者です。
current_userという変数やメソッドはないよ
というエラーですね。
実はpundit、デフォルトだとcurrent_userというメソッドを呼び出してapplication_policy.rbやpost_policy.rbの@userに渡す挙動をします。
しかし今回のテストアプリケーションではv1というnamespaceを切っているので、current_userではなくcurrent_v1_userを呼ばないといけません。
これはapplication_controller.rbでpundit_userというメソッドをオーバーライドしてやると対応できます。
class ApplicationController < ActionController::API
…
+  def pundit_user
+    current_v1_user
+  end
これでcurrent_userではなくcurrent_v1_userがpunditで呼ばれるようになるので、先程のundefined local variable or method current_user'`は解消されます。
再度curlを叩きます。
{"status":500,"error":"Internal Server Error","exception":"#\u003cPundit::NotAuthorizedError: not allowed to index? this Post::ActiveRecord_Relation
許可されなかった時、500エラーが返っているようです。
権限がない時は403エラーが適切ですので、application_controller.rbでPundit::NotAuthorizedErrorをrescueしてやれば良さそうですね。
 class ApplicationController < ActionController::API
   include DeviseTokenAuth::Concerns::SetUserByToken
+  rescue_from Pundit::NotAuthorizedError, with: :render_403
…
+  def render_403
+    render status: 403, json: { message: "You don't have permission." }
+  end
…
再度実行してみましょう。
$ curl localhost:8080/v1/posts -i
HTTP/1.1 403 Forbidden
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
X-Download-Options: noopen
X-Permitted-Cross-Domain-Policies: none
Referrer-Policy: strict-origin-when-cross-origin
Content-Type: application/json; charset=utf-8
Cache-Control: no-cache
X-Request-Id: e19d413c-89c9-4701-94c5-ece2b12560a9
X-Runtime: 0.003657
Transfer-Encoding: chunked
{"message":"You don't have permission."}
403のレスポンスコードと、メッセージもちゃんと返ってきましたね。
なお、試しにapp/policy/application_policy.rbのdef index?をfalseからtrueに変えると、正常に投稿一覧が返ってきます。
しかしapplication_policy.rbはスーパークラスであり、ここは原則変えずに全てfalseのままにしておきましょう。
継承したサブクラスであるpost_policy.rbを編集します。
post_policy.rbの編集
先に最終的なコードを書いておきます。
# frozen_string_literal: true
#
# postのポリシークラス
#
class PostPolicy < ApplicationPolicy
  def index?
    true
  end
  def show?
    true
  end
  def create?
    @user.present?
  end
  def update?
    @record.user == @user
  end
  def destroy?
    @record.user == @user
  end
  #
  # scope
  #
  class Scope < Scope
    def resolve
      scope.all
    end
  end
end
これで意図した通りに動くはずです。なお、punditの挙動を理解するのを優先するため、今回はテストは最後に書きます。
さて、controllerでauthorizeを挿入した位置を思い出してみます。
     def index
       posts = Post.includes(:user).order(created_at: :desc).limit(20)
+      authorize posts
       render json: posts
     end
 
     def show
+      authorize @post
       render json: @post
     end
 
     def create
+      authorize Post
       post = current_v1_user.posts.new(post_params)
       if post.save
… 
     def update
+      authorize @post
       if @post.update(post_params)
… 
     def destroy
+      authorize @post
       @post.destroy
…
どれも、そのactionが行う必要な処理が実行される前に呼び出していることに注目してください。
- #indexはpostの一覧を返すので、
render jsonより前に実行 - #showはpostを返すので、
render jsonより前に実行 - createは新しいレコードを生成するので、
if post.saveより前に実行 - updateはレコードを更新するので、
if @post.update(post_params)より前に実行 - destroyはレコードを削除するので、 
@post.destroyより前に実行 
となります。
仮にsaveやupdateの後にauthorizeしていたらどうでしょう?
権限がない時に403のレスポンスは返りますが、保存処理が終わった後なので、DB上は書き換えができてしまっているはずです。それだと認可の意味がないですよね。
また、そもそもauthorizeを呼んでないと認可が行われないことにも注意です。
結論としては、authorizeを必ず呼ぶことと、呼ぶ位置についてしっかり確認する必要があります。
最後に、create?とupdate?の処理について解説します。
  def create?
    @user.present?
  end
@userにはcurrent_v1_userが入ってきますが、未ログインだと@userはnilが入ってきます。
つまり、上記メソッドではログイン状態ならtrueで200, 非ログイン状態ならfalseで403が返ります。
    def create
      authorize Post
      post = current_v1_user.posts.new(post_params)
controller側も注目です。
post = current_v1_user.posts.new(post_params)の下でauthorize postをしていないことに注目です。
なぜなら前述の通りcurrent_v1_userはnilなので、post = current_v1_user.posts.new(post_params)の下でauthorize postを呼び出そうとすると、postsメソッドが存在せず500エラーになるためです。
判定に必要なのはpostではなくuserなので、その上で適当にPostを渡してauthorizeを動かしてるのです。
2つ目、update?とdestory?の挙動について。
  def update?
    @record.user == @user
  end
こちらの場合、更新対象レコードをcontrollerでauthorize @postと渡しているので@recordには更新・削除対象レコードが渡ってきます。
そのレコードのuserと、current_v1_userが渡ってきている@userを比較し、一致するか判定。
つまり自分自身の投稿か?を判定しているわけですね。
次回の記事ではpunditのテストと、処理をメソッドに切り出す方法を解説します。