Next.js - Cloudflare Turnstileの導入 と CAPTCHA回避(2captcha)対策
Cloudflare Turnstileとは
簡単に説明するなら
Cloudflareが提供するCAPTCHA代替ツールで、reCAPTCHAなどのCAPTCHAとは異なり、ユーザーが画像を選択したり文字を入力したりする必要がない代わりに、ユーザーの行動や環境情報を基にリスク評価を行い、人間かボットかを判断する。
導入
Cloudflare Turnstileを導入するための基本的な手順は以下の通りです。今回は、Next.jsを使用した例で解説します。
Cloudflareアカウント作成
-
サインイン - Cloudflare で作成してください
-
「Let’s make your website or app fast & secure」って画面が出たら、左上のCloudflareのロゴをクリック
Cloudflare Turnstileのサイトキーとシークレットキーの発行
- そしたら、ダッシュボードのホームに移動するので、左のサイドバーから「Turnstile」をクリック
- 「サイトを追加」をクリック
- 指定に沿って入力し、「作成」をクリック
「サイト名」: 自分に取ってわかりやすい好きな名前
「ドメイン」: Cloudflare Turnstileを置きたいサイトのもの (サブドメインも対象になる)
「ウィジェット モード」: デフォルトの「管理対象」が一番無難。詳しくは、Turnstileのウィジェット モードとは何者か
「事前クリアランス」: 選択肢の上に書いてある説明の通り。なんか、厳格にしておきたいから自分は、デフォルトの「いいえ」にした
- この画面になったら、後ででもキーの確認はできるので、Next.jsの方へ
クライアントコンポーネントの作成とサーバーサイドの処理
コンポーネント
※なぜ環境変数名に「NEXT_PUBLIC_」をつけるかについては、Next.js公式ドキュメントの Bundling Environment Variables for the Browser を参照 (要するに、クライアント(ブラウザ)側で処理する際に使う環境変数につける)
"use client";
import Script from "next/script";
export const Turnstile = () => (
<>
<div
className="cf-turnstile"
data-sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY}
style={{
margin: "20px"
}}
/>
<Script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer />
</>
);
- 使い方の例
import { Turnstile } from "@/components/turnstile";
import { useTransition } from "react";
import { Login } from "./actions";
import { toast } from "sonner"; // これについては、https://sonner.emilkowal.ski/ を参照
export default function LoginForm() {
const [isPending, startTransition] = useTransition();
const action = async (formData: FormData) => {
if (isPending) return;
startTransition(async () => {
try {
const result = await Login(formData);
if (result?.error) throw new Error(result.error);
} catch(e: unknown) {
if (e instanceof Error) {
toast.error(e.message);
console.error(e);
/* Cloudflare Turnstile 再検証 */
window.turnstile.reset();
} else {
toast.error("異常なエラーを検知しました");
}
}
})
}
return (
<form action={Login}>
<input
type="email"
name="email"
autoComplete="email"
/>
<input
type="password"
name="password"
autoComplete="current-password"
/>
<Turnstile />
<button disabled={isPending} type="submit">
{isPending ? "処理中..." : "ログイン"}
</button>
</form>
)
}
Cloudflare Turnstileのトークン検証関数 (サーバーサイド)
「Server Actions」で使うことを前提としてるため「Route handler」ではテストしてません
※「Server Actions」については、サーバアクション - React と Server Actions and Mutations を参照
type TurnstileErrorCodes = "missing-input-secret" | "invalid-input-secret" | "missing-input-response" | "invalid-input-response" | "bad-request" | "timeout-or-duplicate" | "internal-error";
type TurnstileResult = {
success: false;
"error-codes": TurnstileErrorCodes[];
messages: []
} | {
success: true;
"error-codes": [];
challenge_ts: string;
hostname: string;
action: string;
cdata: string;
idempotency_key?: string;
metadata?: {
interaction?: boolean
}
}
/**
* check Cloudflare Turnstile's Verify Token
* @param token 検証トークン
* @returns
*/
export const verifyTurnstileToken = async (token: string): Promise<void> => {
if (!token) throw new Error("Cloudflare Turnstileの検証が完了していません。");
const formData = new FormData();
formData.append("secret", process.env.TURNSTILE_SECRETKEY);
formData.append("response", token);
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body: formData,
});
const { success }: TurnstileResult = await res.json();
if (!success) throw new Error("Cloudflare Turnstileの検証トークンが不正です。");
};
Server Actions側の処理
"use server";
import { verifyTurnstileToken } from "@/lib/verify/turnstile.ts"
export const Login = async (formData: FormData) => {
try {
const {
email, password
"cf-turnstile-response": turnstile_token
} = Object.fromEntries(formData.entries()) as { [k: string]: string };
await verifyTurnstileToken(turnstile_token);
// ...ログイン処理...
} catch(e: unknown) {
// ログインできなかった際のエラー
if (e instanceof Error) {
console.error(e);
return {
error: e.message
}
}
}
}
最後に.env(環境変数)をセットする
- 先程のCloudflareのダッシュボードの「Trunstile」のページに戻る
- 「Settings」をクリック
-
スクロールして、サイトキーとシークレットキーをコピー
-
".env"ファイルに以下のように書き込む
NEXT_PUBLIC_TURNSTILE_SITEKEY=ここにサイトキー
TURNSTILE_SECRETKEY=ここにシークレットキー
以上。
CAPTCHA回避対策
「あれ?でも、「2captcha」などのCAPTCHA回避サービスがあるから、回避されちゃって意味ないんじゃ...」って思った人のために書く
経緯
remoteipパラメータを指定すれば、検証者と訪問者の整合性が保たれる的なこと書いてあるけど、ホントかな?
でも、指定しても厳格には検証されないとも書いてある
気になったので、テスト用コードを書いてみた
import { Solver } from "2captcha";
const solver = new Solver(process.env.CAPTCHA_APITOKEN);
const result = await solver.turnstile(process.env.CLOUDFLARE_TURNSTILE_SITEKEY, process.env.TEST_HOSTNAME);
const formData = new FormData();
formData.append("secret", process.env.CLOUDFLARE_TURNSTILE_SECRET);
formData.append("response", result.data);
formData.append("idempotency_key", crypto.randomUUID());
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body: formData,
});
console.log(await res.json())
最初は、remoteipなしでテストしてみた
{
success: true,
"error-codes": [],
challenge_ts: "2024-08-27T04:10:09.988Z",
hostname: "verbose-goldfish-jjrqqq5qrj354r-3000.app.github.dev",
action: "",
cdata: "",
metadata: {
interactive: true,
},
}
さすがは、2captcha見事回避して見せる
次は、テストとして、remoteipパラメータに適当な文字列を指定してみる
formData.append("remoteip", "test");
結果、
{
success: true,
"error-codes": [],
challenge_ts: "2024-08-27T04:12:16.508Z",
hostname: "verbose-goldfish-jjrqqq5qrj354r-3000.app.github.dev",
action: "",
cdata: "",
metadata: {
interactive: true,
},
}
ん...?通過できちゃってる?
なんか、remoteipヘッダー機能してないような?
自分のIPやプライベートIPを入れてみたが変わらず
これじゃあ、何を検証してんのかわかんねえ...
ってことで、Cloudflare公式discord鯖で聞いてみた
返答「なぜremoteipパラメータの指定が効かないかはわからないけど、これを試してみて」
remoteip_leniencyパラメータ
提案してくれたものを試してみることに
「まだ、ドキュメント化してないけど、remoteip_leniencyパラメーターを使ってみて」
要するにこの方法は、まだ最終決定されていないため変更が加えられる可能性がある
でも、使ってみたいので試してみることに
formData.append("remoteip", ipAddress); // remoteipパラメーターと併用するものらしい
formData.append("remoteip_leniency", "strict");
まずは、厳格モードから
✅: 検証通過
❌: 検証エラー
通常 | 2captcha |
---|---|
❌ | ❌ |
うぇえ...
厳格すぎん?いきなり、全部ブロックしてきた
これについては、認証時とサイト訪問時に取得されたIPが、ipv4とipv6かの問題が関係してくるらしい
でも、remoteip_leniencyが機能することが確認できた!
次は、リラックス(relaxed)モード?
formData.append("remoteip", ipAddress); // remoteipパラメーターと併用するものらしい
formData.append("remoteip_leniency", "relaxed");
通常 | 2captcha |
---|---|
✅ | ❌ |
わぉ...
うまく、検証できてるっぽい
さっき上で書いた「Cloudflare Turnstileのトークン検証関数 (サーバーサイド)」の置き換えとして、CAPTCHA回避対策されたものを書いていく
// CAPTCHA回避対策付き
import { headers } from 'next/headers'
type TurnstileErrorCodes = "missing-input-secret" | "invalid-input-secret" | "missing-input-response" | "invalid-input-response" | "bad-request" | "timeout-or-duplicate" | "internal-error";
type TurnstileResult = {
success: false;
"error-codes": TurnstileErrorCodes[];
messages: []
} | {
success: true;
"error-codes": [];
challenge_ts: string;
hostname: string;
action: string;
cdata: string;
idempotency_key?: string;
metadata?: {
interaction?: boolean
}
}
/**
* check Cloudflare Turnstile's Verify Token
* @param token 検証トークン
* @returns
*/
export const verifyTurnstileToken = async (token: string): Promise<void> => {
if (!token) throw new Error("Cloudflare Turnstileの検証が完了していません。");
const header = headers();
const ipAddress = headers().get("x-real-ip");
const formData = new FormData();
formData.append("secret", process.env.TURNSTILE_SECRETKEY);
formData.append("response", token);
formData.append("remoteip", ipAddress);
formData.append("remoteip_leniency", "relaxed");
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body: formData,
});
const { success }: TurnstileResult = await res.json();
if (!success) throw new Error("Cloudflare Turnstileの検証トークンが不正です。");
};
早く正式に実装してほしい。
(正式に決まらなくても、有能すぎて一旦でもドキュメントに書いてほしいわ...)
以上。
Discussion
あざあああああすうううう
うにゃ