From c53cc69d97ef18509b751ffaaac9120496e2b457 Mon Sep 17 00:00:00 2001 From: chpark Date: Thu, 23 Apr 2026 01:27:49 +0900 Subject: [PATCH] =?UTF-8?q?fix(kakao):=20Kakao=20SDK=20v2=20authorize=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98=20+?= =?UTF-8?q?=20code=20=EA=B5=90=ED=99=98=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Kakao SDK v2.7에서 Auth.login() 제거 → authorize() 리다이렉트 플로우 사용 - services/kakao.ts: kakaoWebLoginStart() redirect 시작 - RootNavigator: 웹 URL의 code + state=kakao_login 감지 → kakaoCodeLogin 호출 - useAuthStore.kakaoCodeLogin 추가 (authApi.kakaoCode 호출) - 백엔드 /auth/kakao/code: REST API 키로 code→access_token 교환 후 기존 processKakaoLogin 재사용 - api-secrets에 kakaoRestApiKey/kakaoClientSecret 슬롯 추가 - 환경변수: KAKAO_REST_API_KEY 서버에 저장 완료 Co-Authored-By: Claude Opus 4.7 (1M context) --- deploy/k8s/api.yaml | 4 ++ scripts/deploy-remote.sh | 2 + server/src/routes/auth.ts | 120 ++++++++++++++++++------------- src/api/endpoints.ts | 3 + src/navigation/RootNavigator.tsx | 21 +++++- src/screens/auth/LoginScreen.tsx | 14 ++-- src/services/kakao.ts | 33 +++++---- src/store/useAuthStore.ts | 13 ++++ 8 files changed, 133 insertions(+), 77 deletions(-) diff --git a/deploy/k8s/api.yaml b/deploy/k8s/api.yaml index 828d3a7..874e54d 100644 --- a/deploy/k8s/api.yaml +++ b/deploy/k8s/api.yaml @@ -78,6 +78,10 @@ spec: valueFrom: { secretKeyRef: { name: api-secrets, key: codefClientId, optional: true } } - name: CODEF_CLIENT_SECRET valueFrom: { secretKeyRef: { name: api-secrets, key: codefClientSecret, optional: true } } + - name: KAKAO_REST_API_KEY + valueFrom: { secretKeyRef: { name: api-secrets, key: kakaoRestApiKey, optional: true } } + - name: KAKAO_CLIENT_SECRET + valueFrom: { secretKeyRef: { name: api-secrets, key: kakaoClientSecret, optional: true } } readinessProbe: httpGet: path: /health diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index 18e3a0d..8e13686 100644 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -76,6 +76,8 @@ kubectl -n insurance create secret generic api-secrets \ --from-literal=solapiSenderKey="${SOLAPI_SENDER_KEY:-}" \ --from-literal=codefClientId="${CODEF_CLIENT_ID:-}" \ --from-literal=codefClientSecret="${CODEF_CLIENT_SECRET:-}" \ + --from-literal=kakaoRestApiKey="${KAKAO_REST_API_KEY:-}" \ + --from-literal=kakaoClientSecret="${KAKAO_CLIENT_SECRET:-}" \ --dry-run=client -o yaml | kubectl apply -f - kubectl apply -f deploy/k8s/postgres.yaml diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index b3d26fa..b85d726 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -1,4 +1,4 @@ -import type { FastifyInstance } from 'fastify'; +import type { FastifyInstance, FastifyReply } from 'fastify'; import bcrypt from 'bcrypt'; import { z } from 'zod'; @@ -17,8 +17,10 @@ const LoginBody = z.object({ password: z.string().min(1), }); -const KakaoBody = z.object({ - accessToken: z.string().min(10), +const KakaoBody = z.object({ accessToken: z.string().min(10) }); +const KakaoCodeBody = z.object({ + code: z.string().min(5), + redirectUri: z.string().url(), }); type KakaoMe = { @@ -40,6 +42,62 @@ async function fetchKakaoProfile(accessToken: string): Promise { return (await res.json()) as KakaoMe; } +async function exchangeKakaoCode(code: string, redirectUri: string): Promise { + const restKey = process.env.KAKAO_REST_API_KEY; + if (!restKey) throw new Error('KAKAO_REST_API_KEY not configured'); + const form = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: restKey, + redirect_uri: redirectUri, + code, + }); + if (process.env.KAKAO_CLIENT_SECRET) { + form.set('client_secret', process.env.KAKAO_CLIENT_SECRET); + } + const res = await fetch('https://kauth.kakao.com/oauth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8' }, + body: form.toString(), + }); + if (!res.ok) throw new Error(`Kakao token exchange ${res.status}: ${await res.text()}`); + const data = (await res.json()) as any; + return data.access_token as string; +} + +async function processKakaoLogin(app: FastifyInstance, reply: FastifyReply, accessToken: string) { + let me: KakaoMe; + try { + me = await fetchKakaoProfile(accessToken); + } catch { + return reply.code(401).send({ message: '카카오 인증 실패' }); + } + const kakaoId = String(me.id); + const email = me.kakao_account?.email; + const name = me.kakao_account?.profile?.nickname ?? me.properties?.nickname ?? '카카오사용자'; + const profileImage = me.kakao_account?.profile?.profile_image_url ?? me.properties?.profile_image; + + let user = await app.prisma.user.findUnique({ + where: { kakaoId }, + include: { profile: true }, + }); + if (!user) { + user = await app.prisma.user.create({ + data: { + kakaoId, + email: email ?? null, + name, + phone: me.kakao_account?.phone_number, + provider: 'KAKAO', + profileImage, + profile: { create: { age: 30, gender: 'MALE', job: '기타' } }, + }, + include: { profile: true }, + }); + } + const token = await reply.jwtSign({ sub: user.id, email: user.email ?? undefined }); + return { token, user: publicUser(user) }; +} + export async function authRoutes(app: FastifyInstance) { app.post('/register', async (req, reply) => { const body = RegisterBody.parse(req.body); @@ -54,13 +112,7 @@ export async function authRoutes(app: FastifyInstance) { name: body.name, phone: body.phone, provider: 'EMAIL', - profile: { - create: { - age: body.age, - gender: body.gender, - job: body.job ?? '기타', - }, - }, + profile: { create: { age: body.age, gender: body.gender, job: body.job ?? '기타' } }, }, include: { profile: true }, }); @@ -86,46 +138,18 @@ export async function authRoutes(app: FastifyInstance) { app.post('/kakao', async (req, reply) => { const body = KakaoBody.parse(req.body); - let me: KakaoMe; + return processKakaoLogin(app, reply, body.accessToken); + }); + + app.post('/kakao/code', async (req, reply) => { + const body = KakaoCodeBody.parse(req.body); + let accessToken: string; try { - me = await fetchKakaoProfile(body.accessToken); - } catch (e) { - return reply.code(401).send({ message: '카카오 인증 실패' }); + accessToken = await exchangeKakaoCode(body.code, body.redirectUri); + } catch (e: any) { + return reply.code(401).send({ message: `카카오 토큰 교환 실패: ${e?.message ?? ''}` }); } - - const kakaoId = String(me.id); - const email = me.kakao_account?.email; - const name = me.kakao_account?.profile?.nickname ?? me.properties?.nickname ?? '카카오사용자'; - const profileImage = me.kakao_account?.profile?.profile_image_url ?? me.properties?.profile_image; - - let user = await app.prisma.user.findUnique({ - where: { kakaoId }, - include: { profile: true }, - }); - - if (!user) { - user = await app.prisma.user.create({ - data: { - kakaoId, - email: email ?? null, - name, - phone: me.kakao_account?.phone_number, - provider: 'KAKAO', - profileImage, - profile: { - create: { - age: 30, - gender: 'MALE', - job: '기타', - }, - }, - }, - include: { profile: true }, - }); - } - - const token = await reply.jwtSign({ sub: user.id, email: user.email ?? undefined }); - return { token, user: publicUser(user) }; + return processKakaoLogin(app, reply, accessToken); }); app.get('/me', { onRequest: [app.authenticate] }, async (req) => { diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts index 67c1a56..004fe51 100644 --- a/src/api/endpoints.ts +++ b/src/api/endpoints.ts @@ -35,6 +35,9 @@ export const authApi = { kakao: (accessToken: string) => api('/auth/kakao', { method: 'POST', body: { accessToken }, skipAuth: true }), + kakaoCode: (code: string, redirectUri: string) => + api('/auth/kakao/code', { method: 'POST', body: { code, redirectUri }, skipAuth: true }), + me: () => api('/auth/me'), }; diff --git a/src/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx index b512042..0ed36a0 100644 --- a/src/navigation/RootNavigator.tsx +++ b/src/navigation/RootNavigator.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { View, ActivityIndicator } from 'react-native'; +import { View, ActivityIndicator, Platform } from 'react-native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import BottomTabs from './BottomTabs'; import LoginScreen from '@/screens/auth/LoginScreen'; @@ -44,11 +44,26 @@ export type RootStackParamList = { const Stack = createNativeStackNavigator(); export default function RootNavigator() { - const { user, loading, hydrate } = useAuthStore(); + const { user, loading, hydrate, kakaoCodeLogin } = useAuthStore(); useEffect(() => { + // 카카오 OAuth redirect callback 처리 (웹 전용) + if (Platform.OS === 'web' && typeof window !== 'undefined') { + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + const state = params.get('state'); + if (code && state === 'kakao_login') { + const redirectUri = window.location.origin + '/'; + // code 사용 후 URL 정리 + window.history.replaceState({}, '', window.location.pathname); + kakaoCodeLogin(code, redirectUri).catch(() => { + hydrate(); + }); + return; + } + } hydrate(); - }, [hydrate]); + }, [hydrate, kakaoCodeLogin]); if (loading) { return ( diff --git a/src/screens/auth/LoginScreen.tsx b/src/screens/auth/LoginScreen.tsx index 77f8e25..b7d391c 100644 --- a/src/screens/auth/LoginScreen.tsx +++ b/src/screens/auth/LoginScreen.tsx @@ -8,7 +8,7 @@ import Button from '@/components/Button'; import { colors } from '@/theme/colors'; import { radius, spacing, typography } from '@/theme/typography'; import { useAuthStore } from '@/store/useAuthStore'; -import { kakaoWebLogin } from '@/services/kakao'; +import { kakaoWebLoginStart } from '@/services/kakao'; import { socialApi } from '@/api/endpoints'; import { saveToken } from '@/api/client'; @@ -38,16 +38,12 @@ export default function LoginScreen() { }; const onKakao = async () => { - const token = await kakaoWebLogin(); - if (!token) { - if (Platform.OS !== 'web') Alert.alert('카카오 로그인', '네이티브 SDK 연동은 빌드 후 동작합니다.'); + if (Platform.OS === 'web') { + // v2.7 authorize: 페이지 리다이렉트 후 콜백에서 code 처리 + await kakaoWebLoginStart(); return; } - try { - await kakaoLogin(token); - } catch (e: any) { - Alert.alert('카카오 로그인 실패', e?.message ?? ''); - } + Alert.alert('카카오 로그인', '네이티브 SDK 연동은 모바일 빌드 후 동작합니다.'); }; const onNaver = async () => { diff --git a/src/services/kakao.ts b/src/services/kakao.ts index 68ac79d..728c10e 100644 --- a/src/services/kakao.ts +++ b/src/services/kakao.ts @@ -24,28 +24,27 @@ async function loadKakaoSDK() { if (!Kakao.isInitialized() && KAKAO_JS_KEY) Kakao.init(KAKAO_JS_KEY); } -export async function kakaoWebLogin(): Promise { - if (Platform.OS !== 'web') return null; +/** + * 웹 카카오 로그인 (v2.7 authorize 방식). + * 리다이렉트가 발생하므로 즉시 반환값이 없고, 콜백 URL에서 code를 처리해야 함. + * 콜백은 RootNavigator에서 `code`, `state=kakao_login` 쿼리스트링 감지. + */ +export async function kakaoWebLoginStart(): Promise { + if (Platform.OS !== 'web') return; await loadKakaoSDK(); const Kakao = (window as any).Kakao; if (!Kakao || !Kakao.isInitialized?.()) { - const manual = window.prompt( - '카카오 JS 키가 설정되지 않았습니다 (EXPO_PUBLIC_KAKAO_JS_KEY).\n' + - '개발 테스트용: 이미 발급된 Kakao access_token을 직접 붙여넣으세요.' - ); - return manual; + window.alert('카카오 JS 키가 설정되지 않았습니다. EXPO_PUBLIC_KAKAO_JS_KEY 확인 필요.'); + return; } - return new Promise((resolve) => { - Kakao.Auth.login({ - scope: 'profile_nickname,profile_image,account_email', - success: (authObj: any) => resolve(authObj.access_token), - fail: () => resolve(null), - }); + Kakao.Auth.authorize({ + redirectUri: window.location.origin + '/', + state: 'kakao_login', + scope: 'profile_nickname,profile_image,account_email', }); } -export async function kakaoNativeLogin(): Promise { - // @react-native-seoul/kakao-login 은 bare workflow + Android/iOS 네이티브 설정이 필요 - // 현재 Expo managed 환경에서는 불가 — 배포 빌드 후 동작 - return null; +export function getKakaoRedirectUri(): string | null { + if (Platform.OS !== 'web') return null; + return window.location.origin + '/'; } diff --git a/src/store/useAuthStore.ts b/src/store/useAuthStore.ts index 112ccbc..2e7b819 100644 --- a/src/store/useAuthStore.ts +++ b/src/store/useAuthStore.ts @@ -10,6 +10,7 @@ type State = { login: (email: string, password: string) => Promise; register: (body: Parameters[0]) => Promise; kakaoLogin: (accessToken: string) => Promise; + kakaoCodeLogin: (code: string, redirectUri: string) => Promise; logout: () => Promise; }; @@ -71,6 +72,18 @@ export const useAuthStore = create((set) => ({ } }, + kakaoCodeLogin: async (code, redirectUri) => { + set({ loading: true, error: null }); + try { + const res = await authApi.kakaoCode(code, redirectUri); + await saveToken(res.token); + set({ user: res.user, loading: false }); + } catch (e: any) { + set({ loading: false, error: e?.message ?? '카카오 로그인 실패' }); + throw e; + } + }, + logout: async () => { await clearToken(); set({ user: null });