Discord Oauth2でWeb認証を作る | Next.js

に公開
2

はじめに

本記事では、Next.js、React、Tailwind CSS、Cloudflare Turnstile、そして Discord OAuth 2 を組み合わせて、セキュアな Web 認証システムを実装する方法を紹介します。

ソースコードは以下のリポジトリで公開しています。
https://github.com/yuk228/Discord-Oauth2-Web-Verify

認証システムの主な動作フロー

  1. ユーザーが Discord サーバーに参加
  2. Cloudflare Turnstile でボットチェックを実施
  3. Discord OAuth 2 でユーザー情報を取得
  4. 指定したロールをユーザーに付与
  5. 認証完了ログを送信

Cloudflare Turnstile とは

Cloudflare Turnstile は、以下の画像のようなチェックボックス型のボット対策サービスです。

reCAPTCHA や hCaptcha と異なり、面倒な画像認証を解く必要がなく、チェックボックスをクリックするだけでボットかどうかを判定できます

環境構築

Next.js プロジェクトの作成

npx create-next-app@latest discord-web-verify \
  --typescript --eslint --tailwind --app --import-alias "@/*" --use-turbopack

追加パッケージのインストール

iron-session

npm i iron-session

iron-session は Cookie を利用したセッション管理ライブラリです。
保存されるデータは AES-256-CBC で暗号化されます。
詳しくは以下の記事が参考になります。
https://qiita.com/aurora1530/items/015cdef0fc9c8033c949

next-turnstile

npm i next-turnstile

next-turnstile は、Next.js 用の Cloudflare Turnstile 統合パッケージです。サーバーサイド・クライアントサイドの両方で動作し、簡単に導入できます。
https://github.com/JedPattersonn/next-turnstile

shadcn/ui

npx shadcn@latest init
npx shadcn@latest add button

shadcn/ui はモダンで美しい UI コンポーネントを提供するライブラリです。今回は Button コンポーネントを使用します。
https://ui.shadcn.com/docs/installation/next

事前準備

Discord Developer Portal

Discord Developer Portal で Bot を作成してください。

  1. 「OAuth 2」の「Redirects」に https://localhost:3000/api/callback を入力
  2. 「OAuth 2 URL Generator」で identify にチェック
  3. 生成された URL をコピー

この URL はユーザー認証時に使用します。
https://discord.com/developers/applications

環境変数の設定

プロジェクトのルートディレクトリに .env.local ファイルを作成し、以下の環境変数を設定してください。

# Discord OAuth2
CLIENT_ID=              # Discord Developer Portal から取得
CLIENT_SECRET=          # 同上
BASE_URL=http://localhost:3000

# Discord サーバー
DISCORD_GUILD_ID=       # 対象サーバーの ID
DISCORD_ROLE_ID=        # 認証成功時に付与するロール ID
DISCORD_BOT_TOKEN=      # Bot トークン
DISCORD_WEBHOOK=        # 認証ログ用 Webhook URL

# Cloudflare Turnstile
NEXT_PUBLIC_TURNSTILE_SITE_KEY=  # Cloudflare ダッシュボードから取得
TURNSTILE_SECRET_KEY=            # 同上

# Session
SESSION_PASSWORD=                # 32 文字のランダム英数字

Cloudflare Turnstile の設定

  1. Cloudflare ダッシュボード にアクセス
  2. 「Turnstile」を選択
  3. 「Add Widget」をクリック
  4. 以下を設定:
    • Widget name: 任意
    • Add Hostnames: localhost:3000(開発環境の場合)
  5. 「Create」をクリック
  6. 表示された Site Key と Secret Key を環境変数へ設定

実装

Session の作成

lib/session.ts
export interface SessionData {
    csrfToken?: string;
    code?: string;
}

import { SessionOptions } from "iron-session";

export const sessionOptions: SessionOptions = {
    password: process.env.SESSION_PASSWORD!,
    cookieName: "discord-verify-session",
    cookieOptions: {
        secure: process.env.NODE_ENV === "production",
        httpOnly: true,
        sameSite: "lax",
        path: "/",
        maxAge: 60 * 60,
    },
};

iron-session を用いてセッションを設定します。

password

セッションの暗号化に使用します。

secure

process.env.NODE_ENV === "production" とすることで、本番環境のみ HTTPS が必須になります。

httpOnly

通常は document.cookie などの JavaScript から Cookie にアクセスできますが、httpOnly を指定するとサーバーサイドからのみアクセス可能になります。

sameSite

CSRF 攻撃対策で使用します。
strict
同一サイトからのリクエストのみ Cookie を送信します。

