記録。

めも。

GraphQLの認証と認可について

GraphQLの認証と認可についてどうなっているのか知りたくなったので、調べてみた。

基本的にgraphql-rubyをベースに書いてます。

認証(Authentification)について

GraphQL - Overview

ここに記載されていた。

In general, authentication is not addressed in GraphQL at all.

残念ながら、認証の機構については全く用意されていなかった。

そのため認証については個別で実装したり、他のサードパーティのライブラリやSaaSなどを使用して導入するしかないっぽい。

ドキュメントには以下のように記載されていた。

Instead, your controller should get the current user based on the HTTP request (eg, an HTTP header or a cookie) and provide that information to the GraphQL query.

代わりにHTTPリクエストのCookieなり、ヘッダーなりにユーザーの情報を含めてGraphQLのやり取りすることが必要だと言っている。

実際、graphql-rubyをインストールした際に生成されるcontrollerはこのようになっている。

class GraphqlController < ApplicationController
  # If accessing from outside this domain, nullify the session
  # This allows for outside API access while preventing CSRF attacks,
  # but you'll have to authenticate your user separately
  # protect_from_forgery with: :null_session

  def execute
    variables = ensure_hash(params[:variables])
    query = params[:query]
    operation_name = params[:operationName]
    context = {
      # Query context goes here, for example:
      # current_user: current_user <= ここがコメントアウトされている
    }
    result = AppSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
    render json: result
  rescue StandardError => e
    raise e unless Rails.env.development?

    handle_error_in_development e
  end

  private

  # Handle form data, JSON body, or a blank value
  def ensure_hash(ambiguous_param)
    case ambiguous_param
    when String
      if ambiguous_param.present?
        ensure_hash(JSON.parse(ambiguous_param))
      else
        {}
      end
    when Hash, ActionController::Parameters
      ambiguous_param
    when nil
      {}
    else
      raise ArgumentError, "Unexpected parameter: #{ambiguous_param}"
    end
  end

  def handle_error_in_development(e)
    logger.error e.message
    logger.error e.backtrace.join("\n")

    render json: { error: { message: e.message, backtrace: e.backtrace }, data: {} }, status: 500
  end
end

contextの部分にcurrent_userというものが用意されている。

実装者がこの部分を何らかの形でユーザー情報を取得し、current_userに含めれば、その後のQueryやMutationでcurrent_userにアクセスして 認可的な処理などを実装することができる。

それで認証は?

先ほどの説明から認証の機構は用意されていないので、自前で実装するしかない。

全てを1からやるのは面倒なので、railsの認証のライブラリで有名なDeviseを使用してサンプル実装をやってみた。

使用したgem

  • devise
  • devise-token_authenticatable

deviseの基本的な部分はをインストールすれば、モデルなど自動的に作成してくれるので特にやることはない。

先ほどの GraphqlController のcontextのcurrent_userのコメントアウトを外せば使用できるようになり、

デバッグしてみてもリクエストを送信したユーザーの情報が含まれていることがわかると思う。

(リクエストにはユーザー作成時に発行されたトークンをリクエストの Authorization: Bearer <発行されたトークン> にセットすることでユーザーが誰であるのかをサーバー側で把握することができる。)

認可について

graphql-rubyには3つの認可フレームワークが用意されている。

  • visibility
  • Accessibility
  • Authorization

このうち Accessibility については、非推奨となっているので今回は扱わない。

visibilityについて

これは、ユーザーの権限によってGraphQLスキーマの一部を隠すものだ。

実際にvisibilityを使用してみる

module Types
  class QueryType < Types::BaseObject

    field :todos, Types::TodoType.connection_type, null: false do
      def visible?(context)
        super && context[:current_user].role == 'admin'
      end
    end

    def todos
      Todo.all
    end
  end
end

上記以外の補足説明としては以下の2つのモデルが存在している

  • User
  • Todo
class User < ApplicationRecord
  has_many :todos, dependent: :destroy
end

class Todo < ApplicationRecord
  belongs_to :user
end

上記のような関係性である。

