/** * Cloudflare Workers — 분쟁 검토 도구 AI 프록시 v2 * - Google ID Token 검증 (@backpac.kr 도메인 확인) * - Gemini API 키를 Workers Secret에 보관 * - 상세 오류 로깅 추가 */ const GEMINI_MODEL = "gemini-2.0-flash-lite"; const CORS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", }; function json(data, status = 200) { return new Response(JSON.stringify(data), { status, headers: { ...CORS, "Content-Type": "application/json" }, }); } export default { async fetch(request, env) { // Preflight if (request.method === "OPTIONS") { return new Response(null, { status: 204, headers: CORS }); } const url = new URL(request.url); // 헬스체크 if (url.pathname === "/health") { return json({ ok: true, model: GEMINI_MODEL }); } // AI 방어논리 엔드포인트 if (url.pathname === "/api/defense" && request.method === "POST") { // 1. 환경 변수 확인 if (!env.GEMINI_API_KEY) { console.error("GEMINI_API_KEY 환경변수 없음"); return json({ error: "서버 설정 오류: GEMINI_API_KEY 미등록" }, 500); } if (!env.GOOGLE_CLIENT_ID) { console.error("GOOGLE_CLIENT_ID 환경변수 없음"); return json({ error: "서버 설정 오류: GOOGLE_CLIENT_ID 미등록" }, 500); } // 2. Authorization 헤더 확인 const authHeader = request.headers.get("Authorization") || ""; const idToken = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null; if (!idToken) { return json({ error: "인증 토큰이 없습니다. 로그인 후 다시 시도해 주세요." }, 401); } // 3. Google ID Token 검증 let email = ""; try { const verifyResult = await verifyGoogleToken(idToken, env.GOOGLE_CLIENT_ID); if (!verifyResult.ok) { console.error("토큰 검증 실패:", verifyResult.error); return json({ error: "인증 실패: " + verifyResult.error }, 403); } email = verifyResult.email; } catch (e) { console.error("토큰 검증 예외:", e.message); return json({ error: "인증 처리 중 오류: " + e.message }, 403); } // 4. 도메인 확인 const allowedDomain = env.ALLOWED_DOMAIN || "backpac.kr"; if (!email.endsWith("@" + allowedDomain)) { return json({ error: `@${allowedDomain} 계정만 접근 가능합니다. (현재: ${email})` }, 403); } // 5. 요청 본문 파싱 let body; try { body = await request.json(); } catch (e) { return json({ error: "요청 형식 오류: JSON 파싱 실패" }, 400); } const prompt = body?.prompt; if (!prompt || typeof prompt !== "string" || prompt.trim().length === 0) { return json({ error: "prompt가 비어있습니다." }, 400); } // format: "text" → 평문 응답 / "json"(기본값) → JSON 형식 강제 const isText = body?.format === "text"; // 6. Gemini API 호출 const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${env.GEMINI_API_KEY}`; let geminiRes; try { geminiRes = await fetch(geminiUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], generationConfig: { maxOutputTokens: isText ? 2048 : 1024, temperature: isText ? 0.4 : 0.3, ...(isText ? {} : { responseMimeType: "application/json" }), }, }), }); } catch (e) { console.error("Gemini fetch 예외:", e.message); return json({ error: "Gemini API 연결 실패: " + e.message }, 502); } if (!geminiRes.ok) { const errText = await geminiRes.text().catch(() => ""); console.error("Gemini API 오류:", geminiRes.status, errText); if (geminiRes.status === 429) return json({ error: "RATELIMIT" }, 429); if (geminiRes.status === 400) return json({ error: "BADKEY: " + errText }, 400); return json({ error: `Gemini API 오류 ${geminiRes.status}: ${errText}` }, 502); } let geminiData; try { geminiData = await geminiRes.json(); } catch (e) { console.error("Gemini 응답 파싱 실패:", e.message); return json({ error: "Gemini 응답 파싱 실패" }, 502); } const text = geminiData?.candidates?.[0]?.content?.parts?.[0]?.text ?? ""; if (!text) { console.error("Gemini 빈 응답:", JSON.stringify(geminiData)); return json({ error: "Gemini 빈 응답" }, 502); } return json({ text }); } return new Response("Not Found", { status: 404, headers: CORS }); }, }; /** * Google JWK 공개키 조회 (Cloudflare Cache API로 캐싱) */ async function fetchGoogleCerts() { const CERTS_URL = "https://www.googleapis.com/oauth2/v3/certs"; const cache = caches.default; const cached = await cache.match(new Request(CERTS_URL)); if (cached) return cached; const res = await fetch(CERTS_URL); if (res.ok) { await cache.put(new Request(CERTS_URL), res.clone()); } return res; } /** * Google ID Token 서명 검증 */ async function verifyGoogleToken(idToken, clientId) { try { // JWT 파싱 const parts = idToken.split("."); if (parts.length !== 3) return { ok: false, error: "잘못된 토큰 형식" }; const [headerB64, payloadB64, sigB64] = parts; // 헤더에서 kid 추출 let header; try { header = JSON.parse(b64decode(headerB64)); } catch { return { ok: false, error: "헤더 파싱 실패" }; } // Google 공개키 조회 (캐싱) const certsRes = await fetchGoogleCerts(); if (!certsRes.ok) return { ok: false, error: "Google 공개키 조회 실패" }; const certs = await certsRes.json(); const jwk = certs.keys?.find((k) => k.kid === header.kid); if (!jwk) return { ok: false, error: "공개키 kid 불일치" }; // 공개키 import let publicKey; try { publicKey = await crypto.subtle.importKey( "jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"] ); } catch (e) { return { ok: false, error: "공개키 import 실패: " + e.message }; } // 서명 검증 const sigBuf = Uint8Array.from(atob(sigB64.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0) ); const dataBuf = new TextEncoder().encode(headerB64 + "." + payloadB64); const valid = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", publicKey, sigBuf, dataBuf); if (!valid) return { ok: false, error: "서명 검증 실패" }; // 페이로드 파싱 let claims; try { claims = JSON.parse(b64decode(payloadB64)); } catch { return { ok: false, error: "페이로드 파싱 실패" }; } // iss 검증 if (!["accounts.google.com", "https://accounts.google.com"].includes(claims.iss)) { return { ok: false, error: "iss 불일치: " + claims.iss }; } // aud 검증 if (claims.aud !== clientId) { return { ok: false, error: "aud 불일치" }; } // 만료 검증 if (Date.now() / 1000 > claims.exp) { return { ok: false, error: "토큰 만료 — 새로고침 후 다시 로그인해 주세요." }; } return { ok: true, email: claims.email || "" }; } catch (e) { return { ok: false, error: "검증 중 예외: " + e.message }; } } function b64decode(str) { const s = str.replace(/-/g, "+").replace(/_/g, "/"); return decodeURIComponent( atob(s) .split("") .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) .join("") ); }