Skip to main content

Node.jsで安全なGraphQL APIを構築する

著者
Headshot of Lawrence Eagles

Lawrence Eagles

blog banner node js

2022年3月29日

0 分で読めます

GraphQLには、検証や型チェックなどのセキュリティ機能が標準で備わっています。しかし、APIに関するセキュリティ上の懸念にすべて対処できるわけではありません。この記事では、FastifyとGraphQLを使ってシンプルなNode.jsアプリケーションを構築し、GraphQL APIを安全にする方法を学びます。

公式ドキュメントによると、GraphQLはAPI向けのグラフクエリ言語であり、データを使ってクエリを実行するためのランタイムです。GraphQLを使うと、API内のデータを明確に記述でき、クライアントが必要なデータを完全に指定できる、柔軟で高速なAPIを作成できます。RESTと同様に、GraphQLはHTTP上で動作するため、データベースに依存せず、あらゆるバックエンド言語やクライアントで利用できます。

Fastifyはプラグインベースの効率性と高いパフォーマンスを備えたNode.jsフレームワークで、高速なHTTPサーバーの構築に適しています。HapiとExpressに着想を得たFastifyは、オーバーヘッドを抑え、開発者にとって使いやすく、より高性能な選択肢を提供します。

FastifyはMercuriusプラグインを使ってGraphQLをサポートします。Mercuriusプラグインは、Fastify向けに設定可能なGraphQLアダプターです。以降のセクションで詳しく説明します。

まず、前提条件を確認しましょう。

前提条件

この記事を進めるには、以下が必要です。

  • Node.js バージョン12以降

  • JavaScriptの基本的な知識

  • GraphQLの基本的な知識

はじめに

まず、基本的なNode.jsサーバーを作成します。

プロジェクトフォルダーを作成してスタータープロジェクトを用意し、そのフォルダーからコマンドラインインターフェース(CLI)で以下のコードを実行します。これにより、アプリケーションの初期設定を行い、必要な依存関係をインストールします。

// bootstrap npm project
npm init -y

// install dependencies
npm i fastify nodemon fastify-plugin mercurius-auth jsonwebtoken

このプロジェクトでは、特定のバージョンのMercuriusを使用します。インストールするには、次のコマンドを実行してください。

npm i mercurius@7.9.1

次に、"type": "module"をpackage.jsonファイルに追加し、commonJSではなく標準のJavaScriptモジュールシステムを使えるようにES6モジュールを有効にします。

続いて、package.jsonファイルを開き、scriptsセクションを次のように編集して、Node.jsサーバーを起動するコマンドをNPMスクリプトに追加します。

"scripts": {
    // start the Node.js server in production
    "start": "node --es-module-specifier-resolution=node ./src/index.js",
    // use nodemon to restart development server when code is compiled
    "dev": "nodemon --es-module-specifier-resolution=node ./src/index.js"
}

ESモジュールとNodeのcommonJSモジュール間の相互運用性を有効にするには、--es-module-specifier-resolution=nodeの記述が必要です。

次に、ルートディレクトリにsrcディレクトリを作成します。srcディレクトリにgraphqlフォルダーを作り、その中にschema.jsとresolvers.jsファイルを作成します。schema.jsファイルに次のコードを追加してください。

const schema =`
  type Query {
    users: [User]!
  }

  type User {
    id: ID!
  }
  `;
  export default schema;

次に、resolvers.jsファイルに以下のコードを追加します。

const resolvers = {};
export default resolvers;

次のセクションでschema.jsを更新し、resolvers.jsを追加します。ただし、サーバーを正しく動作させるために必要なので、まずは定型コードを使ってこれらのファイルを作成しておきます。

srcディレクトリにindex.jsファイルを作成し、次のコードを記述します。

import fastify from 'fastify';
import mercurius from 'mercurius';
import jwt from 'jsonwebtoken';
import mercuriusAuth from 'mercurius-auth';
import schema from './graphql/schema.js'; 
import resolvers from './graphql/resolvers.js';

const port = process.env.PORT || 4500;
const app = fastify({ logger: true });

// Activate plugins below:
app.register(
  mercurius, { 
      schema, 
      resolvers, 
      graphiql: 'playground', 
      queryDepth: 7 
});

// register auth policy

// create server
const start = async () => {
  try {
    await app.listen(port);
  } catch (err) {
    app.log.error(err);
    process.exit(1);
  }
};
start();

上記のコードでは、基本的なFastifyサーバーを作成し、schema、resolvers、graphiql、queryDepthのオプションを指定してMercuriusプラグインを登録しています。

