Discord Oauth2でWeb認証を作る | Next.js
はじめに
本記事では、Next.js、React、Tailwind CSS、Cloudflare Turnstile、そして Discord OAuth 2 を組み合わせて、セキュアな Web 認証システムを実装する方法を紹介します。
ソースコードは以下のリポジトリで公開しています。
認証システムの主な動作フロー
- ユーザーが Discord サーバーに参加
- Cloudflare Turnstile でボットチェックを実施
- Discord OAuth 2 でユーザー情報を取得
- 指定したロールをユーザーに付与
- 認証完了ログを送信
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
で暗号化されます。
詳しくは以下の記事が参考になります。
next-turnstile
npm i next-turnstile
next-turnstile
は、Next.js 用の Cloudflare Turnstile 統合パッケージです。サーバーサイド・クライアントサイドの両方で動作し、簡単に導入できます。
shadcn/ui
npx shadcn@latest init
npx shadcn@latest add button
shadcn/ui
はモダンで美しい UI コンポーネントを提供するライブラリです。今回は Button コンポーネントを使用します。
事前準備
Discord Developer Portal
Discord Developer Portal で Bot を作成してください。
- 「OAuth 2」の「Redirects」に
https://localhost:3000/api/callback
を入力 - 「OAuth 2 URL Generator」で
identify
にチェック - 生成された URL をコピー
この URL はユーザー認証時に使用します。
環境変数の設定
プロジェクトのルートディレクトリに .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 の設定
- Cloudflare ダッシュボード にアクセス
- 「Turnstile」を選択
- 「Add Widget」をクリック
- 以下を設定:
- Widget name: 任意
- Add Hostnames:
localhost:3000
(開発環境の場合)
- 「Create」をクリック
- 表示された Site Key と Secret Key を環境変数へ設定
実装
Session の作成
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 コールバックの処理
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
パラメータを付与してリダイレクトします。
- 先ほど作成した
sessionOptions
とreq
/res
でセッションを初期化 - 32 バイトの CSRF トークンを生成(hex 変換)
- クエリパラメータから
code
を取得 - CSRF トークンと
code
をセッションへ保存 →await session.save()
- 成功したら
/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
)はクライアントサイドで動作する必要があるため、一度クライアントサイドのページにリダイレクトしています。
検証ページの実装
/api/csrf
へ GET し CSRF トークンを取得
1. 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保存します。
api/verify
にPOST
4. 認証ボタンクリック時、csrf tokenとtokenを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が読み込まれるまでページが空白になる可能性があるからとのこと。
検証処理の実装
Turnstileトークンの検証
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 アクセストークンの取得
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 のアクセストークンを取得します。同時にリフレッシュトークンも取得できますが、今回は一時的な認証のみを行うため使用しません。
ユーザー情報の取得
型定義
export interface DiscordUser {
id: number;
username: string;
global_name: string;
avatar_id: string;
locale: string;
mfa_enabled: boolean;
}
ユーザー情報取得関数
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からユーザー情報を取得します。
ロールの付与
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;
}
}
この関数は、認証が成功したユーザーに指定したロールを付与します。
ログの送信
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の実装
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は以下の手順で認証を処理します:
- CSRFトークンの検証
- Turnstileトークンの検証
- Discord アクセストークンの取得
- ユーザー情報の取得
- ロールの付与
- ログの送信
error/successページの作成
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>
)
}
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
特にOAuth2を使って認証機能を実装するにあたり、可能セキュリティ対策をとること、ドキュメント通りに実装することが重要です。
この記事ではOAuth2でもっとも代表的な攻撃であるCSRFへの対策のためのパラメータハンドリングが考慮されていないように見受けられます。
discord側のドキュメントにも推奨という書き方がされていますので対応を検討いただくのが良いでしょう。
記事を見て頂き、ありがとうございます。
申し訳ありません。パラメータハンドリング等、セキュリティ対策の勉強及び公式ドキュメントを見て執筆しなおします。