0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js + Supabaseで作るTRPG管理ツール — 匿名認証まわりの設計と注意点

0
Posted at

背景

個人開発しているTRPG(卓上RPG)のGM向け管理ツール「CampaignDesk」では、GMが発行した招待リンク(/join/:campaignId)からプレイヤーがキャンペーンに参加します。

当初、このアプリは全ルートがログイン必須でした。つまり招待リンクを踏んだプレイヤーは、参加する前にまずアカウント登録(メール認証)を求められる構成になっていました。

これは明らかに離脱ポイントです。GMからDiscordなどでリンクを渡されたプレイヤーが、遊ぶ前にメール確認まで求められたら普通に面倒くさがって離脱します。かといって「参加だけなら誰でも自由に」とRLSを緩めるのはデータ設計上避けたい。この間を埋める方法として、Supabaseの Anonymous Sign-ins を採用しました。

なぜAnonymous Sign-insを選んだか

候補として以下を検討しました。

  • メールリンク認証を必須のまま: 離脱率が高い(今回の課題そのもの)
  • 招待トークンを自作してRLSをトークンベースに変更: 全テーブルのRLS設計を作り直す必要があり影響範囲が大きい
  • Supabase Anonymous Sign-ins: Postgres側は「ロールがauthenticatedのユーザー」という点で通常ユーザーと同じ扱いになるため、既存のRLS/外部キー設計をほぼ変更せずに済む

signInAnonymously() で発行されるセッションは、auth.uid() が実在し、Postgresロールも authenticated になります。JWTに is_anonymous: true のクレームが乗るだけなので、既存の「auth.uid() ベースのRLS」「auth.users への外部キー」という設計をそのまま使い回せる、という点が決め手でした。

実装

middlewareでのルーティング制御

認証状態のチェックは src/lib/supabase/middleware.ts で行っています。ポイントは以下の3つです。

  • /dashboard はGM専用なので、匿名ユーザーはアクセス不可(is_anonymous を見て弾く)
  • /join, /play は未ログインなら自動的に匿名サインインしてそのまま通す
  • 匿名サインインが失敗した場合は、これまで通り /login にフォールバック(既存ユーザー体験を壊さない)

const { data: { user } } = await supabase.auth.getUser();
const isRealUser = !!user && !user.is_anonymous;

const { pathname } = request.nextUrl;
const guestAllowedPrefixes = ["/join", "/play"];

if (!isRealUser && pathname.startsWith("/dashboard")) {
const url = request.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("next", pathname + request.nextUrl.search);
return NextResponse.redirect(url);
}

if (!user && guestAllowedPrefixes.some((prefix) => pathname.startsWith(prefix))) {
const { error } = await supabase.auth.signInAnonymously();
if (error) {
const url = request.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("next", pathname + request.nextUrl.search);
return NextResponse.redirect(url);
}
return supabaseResponse;
}

「GM専用の/dashboard」と「ゲスト参加を許す/join・/play」を明確に分けているのがポイントです。これを分けないと、匿名の「ゲスト」が普通にダッシュボードへ入り込み、無料枠のキャンペーンを作成できてしまう、という意図しない抜け道ができてしまいます。

落とし穴になりそうだった点

参加処理を担う join_campaign() はこういうSQL関数です。

insert into campaign_players (campaign_id, user_id, player_name)
values (v_campaign_id, auth.uid(), p_player_name);

campaign_players.user_id は auth.users(id) への not null な外部キーです。もしAnonymous Sign-insが「本物のauth.usersレコードを作らない、別枠の仮ユーザー」のような実装だったら、ここでFK制約に引っかかってこの設計は破綻していました。

実際にはAnonymous Sign-insも auth.users に通常のユーザーと同じ形でレコードを作るため、SQL側は一切変更せずに済みました。ここは実装前にコードを追って確認しておいて正解でした(「middlewareだけ直せば動くだろう」で進めていたら、この場所で初めて気づいて手戻りになっていたと思います)。

セキュリティは大丈夫か

匿名認証を有効化すると「誰でも認証済みユーザーになれる」状態になるので、既存のRLSがそのまま安全かどうかは別途確認が必要でした。

確認した観点は以下の3点です。

  1. 全テーブルでRLSが有効になっているか — マイグレーション全体を確認し、RLS未設定のテーブルがないことを確認
  2. ポリシーがauth.uid()ベースで正しくスコープされているか — 例えば参加者向けのイベント取得は次のように campaign_players へのメンバーシップチェックが入っている

-- session_events の SELECT ポリシー例
exists (
select 1 from campaign_players cp
where cp.campaign_id = session_events.campaign_id
and cp.user_id = auth.uid()
)

  1. SECURITY DEFINER関数がRLSをバイパスする代わりに、内部で権限チェックをしているか — 例えば get_player_session_view() は関数内で自前のメンバーシップチェックを行い、非メンバーなら例外を投げる設計になっています

if not exists (
select 1 from campaign_players cp
where cp.campaign_id = p_campaign_id and cp.user_id = auth.uid()
) then
raise exception 'not a member of this campaign';
end if;

匿名セッションであっても auth.uid() は実在するUUIDなので、これらのチェックは通常ユーザーとまったく同じロジックで機能します。匿名認証を入れたからといって新しく空いた穴は無い、という結論になりました。
そのまま使うと危険です。実際に参加した匿名ユーザーは campaign_players に行を持ち、キャラクターシートやセッション履歴という実データを紐づけて持っているため、一律削除だとプレイヤーのデータを黙って消してしまいます。

そこで、campaign_players に一度も紐づかなかった匿名ユーザーだけを対象にする関数を pg_cron で毎日実行する形にしました。

create or replace function cleanup_orphaned_anonymous_users()
returns void
language sql
security definer
set search_path = public
as $$
delete from auth.users u
where u.is_anonymous is true
and u.created_at < now() - interval '2 days'
and not exists (
select 1 from campaign_players cp where cp.user_id = u.id
);
$$;

select cron.schedule(
'cleanup-orphaned-anonymous-users',
'0 18 * * *', -- 毎日 UTC18:00(JST 3:00)
$$ select cleanup_orphaned_anonymous_users(); $$
);

まとめ

  • Supabase Anonymous Sign-insは、既存のRLS/外部キー設計を変えずに「ログイン不要のゲスト参加」を実現できる
  • ただし「ゲストに開いていい範囲」と「本アカウント専用の範囲」はmiddlewareで明確に分離する必要がある
  • 匿名認証を入れたあとのセキュリティ確認は「RLS有効化」「ポリシーのスコープ」「SECURITY DEFINER関数内チェック」の3点を洗い出せば十分
  • 未参加のまま残る匿名ユーザーは、汎用的な「n日で一律削除」ではなく、実データを持つかどうかで対象を絞ったクリーンアップにするのが安全

実際に触ってみる

ここで書いた仕組みは、実際に動いているものをログイン不要で触れます。

記事内のコードとほぼ同じ挙動を、招待リンク経由の参加フローとして実際に体験できます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?