petitviolet blog

    GraphQLの認証をどこでやるか

    2018-07-16

    QiitaScalaGraphQLsangria

    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 に対するクエリであれば認証チェックをする、などの実装が可能になる。

    from: https://qiita.com/petitviolet/items/62d7e0462f85bed3f39b