40
26

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

GraphQLの認証をどこでやるか

Posted at

GraphQLなAPIを実装するにあたって、認証をどうするか。

  • GraphQL内部で認証する
  • GraphQLの外で認証する
  • 認証とスキーマ

参考:

ScalaでSangriaを使って実装を考える。

個人的な結論

認証はGraphQLの手前で実行する。

具体例としては、クライアント側は認証が必要な操作をする際にHTTP Headerに認証トークン的なものを入れて送る。
サーバ側はGraphQLの世界に入る前にHTTP Headerからトークンを取り出して認証を行い、ログイン中のアカウントを特定してCtxのプロパティに入れてGraphQL世界に渡す。

スキーマとしては、アカウントそのものの情報を必要とする場合はviewerディレクティブからアクセスする。
それ以外はルートに配置する。

認可は別で考える必要があるがここでは扱わない。

GraphQL内部で認証する

GraphQLの世界に入ってから認証処理を行うパターン。
resolveする際に毎回認証するわけにはいかないので、全体で一回だけになるようにハンドリングする必要がある。

ScalaのGraphQLフレームワークであるsangriaにはUpdateCtxというAPIがあり、認証した結果でCtxを更新して後続の処理に繋げることが出来る。
これによって認証処理を一度だけにすることが可能。

UpdateCtxを使った認証周りの実装はこんな感じ。

// DBへのアクセス
class UserDao() {
  def findByToken(token: Token): Option[User] = ???
}

// Ctxとして使用する
case class GraphQLContext(userOpt: Option[User] = None) {
  val userDao = new UserDao()
  def loggedIn(user: User): GraphQLContext = copy(userOpt = Some(user))
}

val tokenArg = Argument("token", StringType, "token of logged in user")

ObjectType(
  "Mutation",
  fields[Ctx, Unit](
    Field(
      "authenticate",
      OptionType(userType),
      arguments = tokenArg :: Nil,
      resolve = { ctx =>
        ctx.withArgs(tokenArg) { token =>
          // 認証を実行
          UpdateCtx(ctx.ctx.userDao.findByToken(Token(token))) { userOpt =>
            userOpt.fold(ctx.ctx) { user =>
              // 成功していたらCtxを更新する
              ctx.ctx.loggedIn(user)
            }
          }
        }
      }
    ),
    ??? // 他のMutation
  )
)

これを使ったGraphQLのクエリは以下のようにかける

mutation {
  authenticate(token: "your-token") {
    id
  }
  
  // 認証後の操作
  ...
}

UpdateCtxについて詳しくはQiitaに以前書いた。
https://qiita.com/petitviolet/items/1fb6a8e52f02f4309f5b

UpdateCtxはなかなか便利な仕組みだが、

  • クライアント側でQueryを記述する順番に気をつける必要がある
    • UpdateCtxが実行されるパスを上に持ってこないと後続に認証結果が引き継がれない
  • Mutationのみで、Queryでは使用できない
    • Queryはparallelに実行され、Mutationはserialなため
  • Mutationでもネストしたディレクティブでは効果がない
    • これはUpdateCtxの実装の問題かも?

という問題がある。

認証というのはQueryかMutationに関わらず共通して実行されてほしいことが多いので、
すべてのQuery/Mutationに対して認証トークンを渡して毎回認証をするような実装にせざるを得ない。

GraphQLの外で認証する

GraphQLの外、つまりGraphQLの世界に入る前に認証処理を実行してその結果をもってGraphQLの世界に入っていく 。
要するにCtx作成時に認証を行うようなイメージ。

// Ctxとして使用する
class GraphQLContext private (val userOpt: Option[User] = None) 

object GraphQLContext {
  // Headerから取得したtokenを使ったファクトリ
  def create(tokenOpt: Option[String]): GraphQLContext = {
    tokenOpt.fold(apply()) { token =>
      // 認証
      new GraphQLContext(UserDao.findByToken(Token(token)))
    }
  }
}

これをAkka-HTTPと組み合わせて使うなら以下のような実装になる。

