🛡️

Next.jsのAPI RouteとGraphQL Shieldを使ったAPI認可

2023/05/02に公開1

はじめに

Next.jsのAPI Route を使ってGraphQLを構築した時に、APIの認可制御をどうするか悩んだのですがGraphql Sheildを使ったら非常に簡単でわかりやすい実装ができたのでその方法をまとめていきます。

また本記事では主旨となるGraphQL Shield周りの具体例のみに触れ、Next.jsやその他細かい構築に関しては書いておりませんのでご了承ください。

環境

今回はNext.jsのAPI RouteにGraphQLのエンドポイントを設定して構築していきます。

  • React.js
  • Next.js
  • TypeScript
  • GraphQL
  • Graphql Shield
  • Apollo Server
  • graphql-codegen

GraphQL Shield

本記事のメインとなるGraphQL Sheildについて説明します。
https://the-guild.dev/graphql/shield

まずGraphQL ShieldとはGraphQL APIのアクセス制御と権限管理を実現するためのミドルウェアライブラリです。リクエストに対して設定したルールに合致するかを確認し、不正なアクセスの場合はブロックしてエラーを返すことができます。このルールは自分でカスタマイズすることができるため、プロダクトの仕様に合わせて柔軟に使うことができます。

今回紹介するサンプルでは、未認証、一般ユーザー認証、管理者ユーザー認証の3種類を想定してAPIごとにどのパターンでAPIをコールできるかを定義します。

以下のイメージ図の通り、APIリクエストを投げるとApollo Severでリクエストを受け取る前にGraphQL Shieldをミドルウェアとして動かします。そうすることでログイン状態、またはログインしているユーザーの種別に応じて認可ロジックを動かすことができます。

API別の権限設計

ここから実際にAPIに対して認可制御をしていきます。
ただしNext.jsの構築やAPIのスキーマ作成は説明から省かせてもらいますので、最終的なコードは完成リポジトリがあるので必要に応じて参考にしてください。
https://github.com/ikekiyo/nextjs-examples/tree/main/api-route-with-graphql-shield

想定するサービス

今回は本を管理するWebサービスを想定します。

  • 一般ユーザー
    • 本の一覧を見る
    • マイページ情報を見る
    • マイページ情報を更新する
  • 管理ユーザー
    • 登録しているユーザー一覧を見る
    • 新しい本の登録をする

API設計

APIごとに3つ認証パターンの内どれであればリクエストを許可するか決めていきます。認証のパターンはWebサービスでよくある以下の3つを前提とします。

  • 未認証
  • 一般ユーザー
  • 管理者ユーザー

今回は5つのAPIを認証状態に応じて認可制御していきます。

API 未認証 未認証 一般 管理者
booksQuery 本の一覧を取得
userQuery ログインしているユーザー情報を取得 ×
usersQuery サービスに登録しているユーザーの一覧情報を取得 × ×
updateUserMutation ログインしているユーザー情報を更新 ×
createBookMutation 管理画面で新しい本を登録 × ×

Apollo Serverでのミドルウェア設定

ここからは実際にGraphQL Shieldを使って実装していきます。

まず最初にNext.jsのAPI RouteでApollo Serverを動かし、さらに認可制御のミドルウェアを適用していきます。このように順を追って流れを説明していきます。

  1. API RouteでApollo Serverを起動する
  2. Apollo ServerにMiddlewareを適用させる

API RouteでApollo Serverを起動

公式ドキュメントによると@as-integrations/nextを使うよう書かれているため今回もこれを使用します。
@as-integrations/nextを使いシンプルにApollo Serverを起動させるだけのコードの場合はこのようになります。

/pages/api/graphql/index.ts
import { ApolloServer } from '@apollo/server'
import { startServerAndCreateNextHandler } from '@as-integrations/next'

import { resolvers } from 'resolvers/resolvers'
import { typeDefs } from 'graphql/schema'

const server = new ApolloServer({
  resolvers,
  typeDefs,
});

export default startServerAndCreateNextHandler(server);

Apollo ServerにMiddlewareを適用

次にApollo Serverにミドルウェアとして認可処理を適用するにはgraphql-middlewareと@graphql-tools/schemaを使用して先ほどのコードを以下のように修正します。

