- 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 } }
|
valueFrom: { secretKeyRef: { name: api-secrets, key: codefClientId, optional: true } }
|
||||||
- name: CODEF_CLIENT_SECRET
|
- name: CODEF_CLIENT_SECRET
|
||||||
valueFrom: { secretKeyRef: { name: api-secrets, key: codefClientSecret, optional: true } }
|
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:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /health
|
path: /health
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ kubectl -n insurance create secret generic api-secrets \
|
|||||||
--from-literal=solapiSenderKey="${SOLAPI_SENDER_KEY:-}" \
|
--from-literal=solapiSenderKey="${SOLAPI_SENDER_KEY:-}" \
|
||||||
--from-literal=codefClientId="${CODEF_CLIENT_ID:-}" \
|
--from-literal=codefClientId="${CODEF_CLIENT_ID:-}" \
|
||||||
--from-literal=codefClientSecret="${CODEF_CLIENT_SECRET:-}" \
|
--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 -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
kubectl apply -f deploy/k8s/postgres.yaml
|
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 bcrypt from 'bcrypt';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -17,8 +17,10 @@ const LoginBody = z.object({
|
|||||||
password: z.string().min(1),
|
password: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
const KakaoBody = z.object({
|
const KakaoBody = z.object({ accessToken: z.string().min(10) });
|
||||||
accessToken: z.string().min(10),
|
const KakaoCodeBody = z.object({
|
||||||
|
code: z.string().min(5),
|
||||||
|
redirectUri: z.string().url(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type KakaoMe = {
|
type KakaoMe = {
|
||||||
@@ -40,6 +42,62 @@ async function fetchKakaoProfile(accessToken: string): Promise<KakaoMe> {
|
|||||||
return (await res.json()) as 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) {
|
export async function authRoutes(app: FastifyInstance) {
|
||||||
app.post('/register', async (req, reply) => {
|
app.post('/register', async (req, reply) => {
|
||||||
const body = RegisterBody.parse(req.body);
|
const body = RegisterBody.parse(req.body);
|
||||||
@@ -54,13 +112,7 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
name: body.name,
|
name: body.name,
|
||||||
phone: body.phone,
|
phone: body.phone,
|
||||||
provider: 'EMAIL',
|
provider: 'EMAIL',
|
||||||
profile: {
|
profile: { create: { age: body.age, gender: body.gender, job: body.job ?? '기타' } },
|
||||||
create: {
|
|
||||||
age: body.age,
|
|
||||||
gender: body.gender,
|
|
||||||
job: body.job ?? '기타',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
include: { profile: true },
|
include: { profile: true },
|
||||||
});
|
});
|
||||||
@@ -86,46 +138,18 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.post('/kakao', async (req, reply) => {
|
app.post('/kakao', async (req, reply) => {
|
||||||
const body = KakaoBody.parse(req.body);
|
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 {
|
try {
|
||||||
me = await fetchKakaoProfile(body.accessToken);
|
accessToken = await exchangeKakaoCode(body.code, body.redirectUri);
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
return reply.code(401).send({ message: '카카오 인증 실패' });
|
return reply.code(401).send({ message: `카카오 토큰 교환 실패: ${e?.message ?? ''}` });
|
||||||
}
|
}
|
||||||
|
return processKakaoLogin(app, reply, accessToken);
|
||||||
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) };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/me', { onRequest: [app.authenticate] }, async (req) => {
|
app.get('/me', { onRequest: [app.authenticate] }, async (req) => {
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ export const authApi = {
|
|||||||
kakao: (accessToken: string) =>
|
kakao: (accessToken: string) =>
|
||||||
api<AuthResponse>('/auth/kakao', { method: 'POST', body: { accessToken }, skipAuth: true }),
|
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'),
|
me: () => api<User>('/auth/me'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect } from 'react';
|
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 { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
import BottomTabs from './BottomTabs';
|
import BottomTabs from './BottomTabs';
|
||||||
import LoginScreen from '@/screens/auth/LoginScreen';
|
import LoginScreen from '@/screens/auth/LoginScreen';
|
||||||
@@ -44,11 +44,26 @@ export type RootStackParamList = {
|
|||||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
|
|
||||||
export default function RootNavigator() {
|
export default function RootNavigator() {
|
||||||
const { user, loading, hydrate } = useAuthStore();
|
const { user, loading, hydrate, kakaoCodeLogin } = useAuthStore();
|
||||||
|
|
||||||
useEffect(() => {
|
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();
|
||||||
}, [hydrate]);
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hydrate();
|
||||||
|
}, [hydrate, kakaoCodeLogin]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Button from '@/components/Button';
|
|||||||
import { colors } from '@/theme/colors';
|
import { colors } from '@/theme/colors';
|
||||||
import { radius, spacing, typography } from '@/theme/typography';
|
import { radius, spacing, typography } from '@/theme/typography';
|
||||||
import { useAuthStore } from '@/store/useAuthStore';
|
import { useAuthStore } from '@/store/useAuthStore';
|
||||||
import { kakaoWebLogin } from '@/services/kakao';
|
import { kakaoWebLoginStart } from '@/services/kakao';
|
||||||
import { socialApi } from '@/api/endpoints';
|
import { socialApi } from '@/api/endpoints';
|
||||||
import { saveToken } from '@/api/client';
|
import { saveToken } from '@/api/client';
|
||||||
|
|
||||||
@@ -38,16 +38,12 @@ export default function LoginScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onKakao = async () => {
|
const onKakao = async () => {
|
||||||
const token = await kakaoWebLogin();
|
if (Platform.OS === 'web') {
|
||||||
if (!token) {
|
// v2.7 authorize: 페이지 리다이렉트 후 콜백에서 code 처리
|
||||||
if (Platform.OS !== 'web') Alert.alert('카카오 로그인', '네이티브 SDK 연동은 빌드 후 동작합니다.');
|
await kakaoWebLoginStart();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
Alert.alert('카카오 로그인', '네이티브 SDK 연동은 모바일 빌드 후 동작합니다.');
|
||||||
await kakaoLogin(token);
|
|
||||||
} catch (e: any) {
|
|
||||||
Alert.alert('카카오 로그인 실패', e?.message ?? '');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onNaver = async () => {
|
const onNaver = async () => {
|
||||||
|
|||||||
+15
-16
@@ -24,28 +24,27 @@ async function loadKakaoSDK() {
|
|||||||
if (!Kakao.isInitialized() && KAKAO_JS_KEY) Kakao.init(KAKAO_JS_KEY);
|
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();
|
await loadKakaoSDK();
|
||||||
const Kakao = (window as any).Kakao;
|
const Kakao = (window as any).Kakao;
|
||||||
if (!Kakao || !Kakao.isInitialized?.()) {
|
if (!Kakao || !Kakao.isInitialized?.()) {
|
||||||
const manual = window.prompt(
|
window.alert('카카오 JS 키가 설정되지 않았습니다. EXPO_PUBLIC_KAKAO_JS_KEY 확인 필요.');
|
||||||
'카카오 JS 키가 설정되지 않았습니다 (EXPO_PUBLIC_KAKAO_JS_KEY).\n' +
|
return;
|
||||||
'개발 테스트용: 이미 발급된 Kakao access_token을 직접 붙여넣으세요.'
|
|
||||||
);
|
|
||||||
return manual;
|
|
||||||
}
|
}
|
||||||
return new Promise((resolve) => {
|
Kakao.Auth.authorize({
|
||||||
Kakao.Auth.login({
|
redirectUri: window.location.origin + '/',
|
||||||
|
state: 'kakao_login',
|
||||||
scope: 'profile_nickname,profile_image,account_email',
|
scope: 'profile_nickname,profile_image,account_email',
|
||||||
success: (authObj: any) => resolve(authObj.access_token),
|
|
||||||
fail: () => resolve(null),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function kakaoNativeLogin(): Promise<string | null> {
|
export function getKakaoRedirectUri(): string | null {
|
||||||
// @react-native-seoul/kakao-login 은 bare workflow + Android/iOS 네이티브 설정이 필요
|
if (Platform.OS !== 'web') return null;
|
||||||
// 현재 Expo managed 환경에서는 불가 — 배포 빌드 후 동작
|
return window.location.origin + '/';
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type State = {
|
|||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
register: (body: Parameters<typeof authApi.register>[0]) => Promise<void>;
|
register: (body: Parameters<typeof authApi.register>[0]) => Promise<void>;
|
||||||
kakaoLogin: (accessToken: string) => Promise<void>;
|
kakaoLogin: (accessToken: string) => Promise<void>;
|
||||||
|
kakaoCodeLogin: (code: string, redirectUri: string) => Promise<void>;
|
||||||
logout: () => 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 () => {
|
logout: async () => {
|
||||||
await clearToken();
|
await clearToken();
|
||||||
set({ user: null });
|
set({ user: null });
|
||||||
|
|||||||
Reference in New Issue
Block a user