リクエスト元 リクエスト先 GET POST リンク
example.com example.com
other.com other.com

lax(今回使用)
外部サイトからの POST 以外で Cookie を送信します。

リクエスト元 リクエスト先 GET POST リンク
example.com example.com
other.com other.com

none(非推奨)
すべてのリクエストで Cookie を送信します。

リクエスト元 リクエスト先 GET POST リンク
example.com example.com
other.com other.com

OAuth 2 コールバックの処理

app/api/callback/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getIronSession } from "iron-session";
import { sessionOptions } from "@/lib/session";
import { SessionData } from "@/lib/type";
import crypto from "crypto";

export async function GET(req: NextRequest) {
  const res = new NextResponse();
  try {
    const session = await getIronSession<SessionData>(req, res, sessionOptions);
    const code = new URL(req.url).searchParams.get("code");

    if (!code) {
      throw new Error("No code provided");
    }

    const csrfToken = crypto.randomBytes(32).toString("hex");

    session.code = code;
    session.csrfToken = csrfToken;
    await session.save();

    return createRedirectResponse("/verify", res);
  } catch (error) {
    console.log("Error in api/callback:", error);
    return createRedirectResponse("/error", res);
  }
}

Discord OAuth 2 認証後のコールバックを処理します。認証が成功すると、Discord は指定したリダイレクト URI に code パラメータを付与してリダイレクトします。

  1. 先ほど作成した sessionOptionsreq / res でセッションを初期化
  2. 32 バイトの CSRF トークンを生成(hex 変換)
  3. クエリパラメータから code を取得
  4. CSRF トークンと code をセッションへ保存 → await session.save()
  5. 成功したら /verify へリダイレクトし、Cookie も転送
function createRedirectResponse(path: string, res: NextResponse): NextResponse {
    const redirectUrl = new URL(path, process.env.BASE_URL);
    const response = NextResponse.redirect(redirectUrl);

    const cookie = res.headers.get("Set-Cookie");
    if (cookie) {
        response.headers.set("Set-Cookie", cookie);
    }

    return response;
}

検証ページへリダイレクトが必要な理由

コールバック処理はサーバーサイドで実行されます。一方、Cloudflare Turnstile や React の状態管理(useState)はクライアントサイドで動作する必要があるため、一度クライアントサイドのページにリダイレクトしています。

検証ページの実装

https://github.com/yuk228/Discord-Oauth2-Web-Verify/blob/main/app/verify/page.tsx

1. /api/csrf へ GET し CSRF トークンを取得

api/csrf に GET リクエストを送り、セッションに含まれる CSRF トークンを取得します。
これは useEffect を使ってページ読み込み時に実行します。

  useEffect(() => {
    const fetchCsrfToken = async () => {
      try {
        const res = await fetch("/api/csrf", {
          method: "GET",
          credentials: "include",
          headers: {
            Accept: "application/json",
          },
        });
        if (!res.ok) {
          throw new Error("Failed to fetch CSRF token");
        }
        const data = await res.json();
        setCsrfToken(data.csrfToken);
      } catch (error) {
        console.error("Error fetching CSRF token:", error);
        router.push("/error");
      }
    };
    fetchCsrfToken();
  }, [router]);

ReactのuseState()を用いてcsrf tokenをstateに保存します。

const [csrfToken, setCsrfToken] = useState<string | null>(null);

3. Cloudflare Turnstileの表示と検証、tokenの保存

next-turnstileを使用してUIを表示します。

<Turnstile
  siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY as string}
  onVerify={(token) => {
    setToken(token);
  }}
/>

onVerify()で認証完了時、tokenをstate保存します。

4. 認証ボタンクリック時、csrf tokenとtokenをapi/verifyにPOST

stateからcsrf token, tokenを取得し、headersのX-CSRF-TOKENとbodyにそれぞれ設定してapi/verifyにPOSTリクエストを送ります。