このpermissionsはGraphQL Sheildを使って認可定義をしていくのですが、次章で詳しく説明していきます。ここではまず動かし方だけ先に押さえておきましょう。

/pages/api/graphql/index.ts
import { ApolloServer } from '@apollo/server'
import { startServerAndCreateNextHandler } from '@as-integrations/next'
+ import { makeExecutableSchema } from '@graphql-tools/schema'
+ import { applyMiddleware } from 'graphql-middleware'

import { resolvers } from 'resolvers/resolvers'
import { typeDefs } from 'graphql/schema'
+ import { permissions } from 'resolvers/permissions'

// スキーマを作成
+ export const schema = makeExecutableSchema({
+   typeDefs,
+   resolvers,
+ })

// スキーマにミドルウェアを適用する
- const server = new ApolloServer({
-   schema: schema
- })
+ const server = new ApolloServer({
+   schema: applyMiddleware(schema, permissions),
+ })

export default startServerAndCreateNextHandler(server);

GraphQL Sheildを使った認可定義

ここからが本題となります。

GraphQL Sheildを使って実現するには以下のフローで作業を進めていきます。

  1. カスタムルールの作成
  2. 各API別にカスタムルールの割り当て
  3. リクエストのContext情報にログインしているユーザー情報を入れる

カスタムルールの作成

カスタムルールの作成にはGraphQL Shieldのruleメソッドを使って定義します。ここで作成したルールは結果がtrueである場合のみAPIリクエストをパスできるようになります。

https://the-guild.dev/graphql/shield/docs/rules

管理者ユーザー権限のカスタムルール

早速GraphQL Shieldのrule()を使って管理者権限のカスタムルールを作成していきます。

まずrules.tsというファイルを作成し、以下のようにisAdminという管理者権限かを判断するルールを作成します。このコードを見て頂くとわかるように、Contextの中のuserがAdminであればisAdminがTrueになるように定義します。

rules.ts
import { rule, or } from 'graphql-shield'

// Adminユーザー認証
export const isAdmin = rule({ cache: 'contextual' })(
  async (_parent, _args, ctx, _info) => {
    return ctx.user !== null && ctx.user.admin
  }
)

しかしまだContextの中にユーザー情報を入れる処理がないため、先ほど設定した /api/graphql/index.ts のApollo Serverのハンドラーの中でContextにユーザー情報を詰めていきます。

/pages/api/graphql/index.ts
- export default startServerAndCreateNextHandler(server);
+ export default startServerAndCreateNextHandler(server, {
+   context: async (ctx) => {
+     let user: User | null = null
+     const token = ctx.headers.authorization ?? ''
+     if (token) {
+       // FirebaseやDBからUser情報を取得する(以下はDBから取得するイメージ)
+       user = await getUser()
+     }
+     return { user }
+   },
+ })

一般ユーザー権限のカスタムルール

一般ユーザーの場合は、コンテキストにユーザー情報があってかつadminがnullであれば一般ユーザーと判断します。

rules.ts
import { rule, or } from 'graphql-shield'

// Adminユーザー認証
export const isAdmin = rule({ cache: 'contextual' })(
  async (_parent, _args, ctx, _info) => {
    return ctx.user !== null && ctx.user.admin
  }
)

+ // 一般ユーザー認証
+ export const isUser = rule({ cache: 'contextual' })(
+   async (_parent, _args, ctx, _info) => {
+     return ctx.user !== null && !ctx.user.admin
+   }
+ )

ここからは少し工夫した部分となりますが、一般ユーザー権限のAPIは一般ユーザーでも管理者権限でも実行させることができます。

そのため一般ユーザーまたは管理者ユーザーのどちらかでログインしているなら、リスクエストをパスするという考え方でisAuthenticatedというルールを作成します。

rules.ts
import { rule, or } from 'graphql-shield'

// Adminユーザー認証
export const isAdmin = rule({ cache: 'contextual' })(
  async (_parent, _args, ctx, _info) => {
    return ctx.user !== null && ctx.user.admin
  }
)