def route = (post & path("graphql")) {
  entity(as[JsValue]) { JsObject(fields) =>
    optionalHeaderValueByName("x-token") { tokenOpt =>
      // HeaderからTokenを取り出してGraphQLContextを作成すると同時に認証する
      val ctx = GraphQLContext.create(tokenOpt)
      
      // ほぼ定型文
      val operation = fields.get("operationName") collect { case JsString(op) => op }       
      val vars = fields.get("variables") match {
        case Some(obj: JsObject) => obj
        case _                   => JsObject.empty
      }       
      val Some(JsString(document)) = fields.get("query")
      
      // GraphQLのクエリを実行
      val res = 
        Executor.execute(schema, document, ctx, variables = vars, operationName = operation)
          .map { OK -> _ }
          .recover {
            case err: QueryAnalysisError => BadRequest -> err.resolveError
            case err: ErrorWithResolver => InternalServerError -> err.resolveError
          }
      complete(res)
      }
  }
}

GraphQLContext.createしたものをCtxとしてsangria.execution.Executor.executeの引数に与えれば良い。
これによってGraphQLのクエリには認証tokenにあたるものは登場しない。

メリットとしてはGraphQLと認証処理を分離することが出来る、ということが大きい。
デメリットとしては以下がある。

  • Http Headerに認証トークンを与えないといけないのでGraphiQLをそのままでは使えない
  • GraphQLレイヤより手前で認証処理を行わないといけないので、レイヤードアーキテクチャと相性が悪いかも
    • GraphQLはアダプタ層/プレゼンター層、認証はアプリケーション層/ユースケース層なので逆転してしまうはず
    • アプリケーションの設計によってここは変わるかも知れない

懸念

アプリケーションアーキテクチャの一番外側に近い箇所での認証を強要されることになってしまう。
たとえば認証はCleanArchitectureにおけるユースケース層で行いたい場合などには、GraphQLを導入することによって認証をアダプタ層で行わざるをえなくなってしまうので、アーキテクチャとの整合性が取れなくなってしまう。
ここについては目をつぶるか、認証トークンに紐づくアカウントIDのみを取得した上でそれをDTOとしてGraphQLのCtxに詰め、ユースケース層で改めてアカウントIDからアカウントのオブジェクトを取得する、という実装にすれば整合性は失われないが明らかに無駄な処理になるので、コードの綺麗さかパフォーマンスかを選択することになる。

認証とスキーマ

リクエストしてきたユーザ、つまり認証結果に紐づく情報をどうやってGraphQLのスキーマとして表現するか。
Learn Relayにも記述がある、viewerというディレクティブを使うと良さそう。
Githubのv4 APIにもviewerがあり、アカウントの情報やそのアカウントが持つリポジトリの情報などにアクセスすることが出来る。

では認証しないと使えないサービスであった場合に、全てのクエリをviewer配下に置くかどうか。
これは恐らくNoで、そういったサービスの場合でも、リクエスト元のアカウントに紐づく情報のみがviewer以下で、認証さえしていれば共通してアクセスできるリソースに対してはルートに配置するのが良いかと。

認証を必須とするようなディレクティブについて、sangriaではMiddlewareという機構を提供しているため、そこで認証の必須チェックを行うことも出来る。

公式ドキュメントのサンプルをそのまま貼ってみる。

object SecurityEnforcer extends Middleware[SecureContext] with MiddlewareBeforeField[SecureContext] {
  type QueryVal = Unit
  type FieldVal = Unit

  def beforeQuery(context: MiddlewareQueryContext[SecureContext, _, _]) = ()
  def afterQuery(queryVal: QueryVal, context: MiddlewareQueryContext[SecureContext, _, _]) = ()

  def beforeField(queryVal: QueryVal, mctx: MiddlewareQueryContext[SecureContext, _, _], ctx: Context[SecureContext, _]) = {
    val permissions = ctx.field.tags.collect {case Permission(p)  p}
    val requireAuth = ctx.field.tags contains Authorised
    val securityCtx = ctx.ctx

    if (requireAuth)
      securityCtx.user

    if (permissions.nonEmpty)
      securityCtx.ensurePermissions(permissions)

    continue
  }
}

Fieldtagに何か値を設定しておいて、そのFieldに対するクエリであれば認証チェックをする、などの実装が可能になる。

40
26
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
40
26

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?