認証に成功したら/successに、失敗したら/errorに遷移します。

  const handleVerify = async () => {
    if (!token || !csrfToken) return;
    try {
      const res = await fetch("/api/verify", {
        method: "POST",
        credentials: "include",
        headers: {
          "Content-Type": "application/json",
          "X-CSRF-Token": csrfToken,
        },
        body: JSON.stringify({
          token,
        }),
      });

      const result = await res.json();

      if (result.status === 200) {
        router.push("/success");
      } else {
        router.push("/error");
      }
    } catch {
      router.push("/error");
    }

Suspenseとは

export default function VerifyPage() {
    return (
        <Suspense fallback={<div>Loading...</div>}>
            <Verify />
        </Suspense>
    );
}

Suspenseとは、コンポーネントがロード中の時に別のコンポーネントを読み込む仕組みです。
今回は、ロード中に<div>Loading...</div>を表示させ、終わり次第<Verify />を表示させています。

なんでこれを急に使ったかというと、useSearchParams()使用時にはSuspenseで囲わないとエラーになるからです。
実際に、以下のようなエラーが出力されます。

useSearchParams() should be wrapped in a suspense boundary at page "/verify". | useSearchParams() は、ページ "/verify" のサスペンス境界で囲む必要があります。

useSearchParams()使用時にページ全体がクライアントサイドレンダリングに設定され、クライアントサイドのJavaScriptが読み込まれるまでページが空白になる可能性があるからとのこと。

https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout

検証処理の実装

Turnstileトークンの検証

lib/functions/verify.ts
export async function verifyToken(token: string) {
  try {
    const verificationResponse = await fetch(
      "https://challenges.cloudflare.com/turnstile/v0/siteverify",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          secret: process.env.TURNSTILE_SECRET_KEY as string,
          response: token,
        }),
      }
    );

    if (!verificationResponse.ok) {
      throw new Error("Failed to verify token");
    }

    return await verificationResponse.json();
  } catch (error) {
    console.log("Error in verify csrf token:", error);
    throw error;
  }
}

この関数は、Cloudflare Turnstileのトークンを検証します。トークンとシークレットキーを使用して検証APIにリクエストを送信し、結果を返します。

Discord アクセストークンの取得

lib/functions/verify.ts
export async function getToken(code: string) {
  try {
    const body = new URLSearchParams({
      grant_type: "authorization_code",
      code: code,
      redirect_uri: `${process.env.BASE_URL}/api/callback`,
    }).toString();

    const token = await fetch(`https://discord.com/api/v10/oauth2/token`, {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Authorization:
          "Basic " +
          Buffer.from(`${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`).toString("base64"),
      },
      body: body,
    });

    if (!token.ok) {
      throw new Error("Failed to fetch token");
    }

    return await token.json();
  } catch (error) {
    console.log("Error in get token from discord:", error);
    throw error;
  }
}

この関数は、OAuth 2 の認証コードを使用して Discord のアクセストークンを取得します。同時にリフレッシュトークンも取得できますが、今回は一時的な認証のみを行うため使用しません。

ユーザー情報の取得

型定義

lib/type.ts
export interface DiscordUser {
  id: number;
  username: string;
  global_name: string;
  avatar_id: string;
  locale: string;
  mfa_enabled: boolean;
}

ユーザー情報取得関数

lib/functions/get-info.ts
import { DiscordUser } from "../type";

export async function getInfo(accessToken: string): Promise<DiscordUser> {
  try {
    const res = await fetch(`https://discord.com/api/users/@me`, {
      method: "GET",
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });
    const userInfo = await res.json();
    return userInfo as DiscordUser;
  } catch (error) {
    console.log("Error in getInfo:", error);
    throw error;
  }
}

この関数は、取得したアクセストークンを使用してDiscord APIからユーザー情報を取得します。

ロールの付与

lib/functions/assign-role.ts
export async function assignRole(userId: string) {
  try {
    const guildId = process.env.DISCORD_GUILD_ID;
    const roleId = process.env.DISCORD_ROLE_ID;
    const botToken = process.env.DISCORD_BOT_TOKEN;

    const res = await fetch(
      `https://discord.com/api/v10/guilds/${guildId}/members/${userId}/roles/${roleId}`,
      {
        method: "PUT",
        headers: {
          Authorization: `Bot ${botToken}`,
          "Content-Type": "application/json",
        },
      }
    );

    if (!res.ok) {
      throw new Error("Failed to assign role");
    }
  } catch (error) {
    console.log("Error in assign role:", error);
    throw error;
  }
}

この関数は、認証が成功したユーザーに指定したロールを付与します。

ログの送信

lib/functions/logger.ts
import { DiscordUser } from "../type";

export async function logger(userInfo: DiscordUser) {
    try {
        const webhookUrl = process.env.DISCORD_WEBHOOK || "";

        const fields = [
            {
                name: "👤ユーザー",
                value: `${userInfo.global_name}(${userInfo.username || userInfo.username})`,
                inline: false,
            },
            {
                name: "✉️ユーザー情報",
                value: `ID: \`${userInfo.id}\`\n言語: \`${userInfo.locale}\`\nMFA: \`${userInfo.mfa_enabled}\``,
                inline: false,
            },
        ];

        const embed = {
            title: "✅認証成功",
            fields: fields,
            thumbnail: {
                url: `https://cdn.discordapp.com/avatars/${userInfo.id}/${userInfo.avatar_id}.png`,
            },
            color: 0x7e22d2,
            timestamp: new Date().toISOString(),
        };

        const response = await fetch(webhookUrl, {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify({
                embeds: [embed],
            }),
        });

        if (!response.ok) {
            throw new Error("Webhook request failed");
        }
    } catch (error) {
        console.log("Error in logger:", error);
        throw error;
    }
}