// 一般ユーザー認証
export const isUser = rule({ cache: 'contextual' })(
  async (_parent, _args, ctx, _info) => {
    return ctx.user !== null && !ctx.user.admin
  }
)

+ // 認証済み
+ export const isAuthenticated = or(isUser, isAdmin)

未認証のカスタムルール

最後に未認証のルールを作るのですが、未認証の場合は簡単で常にtrueを返すルールを作成します。これをAPIに割り当てるとどのような認証状態であってもリクエストを許可することになります。

rules.ts
import { rule, or } from 'graphql-shield'

// Adminユーザー認証
export const isAdmin = rule({ cache: 'contextual' })(
  async (_parent, _args, ctx, _info) => {
    return ctx.user !== null && ctx.user.admin
  }
)

// 一般ユーザー認証
export const isUser = rule({ cache: 'contextual' })(
  async (_parent, _args, ctx, _info) => {
    return ctx.user !== null && !ctx.user.admin
  }
)

// 認証済み
export const isAuthenticated = or(isUser, isAdmin)

+ // 未認証
+ export const isNotAuthenticated = rule({ cache: 'contextual' })(
+   async (_parent, _args, _ctx, _info) => {
+     return true
+   }
+ )

これでGraphQL Shieldを使った設定は終わりとなります。

APIへのカスタムルール割り当て

最後にAPI別にカスタムルールを割り当てていきます。これが終われば設定の完了となります。

これは簡単に言うとAPIごとにどのパーミッション設定を割り当てるかを定義しますが、おそらくコードを見ると雰囲気が掴めると思います。

permissions.ts
import { shield } from 'graphql-shield'

import * as rules from './rules'

export const permissions = shield({
  Query: {
    // 認証不要
    books: rules.isNotAuthenticated,

    // 一般ユーザー認証
    user: rules.isAuthenticated,

    // 管理者ユーザー認証
    users: rules.isAdmin,
  },
  Mutation: {
    // 一般ユーザー認証
    updateUser: rules.isAuthenticated,

    // 管理者ユーザー認証
    createBook: rules.isAdmin,
  },
})

このように各APIに対して、先ほど作成したカスタムルールを割り当ててどの認証状態であればリクエストを通すか設定します。

個人的にはこの書き方が非常に可読性が高く使いやすいなと感じています。

またチームで開発する場合はコメントを入れて同じルールのものをグルーピングしておいた方が、可読性が上がりバグの混入率が下がるのでお勧めです。

以上で設定は完了となります。

実行

最後に動作確認をしやすくするためにコードを少しアレンジしようと思います。

APIのリクエストヘッダーauthorizationに入っている文字列に応じて、擬似的にログインしているユーザーの切り替え制御をします。

/pages/api/graphql/index.ts
export default startServerAndCreateNextHandler(server, {
  context: async (ctx) => {
-     let user: User | null = null
-     const token = ctx.headers.authorization ?? ''
-     if (token) {
-       // FirebaseやDBからUser情報を取得する(以下はDBから取得するイメージ)
-       user = await getUser()
-     }
+     let user = null
+     switch (ctx.headers.authorization) {
+       case 'user':
+         user = {
+           id: '1',
+           name: 'yamada taro',
+           admin: false,
+         }
+         break
+       case 'admin':
+         user = {
+           id: '1',
+           name: 'yamada taro',
+           admin: true,
+         }
+         break
+       default:
+         user = null
+         break
+     }
    return { user }
  },
})

ここまで修正をしたら yarn dev をしてサーバーを起動したら http://localhost:3000/api/graphql にアクセスします。

booksQuery

まずは認証不要のbooksQueryを未認証状態でコールします。

このように未認証状態でも無事に取得できています。

userQuery

次は一般権限以上のログインが必要なuserQueryをまずは未認証状態でコールします。

このようにNot Authorised!が表示されたので意図通りに動いています。(Authorisedになっているのはライブラリー側のタイポ?なのか実際にその文字列が返ってきます)

ではuserQueryを一般ユーザーでログインした状態でコールするために、画像のようにHeadersにuserと入れた状態で実行します。

一般権限でログインしていれば、ちゃんとデータを取得することができました。もちろんAdmin権限でコールしても正しくレスポンスが取得できます。