これで、npm run devを実行してサーバーを起動できます。出力は次のとおりです。

{"level":30,"time":1620202591072,"pid":11775,"hostname":"pc-name","msg":"Server listening at http://127.0.0.1:4500"}

サーバーが動作していることを確認できました。次のセクションでは、GraphQLを使ってブログAPIを構築します。

FastifyとGraphQLで安全なブログAPIを構築する

APIを安全にする方法はいくつかあります。たとえば、次のような方法です。

  • 認証と認可:認証は、ユーザーが本人であることを確認する仕組みです。認可は、ユーザーに付与された権限を扱います。認証によってユーザーがログインできるかどうかを判断し、その後もユーザーを識別できるようにします。認可では、識別済みのユーザーに割り当てる権限を定め、作成、読み取り、更新、削除などの操作を実行できるかどうかを制御します。

  • エラー情報のマスキング:サーバーエラーの詳細を隠し、サーバーの脆弱性を明らかにする可能性のある情報を、誤ってクライアントに提供しないようにします。

  • クエリの深さ制限:GraphQLクエリの最大深度を指定します。深くネストされたクエリは、多くのリソースを消費し、計算コストも高いため危険です。その結果、APIがクラッシュする可能性があります。

  • 入力値のサニタイズと検証:標準的なWebセキュリティ技術を使い、ユーザーが悪意のあるデータを送信できないようにします。ここでは、アプリケーションに組み込まれているGraphQLの検証機能を活用します。

この記事では、MercuriusとMercurius Authプラグインを使い、上記の方法でAPIを構築して安全にします。

今回使用するMercurius Authプラグインには、主に2つの機能があります。1つ目は、スキーマ内のフィールドにカスタム認証ディレクティブを定義できることです。認証ディレクティブは、スキーマ内の保護対象フィールドを識別するための文字列です。

さらに、GraphQLリクエストを処理する際に、これらの保護対象フィールドにカスタム認証ポリシーを適用できます。

まず、モックデータを作成します。srcディレクトリにdataフォルダーを作成し、その中に以下のコードを記述したindex.jsファイルを用意します。

export default {
    users: [
        { id: 1, username: 'JohnDoe', email: 'John_doe@gmail.com', password: '12345', role: 'admin' },
        { id: 2, username: 'JaneDoe', email: 'Jane_doe@gmail.com', password: '12345', role: 'user' },
        { id: 3, username: 'JoeDoe', email: 'Joe_doe@gmail.com', password: '12345', role: 'user' }
    ]
};

次に、schema.jsファイルの定型コードを以下のコードに置き換えて、schemaを設定します。

const schema = `

directive @auth(
    requires: Role = ADMIN,
  ) on OBJECT | FIELD_DEFINITION

  enum Role {
    ADMIN
    USER
  }

type Query {
    user(id: ID!): User! @auth(requires: ADMIN)
    users: [User]! @auth(requires: ADMIN)
    login(username:String!, password:String!): String
}

type User {
    id: ID!
    username: String!
    email: String!
    password: String!
    role: String!
}
`;

export default schema;

上記のコードではGraphQLスキーマを作成し、userフィールドとusersフィールドに認証ディレクティブを定義しました。続いて、これらの保護対象フィールドにカスタムポリシーを適用します。

次に、resolvers.jsファイルの定型コードを以下のコードに置き換えて、リゾルバーを追加します。

import jwt from 'jsonwebtoken';
import Data from '../data';

const resolvers = {
    Query: {
        users: async (_, obj) => Data.users,

        user: async (_, { id }) => {
            let user = Data.users.find((user) => user.id == id);
            if (!user) {
                throw new Error('unknown user');
            }
            return user;
        },

        login: async (_, { username, password }) => {
            let user = Data.users.find((user) => user.username === username && user.password === password);
            if (!user) {
                throw new Error('unknown user!');
            }

            const token = jwt.sign({ username: user.username, password: user.password, role: user.role }, 'mysecrete');
            return token;
        }
    }
};

export default resolvers;

上記のコードには、user、users、loginの各クエリを処理するリゾルバーが含まれています。

最後に、Mercurius Authプラグインを登録して、カスタム認証ポリシーを追加します。srcディレクトリにあるindex.jsファイルで、19行目にあるregister auth policyコメントの下に以下のコードを追加してください。

app.register(mercuriusAuth, {
    authContext(context) {
        return { identity: context.reply.request.headers['x-user'] };
    },
    async applyPolicy(authDirectiveAST, parent, args, context, info) {
        const token = context.auth.identity;
        try {
            const claim = jwt.verify(token, 'mysecrete');
        } catch (error) {
            throw new Error(`An error occurred. Try again!`);
        }

        return true;
    },
    authDirective: 'auth'
});