この関数は、認証成功時にDiscord Webhookを使用してログを送信します。
アクセストークンから取得した情報を使用しています。

検証APIの実装

app/api/verify/route.ts
import { logger } from "@/lib/functions/logger";
import { NextRequest, NextResponse } from "next/server";
import { assignRole } from "@/lib/functions/assign-role";
import { getInfo } from "@/lib/functions/get-info";
import { getToken, verifyToken } from "@/lib/functions/verify";
import { getIronSession } from "iron-session";
import { sessionOptions } from "@/lib/session";
import { SessionData } from "@/lib/type";

export async function POST(req: NextRequest) {
  const res = new NextResponse();
  const session = await getIronSession<SessionData>(req, res, sessionOptions);
  try {
    const body = await req.json();
    const { token } = body;
    const csrfToken = req.headers.get("X-CSRF-Token");

    if (!token || !csrfToken || !session.code) {
      console.log("Missing required parameters: token, csrfToken, or session.code not found");
      return NextResponse.json({ status: 400 });
    }

    if (!session.csrfToken || session.csrfToken !== csrfToken) {
      console.log("CSRF token is incorrect");
      return NextResponse.json({ status: 400 });
    }

    await verifyToken(token);
    const getTokenResult = await getToken(session.code as string);
    const userInfo = await getInfo(getTokenResult.access_token);
    await assignRole(userInfo.id.toString());
    await logger(userInfo);

    session.code = undefined;
    session.csrfToken = undefined;
    await session.save();

    return NextResponse.json({ status: 200 });
  } catch (error) {
    console.log("Error in /api/verify:", error);

    session.code = undefined;
    session.csrfToken = undefined;
    await session.save();

    return NextResponse.json({ status: 500 });
  }
}

このAPIは以下の手順で認証を処理します:

  1. CSRFトークンの検証
  2. Turnstileトークンの検証
  3. Discord アクセストークンの取得
  4. ユーザー情報の取得
  5. ロールの付与
  6. ログの送信

error/successページの作成

app/success/page.tsx
export default function Success() {
    return (
        <main className="flex min-h-screen items-center justify-center">
            <div className="p-10 rounded-4xl md:w-1/2 max-w-md border border-white/[0.10]">
                <div className="mx-auto">
                    <h1 className="text-xl font-bold mb-4 text-center">認証が完了しました</h1>
                    <p className="text-center text-muted-foreground mb-6">
                        認証が正常に完了しました。このページは閉じて構いません。
                    </p>
                </div>
            </div>
        </main>
    )
}
app/error/page.tsx
export default function Error() {
    return (
        <main className="flex min-h-screen items-center justify-center">
            <div className="p-10 rounded-4xl md:w-1/2 max-w-md border border-white/[0.10]">
                <div className="mx-auto">
                    <h1 className="text-xl font-bold mb-4 text-center">エラーが発生しました</h1>
                    <p className="text-center text-muted-foreground mb-6">
                        認証処理中にエラーが発生しました。もう一度お試しください。
                    </p>
                </div>
            </div>
        </main>
    )
}

お好みでスタイリングして下さい。

まとめ

本稿では、Discord OAuth 2 と Cloudflare Turnstile を組み合わせたセキュアな Web 認証アプリの作り方を紹介しました。

バックアップBOTの作成など、様々なことに応用出来ると思いますので、気になる方は試してみて下さい。

Discussion

ritouritou

特にOAuth2を使って認証機能を実装するにあたり、可能セキュリティ対策をとること、ドキュメント通りに実装することが重要です。
この記事ではOAuth2でもっとも代表的な攻撃であるCSRFへの対策のためのパラメータハンドリングが考慮されていないように見受けられます。

discord側のドキュメントにも推奨という書き方がされていますので対応を検討いただくのが良いでしょう。
https://discord.com/developers/docs/topics/oauth2#state-and-security

Yuki.Yuki.

記事を見て頂き、ありがとうございます。

申し訳ありません。パラメータハンドリング等、セキュリティ対策の勉強及び公式ドキュメントを見て執筆しなおします。