最初のQueryTypeの説明に戻すと、adminの場合はユーザー関係なく全てのTodoを見れるようにしたいとする。

上記を実現するためにvisibilityをfield: todosに実装した。

これの状態でadmin以外のユーザーでクエリを実行すると以下のように返ってくる。

"message": "Field 'todos' doesn't exist on type 'Query'"

そもそもそんなものないよと言われている。

確かに隠している形になっている。

ちなみにvisibilityはドキュメントに記載されているように4種類のメソッドを提供している

Type classes have a .visible?(context) class method

Fields and arguments have a #visible?(context) instance method

Enum values have #visible?(context) instance method

Mutation classes have a .visible?(context) class method

特定のモデルに紐付くTypeのfieldに使用したり、

mutationでもクラスメソッドして提供されているので、特定のmutationを隠蔽することができる。

module Mutations
  class CreateTodoMutation < BaseMutation
    argument :title, String, required: true
    argument :description, String, required: true

    field :todo_edge, Types::TodoType.edge_type, null: false

    def self.visible?(context)
      super && context[:current_user].role == 'admin'
    end

 # 以下省略
end

Authorizationについて

これは、ユーザーがアクセス中のオブジェクトに対してアクセスできる権限をチェックするものだ。

先ほどのvisibilityの例をauthorizationに変えてみた

module Types
  class QueryType < Types::BaseObject

    field :todos, Types::TodoType.connection_type, null: false do
      def authorized?(obj, args, ctx)
        super && context[:current_user].role == 'admin'
      end
    end

    def todos
      Todo.all
    end
  end
end

visibilityは受け渡される引数はcontextのみだったのに対して、 object, argument, contextが受け渡される。

objectはfieldから返されるアプリケーションオブジェクトをあらわす。

argumentはアクセスするfieldに必要なargumentをあらわす。

クエリを実行すると、結果としてはvisibilityと同じようにアクセスできずエラーが返却されるが、 メッセージ内容が異なることがわかる。

"message": "Cannot return null for non-nullable field Query.todos"

QueryTypeのtodosというfieldに対して許可を持ってないので、非表示にしたという内容の記述が返ってきた。

先ほどのvisibilityはtodosなんてないよと言ってきたのに対して、こちらは存在するが権限を持っていないよと言われている。

Authorizationは3種類のメソッドを提供している

Type classes have .authorized?(object, context) class methods

Fields have #authorized?(object, args, context) instance methods

Arguments have #authorized?(object, arg_value, context) instance methods

先ほどと同じように実装することができる。

Authorizationはデフォルトでアクセスしたオブジェクトに対して、許可しなかった場合、オブジェクトが存在しなかったかのようにnilを返すようになっている。

これは実装者自信でカスタマイズすることができる。

class AppSchema < GraphQL::Schema
  mutation(Types::MutationType)
  query(Types::QueryType)

  # Opt in to the new runtime (default in future graphql-ruby versions)
  use GraphQL::Execution::Interpreter
  use GraphQL::Analysis::AST

  # Add built-in connections for pagination
  use GraphQL::Pagination::Connections

  def self.unauthorized_object(error)
    raise GraphQL::ExecutionError, "An object of type #{error.type.graphql_name} was hidden due to permissions"
  end

  def self.unauthorized_field(error)
    raise GraphQL::ExecutionError, "The field #{error.field.graphql_name} on an object of type #{error.type.graphql_name} was hidden due to permissions"
  end
end

クラスメソッドとして提供されているunauthorized_fieldを実装することで、デフォルトではなく、カスタマイズしたものを返すことができる。

特定のfieldに対してはunauthorized_fieldが提供され、

特定の(クラス)オブジェクトに対してはunauthorized_objectが提供される。

module Types
  class QueryType < Types::BaseObject
    def self.authorized?(obj, ctx)
      super && ctx[:current_user].role == 'admin'
    end

  # 以下省略

  end
end

unauthorized_objectを実装していないと以下のようにnullが返ってくる

{
  "data": null
}

unauthorized_objectを実装すると以下のように変えることができる。

{
  "errors": [
    {
      "message": "An object of type Query was hidden due to permissions"
    }
  ]
}