意図通りにuserQueryの認可制御をすることができました。

記載は省略しますが、管理者権限が必要となるusersQueryも同様に確認が出来ました。試したい場合はHeadersにadminと入れて確認してみてください。

テスト

最後にAdmin権限でのみコールできるCreateBookMutationをテストします。

テストでは@graphql-tools/mockのaddMocksToSchemaを使ってschemaをmockします。

createBook/permissions.spec.ts
import { ApolloServer } from '@apollo/server'
import { addMocksToSchema } from '@graphql-tools/mock'
import { applyMiddleware } from 'graphql-middleware'

interface ContextValue {
  user: User | null
}
const mockedSchema = addMocksToSchema({
  schema: schema,
})
const schemaWithMiddleware = applyMiddleware(mockedSchema, permissions)
const testServer = new ApolloServer<ContextValue>({
  schema: schemaWithMiddleware,
})

Apollo Serverを作成したら今回はサーバーを起動するのではなく、executeOperationを使ってテストを実行していきます。

https://www.apollographql.com/docs/apollo-server/v2/testing/testing/#executeoperation

未認証状態

まずはCreateBookMutationが未ログイン状態の時に認証エラーとなることを確認します。認証状態はexecuteOperationの第二引数のcontextValueでコントロールします。

describe('認証状態に応じたAPI制御', () => {
  describe('CreateBookMutation', () => {
    const query = `
        mutation CreateBook($createBookInput: CreateBookInput!) {
          createBook(createBookInput: $CreateBookInput) {
            id
          }
        }
      `
    const variables = {
      createBookInput: {
        title: 'test title',
        author: 'test author'
      },
    }
    test('未ログイン状態で認証エラーになること', async () => {
      const response = await testServer.executeOperation(
        {
          query: query,
          variables: variables,
        },
        {
          contextValue: {
            user: null,
          },
        }
      )
      assert(response.body.kind === 'single')
      expect(response.body.singleResult.errors).not.toBeUndefined()
      assert(
        response.body.singleResult.errors &&
          response.body.singleResult.errors?.length > 0
      )
      expect(response.body.singleResult.errors[0]).not.toEqual({
        extensions: { code: 'INTERNAL_SERVER_ERROR' },
      })
      expect(response.body.singleResult.errors[0]).not.toEqual({
        message: 'Not Authorised!',
      })
    })
  })
})

一般権限

次は一般権限のテストをするので、contextValueにはユーザー情報をいれて実行します

    test('一般権限のログイン状態で認証エラーになること', async () => {
      const response = await testServer.executeOperation(
        {
          query: query,
          variables: variables,
        },
        {
          contextValue: {
            user: {
              id: '1',
              name: 'yamada taro',
              admin: false,
            },
          },
        }
      )
      assert(response.body.kind === 'single')
      expect(response.body.singleResult.errors).not.toBeUndefined()
      assert(
        response.body.singleResult.errors &&
          response.body.singleResult.errors?.length > 0
      )
      expect(response.body.singleResult.errors[0]).not.toEqual({
        extensions: { code: 'INTERNAL_SERVER_ERROR' },
      })
      expect(response.body.singleResult.errors[0]).not.toEqual({
        message: 'Not Authorised!',
      })
    })

一般権限ユーザーでも認証エラーとなることが確認できました。

Admin権限

最後にAdmin権限でのテストをやるためにadminがtrueのユーザーをcontextValueに入れて実行します。

    test('Admin権限ユーザーのログイン状態でコールできること', async () => {
      const response = await testServer.executeOperation(
        {
          query: query,
          variables: variables,
        },
        {
          contextValue: {
            user: {
              id: '1',
              name: 'yamada taro',
              admin: true,
            },
          },
        }
      )
      assert(response.body.kind === 'single')
      expect(response.body.singleResult.errors).toBeUndefined()
    })

これでAdmin権限だけで認証が通ることを確認できました。

さいごに

GraphQLを使ったAPI認可処理はGraphQL Shieldを使えば非常に簡単に構築できました。個人的にはpermissionsに定義した時に、どのAPIがどの権限制御をしているか一目瞭然なので非常に使いやすいなと思います。

Discussion