←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のテストと、処理をメソッドに切り出す方法を解説します。