From 2c39d45b4873a2daf0fe328f422e520f7aaf4d76 Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Mon, 4 May 2026 18:07:00 +0900 Subject: [PATCH] fix(admin): block /admin/login redirect loop for non-admin users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INITIAL_SESSION 이벤트로 자동 /admin 이동 시 비-admin 세션 보유자가 proxy.ts → /admin/login 리다이렉트와 무한 루프에 빠지는 문제 수정. - /api/auth/session POST 응답에 isAdmin 추가 (서버에서 checkIsAdmin 호출) - /admin/login 페이지: SIGNED_IN 시 isAdmin 분기 — 비-admin 은 signOut + 세션 쿠키 삭제 후 에러 메시지 표시. INITIAL_SESSION 분기는 제거 (이미 proxy.ts 가 admin 인증된 사용자를 /admin 으로 바운스해 처리) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/web/app/admin/login/page.tsx | 37 ++++++++++++++++------ packages/web/app/api/auth/session/route.ts | 12 +++++-- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/packages/web/app/admin/login/page.tsx b/packages/web/app/admin/login/page.tsx index 28048bf5..c7c4dac3 100644 --- a/packages/web/app/admin/login/page.tsx +++ b/packages/web/app/admin/login/page.tsx @@ -9,21 +9,25 @@ export default function AdminLoginPage() { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); - // 세션이 있으면 /admin 으로 이동. - // - SIGNED_IN: OAuth / password 로그인 직후 - // - INITIAL_SESSION: 이미 localStorage 에 유효 세션이 있는 상태에서 로그인 - // 페이지에 다시 들어온 경우 (proxy.ts 가 쿠키 부재로 리다이렉트시킨 케이스 포함) + // SIGNED_IN 이벤트(OAuth / password 로그인 직후)에만 /admin 으로 이동한다. + // INITIAL_SESSION 은 처리하지 않는다 — 비-admin 세션 보유자가 /admin/login 에 + // 진입하면 INITIAL_SESSION → /admin → proxy.ts 가 /admin/login 으로 다시 + // 리다이렉트 → INITIAL_SESSION 재발화 의 무한 루프가 생긴다. + // 이미 admin 인증된 사용자가 /admin/login 에 들어오는 케이스는 proxy.ts 가 + // /admin 으로 바운스해 처리한다. + // 비-admin 이 password/OAuth 로그인을 시도해 SIGNED_IN 이 발화한 경우에도 + // /admin 으로 보내면 proxy.ts 가 /admin/login 으로 되돌려보내며 루프가 생기므로, + // 세션 sync 응답의 isAdmin 플래그로 분기해 비-admin 은 즉시 sign out 하고 + // 에러를 표시한다. useEffect(() => { const { data: { subscription }, } = supabaseBrowserClient.auth.onAuthStateChange((event, session) => { if ( - (event === "SIGNED_IN" || event === "INITIAL_SESSION") && + event === "SIGNED_IN" && session?.access_token && session?.refresh_token ) { - // Sync session to server-side cookies, then navigate via hard reload - // so proxy.ts sees the refreshed cookies. fetch("/api/auth/session", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -31,9 +35,22 @@ export default function AdminLoginPage() { access_token: session.access_token, refresh_token: session.refresh_token, }), - }).then(() => { - window.location.href = "/admin"; - }); + }) + .then((res) => res.json()) + .then(async (body: { ok?: boolean; isAdmin?: boolean }) => { + if (body?.isAdmin) { + window.location.href = "/admin"; + return; + } + await supabaseBrowserClient.auth.signOut(); + await fetch("/api/auth/session", { method: "DELETE" }); + setError("관리자 권한이 없는 계정입니다."); + setLoading(false); + }) + .catch(() => { + setError("로그인 처리 중 오류가 발생했습니다."); + setLoading(false); + }); } }); return () => subscription.unsubscribe(); diff --git a/packages/web/app/api/auth/session/route.ts b/packages/web/app/api/auth/session/route.ts index 832ab4ad..3f744137 100644 --- a/packages/web/app/api/auth/session/route.ts +++ b/packages/web/app/api/auth/session/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { checkIsAdmin } from "@/lib/supabase/admin"; /** * POST /api/auth/session @@ -7,6 +8,10 @@ import { createSupabaseServerClient } from "@/lib/supabase/server"; * Called by AuthProvider on SIGNED_IN / TOKEN_REFRESHED / INITIAL_SESSION so * that server route handlers and proxy.ts middleware see the same session as * the browser localStorage. + * + * Response includes `isAdmin` so the admin login page can decide whether to + * navigate to /admin or block a non-admin sign-in without bouncing through + * proxy.ts back to /admin/login. */ export async function POST(req: NextRequest) { const { access_token, refresh_token } = await req.json(); @@ -16,7 +21,7 @@ export async function POST(req: NextRequest) { } const supabase = await createSupabaseServerClient(); - const { error } = await supabase.auth.setSession({ + const { data, error } = await supabase.auth.setSession({ access_token, refresh_token, }); @@ -25,7 +30,10 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: error.message }, { status: 401 }); } - return NextResponse.json({ ok: true }); + const userId = data.user?.id; + const isAdmin = userId ? await checkIsAdmin(supabase, userId) : false; + + return NextResponse.json({ ok: true, isAdmin }); } /**