Korzen
Bot Protection

Supabase Auth

Supabase Auth と Bot Protection を組み合わせます。新規登録はサーバー経由で行い、それを経由しなかったものは Before User Created フックで拒否します。

Supabase Auth では、ブラウザーが Supabase を直接呼び出してアカウントを作成できるため、サーバーが新規登録を見ないままになることがあります。これを 2 つの仕組みで塞ぎます。サーバーが Bot Protection に問い合わせてから訪問者を登録し、Before User Created フックがサーバーを経由しなかった新規登録を拒否します。このガイドでは Next.js と @supabase/ssr を使いますが、どのサーバーでも同じ方法で動きます。

タグ

Next.js ガイドと同じく、ルートレイアウトに置きます。decide 関数もそのガイドのものを使います。

呼び出し

訪問者を登録する server action です。Bot Protection に問い合わせた後、Supabase に通行証を渡します。通行証はメールアドレスと時刻を SIGNUP_PASS_SECRET で署名したもので、このシークレットはお客様がランダムに作成し、両側に設定します。

// app/signup/actions.js
"use server";
import { createHmac } from "node:crypto";
import { decide } from "@/lib/korzen"; // from the Next.js guide
import { createClient } from "@/utils/supabase/server";

export async function signUp(prevState, formData) {
  const email = String(formData.get("email")).toLowerCase();
  const d = await decide("/signup");
  if (d?.action === "block") return { error: "We could not create your account." };
  // "challenge": your CAPTCHA, or a phone check, before going on
  const supabase = await createClient();
  const { error } = await supabase.auth.signUp({
    email,
    password: String(formData.get("password")),
    options: { data: { korzen_pass: pass(email), korzen_request_id: d?.request_id ?? null } },
  });
  // ...
}

// pass tells the hook this sign-up came through your server: the email and the time,
// signed with a secret only your server and the hook know.
function pass(email) {
  const ts = Date.now();
  const mac = createHmac("sha256", process.env.SIGNUP_PASS_SECRET).update(`${email}.${ts}`).digest("hex");
  return `${ts}.${mac}`;
}

フックは Edge Function として作ります。Supabase はユーザーを作成する前に、そのユーザーのメールアドレスと metadata を付けてこれを呼び出し、最大 5 秒待ちます。フックは、直近 5 分以内に作られた有効な通行証のないメールでの新規登録を拒否します。Bot Protection は一切呼び出さないので、Bot Protection が遅延したり停止したりしてもフックが止まることはありません。

// supabase/functions/before-user-created/index.ts
import { Webhook } from "https://esm.sh/standardwebhooks@1.0.0";

const hookSecret = Deno.env.get("BEFORE_USER_CREATED_HOOK_SECRET")!.replace("v1,whsec_", "");
const passSecret = new TextEncoder().encode(Deno.env.get("SIGNUP_PASS_SECRET")!);

Deno.serve(async (req) => {
  const payload = await req.text();
  let user;
  try {
    ({ user } = new Webhook(hookSecret).verify(payload, Object.fromEntries(req.headers)));
  } catch {
    return new Response("{}", { status: 401 });
  }
  // Sign-ups with Google, GitHub and the like never pass through your form.
  if (user.app_metadata?.provider !== "email" || (await passOK(user.email, user.user_metadata?.korzen_pass))) {
    return Response.json({});
  }
  return Response.json({ error: { http_code: 403, message: "Please sign up on our site." } }, { status: 403 });
});

// passOK checks the server's signature, made in the last 5 minutes for this email.
async function passOK(email: string, pass: unknown): Promise<boolean> {
  const [ts, mac] = String(pass ?? "").split(".");
  if (!ts || !mac || Date.now() - Number(ts) > 5 * 60_000) return false;
  const key = await crypto.subtle.importKey("raw", passSecret, { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
  const sig = Uint8Array.from(mac.match(/../g) ?? [], (h) => parseInt(h, 16));
  return crypto.subtle.verify("HMAC", key, sig, new TextEncoder().encode(`${email.toLowerCase()}.${ts}`));
}

supabase functions deploy before-user-created --no-verify-jwt で deploy し(Supabase はユーザーの token なしでフックを呼び出します)、関数に SIGNUP_PASS_SECRET を設定してから、Supabase の dashboard の Authentication、Hooks で、この関数を指す HTTP フックとしてオンにします。そこで表示されるシークレットは BEFORE_USER_CREATED_HOOK_SECRET として保管してください。

訪問者の IP

判定は server action の中で行われるため、IP は Next.js ガイドの説明どおりです。Vercel では x-forwarded-for、それ以外ではエッジが設定する独自のヘッダーを使います。

アクションを適用する

読むのは action だけです。block の場合は、その操作がほかの理由で失敗したときと同じエラーを返し、bot に何も悟らせないようにします。challenge の場合は、すでにある確認手段(CAPTCHA、メールや電話の確認コード)を使います。何もなければそのまま続けます。アカウントは monitor mode で始まり、その間 action は常に allow です。block mode をオンにするまで、訪問者にとっては何も変わりません。

request_id を保存する

すべての応答には request_id が含まれます。操作で作成されたもの(アカウントや注文)のカラムに保存するか、その ID と一緒にログに残してください。お問い合わせの際にその判定を特定でき、後からどの応答が偽アカウントを通したのかを調べられます。

手元のマシンでテストする

supabase start と next dev を実行します。supabase/config.toml でローカルのフックをオンにし、関数を起動します。

# supabase/config.toml
[auth.hook.before_user_created]
enabled = true
uri = "http://host.docker.internal:54321/functions/v1/before-user-created"
secrets = "env(BEFORE_USER_CREATED_HOOK_SECRET)"

# then: supabase functions serve before-user-created --no-verify-jwt

次に http://localhost:3000 を開きます。localhost 上のページは常にタグを受け付け、ドメインとして一覧に載ることはありません。ローカルでは IP が 127.0.0.1 または ::1 になりますが、問題ありません。自分のマシンの Docker でアプリを動かすと、代わりにブリッジのアドレス(172.17.0.1 など)になりますが、タグがそこであなたのページを見ているので、チャレンジページは localhost へ戻してくれます。

  1. .env.local に KORZEN_API_KEY を(supabase/functions/.env に SIGNUP_PASS_SECRET も)設定し、新規登録ページを開きます。数秒後、ブラウザーの開発者ツールに _kz Cookie が表示されます。
  2. 新規登録します。判定が dashboard の判定ページに、送信したパスで表示されます。
  3. fail-open を確認します。誤ったキーを設定してもう一度新規登録すると、呼び出しは 401 を返し、新規登録はそのまま完了します。

よくある間違い

  • server action だけでフックがない。bot は公開されている anon キーを使って、Supabase に直接新規登録できてしまいます。
  • 通行証のない新規登録をフックがすべて拒否する。Google や GitHub での新規登録はフォームを経由しません。上のコードはそれらを通します。
  • 両側でシークレットが異なる、またはメールアドレスを大文字・小文字の違うまま比較する。すべての新規登録が拒否されます。action もフックもメールアドレスを小文字にしています。
  • ブラウザーから /v1/decide を呼び出す。API key がすべての訪問者に渡ってしまいます。呼び出しはサーバーで行ってください。
  • タイムアウトがない、またはエラーで新規登録が止まる。200 以外の応答や、時間内に応答がない場合は、訪問者を通すという意味です。