上記のカスタムポリシーでは、authContextメソッドがヘッダーからユーザートークンを取得し、applyPolicyメソッドが認証と認可のカスタムポリシーを定義します。

また、ユーザーの認証または認可に失敗した場合は、「エラーが発生しました。もう一度お試しください!」のような汎用メッセージを含むエラーをスローします。このメッセージは、サーバーの脆弱性を明らかにするおそれのある詳細なサーバーエラーメッセージの代わりにユーザーに表示されます。

これで実装は完了です。次のセクションでAPIをテストします。

APIをテストする

まず、ルートディレクトリでnpm run devを実行してサーバーを起動します。次に、http://localhost:4500/playgroundからGraphQL Playgroundにアクセスします。

usersやuserなどの保護対象APIにクエリを実行すると、以下のようなエラーが表示されます。

ユーザー情報をリクエストするユーザークエリと、「エラーが発生しました。もう一度お試しください!」というエラーレスポンスを表示したGraphQLインターフェース

クエリを成功させるには、認証が必要です。トークンを取得するためにログインしましょう。

ログインするには、Playgroundで新しいタブを開き、次のクエリを実行します。

query {
  login(username: "JohnDoe", password: "12345")
}

クエリが成功すると、以下の画像のようにトークンが生成され、返されます。

ユーザー名とパスワードのフィールドを含むログインクエリと、返された認証トークンを表示するGraphQLインターフェース

ログインに使うユーザーデータは、dataディレクトリ内のindex.jsファイルにあらかじめ記載されています。

そのファイルにないユーザーデータ(username: “John1Doe”など)でログインしようとすると、以下のようなエラーになります。

ユーザー名とパスワードのフィールドを含むログインクエリが表示され、「unknown user!」エラーを返すGraphQLインターフェース

次に、以下のようにx-userとしてトークンをヘッダーに渡すと、保護対象APIに正常にクエリを実行できます。ログインクエリで取得したトークンをコピーし、x-userの値として使ってください。

usersクエリ

usersクエリの例を以下に示します。

query {
  users {
    id
    username
    password
    email
    role
  }
}

以下のように、トークンをx-user HTTPヘッダーパラメーターの値として追加します。

{
  "x-user": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IkpvaG5Eb2UiLCJwYXNzd29yZCI6IjEyMzQ1Iiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNjQ0NTA3MDE5fQ.faslGjI6x-ODO2LGYOaTHClGs2MXCBOoMlWPYnwoH18"
}

実行すると、次の結果が得られます。

usersクエリ、HTTP認証ヘッダー、ユーザー名・パスワード・メールアドレス・ロールを含む返却データを表示したGraphQLクエリインターフェース。

userクエリ

userクエリの例を以下に示します。

query {
  user(id: "1") {
    id
    username
    email
    password
    role
  }
}

以下のように、トークンをx-user HTTPヘッダーパラメーターの値として追加します。

{
  "x-user": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IkpvaG5Eb2UiLCJwYXNzd29yZCI6IjEyMzQ1Iiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNjQ0NTA3MDE5fQ.faslGjI6x-ODO2LGYOaTHClGs2MXCBOoMlWPYnwoH18"
}

実行すると、次の結果が得られます。

ユーザークエリ、HTTP認証ヘッダー、ユーザー名、メールアドレス、パスワード、管理者ロールを含む返却データを表示したGraphQL Playground

まとめ

この記事では、FastifyとGraphQLを使ってシンプルなNode.jsアプリケーションを構築し、GraphQL APIを簡単に安全にできることを紹介しました。

説明したように、GraphQLには検証や型チェックなどのセキュリティ機能が組み込まれています。しかし、ユーザーが自由にデータを要求できる柔軟性と強力さがあるからこそ、セキュリティを常に最優先事項として考える必要があります。

この記事では、GraphQL APIを安全にする方法として、認証と認可、クエリの深さ制限、エラー情報のマスキング、入力値のサニタイズと検証についても説明しました。

これらのセキュリティ対策は非常に有効ですが、クエリのタイムアウトやレート制限などの方法を実装すれば、さらにセキュリティを強化できます。レート制限では、各期間内にクライアントがAPIにクエリを実行できる頻度を指定します。

CTFを始めよう

オンデマンドのバーチャル入門ワークショップで、CTFチャレンジの解き方を学びましょう。

無料のオンラインJavaScriptコードチェッカーをお試しください。Snyk Codeエンジンが、コードのセキュリティと品質に関する問題をどのように分析するかをご確認いただけます。