- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+72
-48
@@ -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<KakaoMe> {
|
||||
return (await res.json()) as KakaoMe;
|
||||
}
|
||||
|
||||
async function exchangeKakaoCode(code: string, redirectUri: string): Promise<string> {
|
||||
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) => {
|
||||
|
||||
@@ -35,6 +35,9 @@ export const authApi = {
|
||||
kakao: (accessToken: string) =>
|
||||
api<AuthResponse>('/auth/kakao', { method: 'POST', body: { accessToken }, skipAuth: true }),
|
||||
|
||||
kakaoCode: (code: string, redirectUri: string) =>
|
||||
api<AuthResponse>('/auth/kakao/code', { method: 'POST', body: { code, redirectUri }, skipAuth: true }),
|
||||
|
||||
me: () => api<User>('/auth/me'),
|
||||
};
|
||||
|
||||
|
||||
@@ -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<RootStackParamList>();
|
||||
|
||||
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();
|
||||
}, [hydrate]);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
hydrate();
|
||||
}, [hydrate, kakaoCodeLogin]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+15
-16
@@ -24,28 +24,27 @@ async function loadKakaoSDK() {
|
||||
if (!Kakao.isInitialized() && KAKAO_JS_KEY) Kakao.init(KAKAO_JS_KEY);
|
||||
}
|
||||
|
||||
export async function kakaoWebLogin(): Promise<string | null> {
|
||||
if (Platform.OS !== 'web') return null;
|
||||
/**
|
||||
* 웹 카카오 로그인 (v2.7 authorize 방식).
|
||||
* 리다이렉트가 발생하므로 즉시 반환값이 없고, 콜백 URL에서 code를 처리해야 함.
|
||||
* 콜백은 RootNavigator에서 `code`, `state=kakao_login` 쿼리스트링 감지.
|
||||
*/
|
||||
export async function kakaoWebLoginStart(): Promise<void> {
|
||||
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({
|
||||
Kakao.Auth.authorize({
|
||||
redirectUri: window.location.origin + '/',
|
||||
state: 'kakao_login',
|
||||
scope: 'profile_nickname,profile_image,account_email',
|
||||
success: (authObj: any) => resolve(authObj.access_token),
|
||||
fail: () => resolve(null),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function kakaoNativeLogin(): Promise<string | null> {
|
||||
// @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 + '/';
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ type State = {
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (body: Parameters<typeof authApi.register>[0]) => Promise<void>;
|
||||
kakaoLogin: (accessToken: string) => Promise<void>;
|
||||
kakaoCodeLogin: (code: string, redirectUri: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -71,6 +72,18 @@ export const useAuthStore = create<State>((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 });
|
||||
|
||||
Reference in New Issue
Block a user