diff --git a/.env b/.env index bcb32df..256d567 100644 --- a/.env +++ b/.env @@ -1,2 +1,7 @@ EXPO_PUBLIC_KAKAO_REST_API_KEY=fcc79e9199b5dbcaedfc00bb30b3d4af EXPO_PUBLIC_SERVER_BASE_URL=https://api.dailysnap.app +EXPO_PUBLIC_NAVER_CLIENT_ID=tCdEIPDTDBnfdVRdRgoO +EXPO_PUBLIC_NAVER_CLIENT_SECRET=IX3znje6Hl +EXPO_PUBLIC_NAVER_REDIRECT_URI=https://dailysnap.xyz/api/auth/naver +EXPO_PUBLIC_GOOGLE_CLIENT_ID=735338487068-kh9kfnrrus8fdmucfek7996ml15jiell.apps.googleusercontent.com.apps.googleusercontent.com +EXPO_PUBLIC_GOOGLE_REDIRECT_URI=https://dailysnap.xyz/auth/google/callback diff --git a/app/index.tsx b/app/index.tsx index 0e74ba1..86ad714 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -6,25 +6,26 @@ import { View, ActivityIndicator } from "react-native"; export default function App() { const router = useRouter(); - const { userInfo } = useAuth(); + // const { userInfo } = useAuth(); useEffect(() => { const timer = setTimeout(() => { - if (userInfo) { - // 로그인된 사용자면, 홈 네비게이션으로 이동 - router.replace("/(tabs)/home"); - } else { - // 로그인되지 않았으면 로그인 페이지로 이동 - router.replace("/login"); - } + router.replace("/(tabs)/home"); + // if (userInfo) { + // // 로그인된 사용자면, 홈 네비게이션으로 이동 + // router.replace("/(tabs)/home"); + // } else { + // // 로그인되지 않았으면 로그인 페이지로 이동 + // router.replace("/login"); + // } }, 100); return () => clearTimeout(timer); - }, [userInfo, router]); + }, [router]); // 로딩 화면 표시. TODO: 추후에 디자인 된 로딩스피너로 수정 return ( - + ); diff --git a/features/auth/api/googleAuth.ts b/features/auth/api/googleAuth.ts new file mode 100644 index 0000000..4d71236 --- /dev/null +++ b/features/auth/api/googleAuth.ts @@ -0,0 +1,92 @@ +import * as WebBrowser from "expo-web-browser"; +import axios from "axios"; + +const GOOGLE_CLIENT_ID = process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID ?? ""; +const GOOGLE_REDIRECT_URI = process.env.EXPO_PUBLIC_GOOGLE_REDIRECT_URI ?? ""; +const SERVER_BASE_URL = process.env.EXPO_PUBLIC_SERVER_BASE_URL ?? ""; + +// 브라우저 로그인 결과 처리를 위한 타입 +export interface GoogleAuthResult { + type: "success" | "error" | "cancel"; + params?: { + code?: string; + error?: string; + }; + error?: Error; +} + +// 구글 로그인 인증 브라우저를 열고 인증 코드를 받아오는 함수 +export async function signInWithGoogle(): Promise { + try { + // OAuth URL + const authUrl = + `https://accounts.google.com/o/oauth2/v2/auth?` + + `client_id=${GOOGLE_CLIENT_ID}` + + `&redirect_uri=${encodeURIComponent(GOOGLE_REDIRECT_URI)}` + + `&response_type=code` + + `&scope=${encodeURIComponent("profile email")}` + + `&prompt=select_account`; + + // 기본 브라우저로 구글 로그인 페이지 열기. 구글로그인은 Webview보다 WebBroswer 권장. + const result = await WebBrowser.openAuthSessionAsync(authUrl, GOOGLE_REDIRECT_URI); + + // 브라우저 결과 처리 + if (result.type === "success") { + // URL에서 code 파라미터 추출 + const url = new URL(result.url); + const code = url.searchParams.get("code"); + + if (code) return { type: "success", params: { code } }; + + const error = url.searchParams.get("error"); + return { + type: "error", + params: { error: error || "인증 코드를 받아오지 못했습니다." }, + }; + } + + return { type: "cancel" }; + } catch (error) { + console.error("Google Auth Error:", error); + return { + type: "error", + error: error instanceof Error ? error : new Error("알 수 없는 오류가 발생했습니다."), + }; + } +} + +// 백엔드와 통신해 사용자 정보 받아오는 함수 +export async function exchangeGoogleCodeForUserInfo(code: string) { + try { + const response = await axios.post(`${SERVER_BASE_URL}/api/auth/google`, { code }); + return response.data; + } catch (error) { + console.error("Google Token Exchange Error:", error); + throw error; + } +} + +// 구글 로그인 전체 플로우 처리 +export async function handleGoogleSignIn() { + try { + const authResult = await signInWithGoogle(); + + if (authResult.type === "success" && authResult.params?.code) { + const userInfo = await exchangeGoogleCodeForUserInfo(authResult.params.code); + return { success: true, user: userInfo }; + } else if (authResult.type === "cancel") { + return { success: false, error: "로그인이 취소되었습니다." }; + } else { + return { + success: false, + error: authResult.params?.error || "로그인 중 오류가 발생했습니다.", + }; + } + } catch (error) { + console.error("Google Sign In Error:", error); + return { + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", + }; + } +} diff --git a/features/auth/ui/GoogleLoginButton.tsx b/features/auth/ui/GoogleLoginButton.tsx index b1b0ede..e610c0d 100644 --- a/features/auth/ui/GoogleLoginButton.tsx +++ b/features/auth/ui/GoogleLoginButton.tsx @@ -1,11 +1,44 @@ -import React from "react"; -import { TouchableOpacity, Text } from "react-native"; +import React, { useState } from "react"; +import { TouchableOpacity, Text, Alert, ActivityIndicator } from "react-native"; import type { SocialLoginProps } from "../model/types"; +import { handleGoogleSignIn } from "../api/googleAuth"; export const GoogleLoginButton: React.FC = ({ onLoginSuccess, onLoginError }) => { + const [isLoading, setIsLoading] = useState(false); + + const handleLogin = async () => { + try { + setIsLoading(true); + + const result = await handleGoogleSignIn(); + + if (result.success && result.user) { + onLoginSuccess(result.user); + } else { + onLoginError(result.error || "구글 로그인 중 오류가 발생했습니다."); + Alert.alert("로그인 실패", result.error || "구글 로그인 중 오류가 발생했습니다."); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다."; + onLoginError(errorMessage); + Alert.alert("로그인 실패", errorMessage); + } finally { + setIsLoading(false); + } + }; + return ( - - 구글로 시작하기 + + {isLoading ? ( + + ) : ( + 구글로 시작하기 + )} ); }; diff --git a/features/auth/ui/KakaoLoginButton.tsx b/features/auth/ui/KakaoLoginButton.tsx index 20297ee..fd91cfc 100644 --- a/features/auth/ui/KakaoLoginButton.tsx +++ b/features/auth/ui/KakaoLoginButton.tsx @@ -4,26 +4,26 @@ import { KakaoLoginWebView } from "./KakaoLoginWebView"; import type { SocialLoginProps } from "../model/types"; export const KakaoLoginButton: React.FC = ({ onLoginSuccess, onLoginError }) => { - const [showWebView, setShowWebView] = useState(false); + const [isShowWebView, setIsShowWebView] = useState(false); const handleKakaoLogin = () => { - setShowWebView(true); + setIsShowWebView(true); }; const handleLoginSuccess = (userInfo: any) => { console.log("카카오 로그인 성공:", userInfo); - setShowWebView(false); + setIsShowWebView(false); onLoginSuccess(userInfo); }; const handleLoginError = (error: any) => { console.error("카카오 로그인 실패:", error); - setShowWebView(false); + setIsShowWebView(false); onLoginError(typeof error === "string" ? error : "카카오 로그인 중 오류가 발생했습니다."); }; const handleClose = () => { - setShowWebView(false); + setIsShowWebView(false); }; return ( @@ -35,7 +35,7 @@ export const KakaoLoginButton: React.FC = ({ onLoginSuccess, o 카카오로 시작하기 - + = ({ onLoginSuccess, onLoginError }) => { + const [showWebView, setShowWebView] = useState(false); + + const handleNaverLogin = () => { + setShowWebView(true); + }; + + const handleLoginSuccess = (userInfo: any) => { + console.log("네이버 로그인 성공:", userInfo); + setShowWebView(false); + onLoginSuccess(userInfo); + }; + + const handleLoginError = (error: any) => { + console.error("네이버 로그인 실패:", error); + setShowWebView(false); + onLoginError(typeof error === "string" ? error : "네이버 로그인 중 오류가 발생했습니다."); + }; + + const handleClose = () => { + setShowWebView(false); + }; + return ( - - 네이버로 시작하기 - + <> + + 네이버로 시작하기 + + + + + + ); }; diff --git a/features/auth/ui/NaverLoginWebView.tsx b/features/auth/ui/NaverLoginWebView.tsx new file mode 100644 index 0000000..ca347c4 --- /dev/null +++ b/features/auth/ui/NaverLoginWebView.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import { ActivityIndicator, Alert, Platform, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import type { WebViewNavigation } from "react-native-webview"; +import WebView from "react-native-webview"; +import axios from "axios"; + +// 환경변수에서 env파일에서 가져옴 +const NAVER_CLIENT_ID = process.env.EXPO_PUBLIC_NAVER_CLIENT_ID; +const NAVER_CLIENT_SECRET = process.env.EXPO_PUBLIC_NAVER_CLIENT_SECRET; +const NAVER_REDIRECT_URI = process.env.EXPO_PUBLIC_NAVER_REDIRECT_URI; + +interface NaverLoginWebViewProps { + onLoginSuccess: (userInfo: any) => void; + onLoginError: (error: any) => void; + onClose: () => void; +} + +export function NaverLoginWebView({ + onLoginSuccess, + onLoginError, + onClose, +}: NaverLoginWebViewProps) { + const [isLoading, setIsLoading] = useState(false); + const [isChangeNavigate, setIsChangeNavigate] = useState(true); + + // CSRF 방지를 위한 state 값 생성 (랜덤 문자열) + const STATE = Math.random().toString(36).substring(2, 15); + + // 인증 코드를 받아서 액세스 토큰으로 교환하고 사용자 정보를 가져옵니다. + const requestToken = async (code: string, state: string) => { + try { + setIsLoading(true); + + // 1: 인증 코드를 액세스 토큰으로 교환 + const tokenResponse = await axios({ + method: "POST", + url: "https://nid.naver.com/oauth2.0/token", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + data: new URLSearchParams({ + grant_type: "authorization_code", + client_id: NAVER_CLIENT_ID!, + client_secret: NAVER_CLIENT_SECRET!, + code, + state, + }), + }); + + const accessToken = tokenResponse.data.access_token; + + // 2: 액세스 토큰으로 네이버 사용자 정보 조회 + const userResponse = await axios({ + method: "GET", + url: "https://openapi.naver.com/v1/nid/me", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + const { response } = userResponse.data; + + const userInfo = { + id: response.id, + email: response.email, + name: response.name, + nickname: response.nickname, + profileImage: response.profile_image, + accessToken, + }; + + onLoginSuccess(userInfo); + } catch (error: any) { + console.error("네이버 로그인 에러:", error); + onLoginError(error); + Alert.alert("로그인 실패", "네이버 로그인 중 오류가 발생했습니다.", [ + { text: "확인", onPress: onClose }, + ]); + } finally { + setIsLoading(false); + } + }; + + // WebView URL 변경 처리 + const handleNavigationChangeState = (event: WebViewNavigation) => { + console.log("▶️ URL 변화", event.url); + // 네이버에서 리다이렉트될 때, redirect_uri?code=...&state=... 형태 + if (event.url.startsWith(`${NAVER_REDIRECT_URI}?`) && event.url.includes("code=")) { + const urlParams = new URLSearchParams(event.url.split("?")[1]); + const code = urlParams.get("code"); + const state = urlParams.get("state") ?? STATE; + if (code) { + requestToken(code, state); + return; + } + } + setIsChangeNavigate(event.loading); + }; + + return ( + + {(isLoading || isChangeNavigate) && ( + + + + )} + + + ); +} diff --git a/package-lock.json b/package-lock.json index 4b75ba9..4b50d0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,37 +9,38 @@ "version": "1.0.0", "dependencies": { "@expo/config-plugins": "~9.0.0", - "@react-native-community/netinfo": "^11.4.1", - "@tanstack/react-query": "^5.76.1", - "axios": "^1.10.0", + "@react-native-community/netinfo": "11.4.1", + "@tanstack/react-query": "5.76.1", + "axios": "1.10.0", "expo": "~52.0.47", "expo-constants": "~17.0.8", "expo-linking": "~7.0.5", "expo-router": "4.0.21", "expo-status-bar": "~2.0.1", - "nativewind": "^2.0.11", + "expo-web-browser": "~14.0.2", + "nativewind": "2.0.11", "react": "18.3.1", "react-native": "0.76.9", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", "react-native-svg": "15.8.0", "react-native-webview": "13.12.5", - "tailwindcss": "^3.3.0", - "zod": "^3.25.7" + "tailwindcss": "3.3.0", + "zod": "3.25.7" }, "devDependencies": { - "@babel/core": "^7.25.2", - "@commitlint/cli": "^19.8.1", - "@commitlint/config-conventional": "^19.8.1", + "@babel/core": "7.25.2", + "@commitlint/cli": "19.8.1", + "@commitlint/config-conventional": "19.8.1", "@types/react": "~18.3.12", - "babel-plugin-module-resolver": "^5.0.2", - "eslint": "^8.57.0", + "babel-plugin-module-resolver": "5.0.2", + "eslint": "8.57.0", "eslint-config-expo": "~8.0.1", - "eslint-config-prettier": "^10.1.1", - "eslint-plugin-prettier": "^5.2.5", - "husky": "^9.1.7", - "prettier": "^3.6.2", - "typescript": "^5.3.3" + "eslint-config-prettier": "10.1.1", + "eslint-plugin-prettier": "5.2.5", + "husky": "9.1.7", + "prettier": "3.6.2", + "typescript": "5.3.3" }, "engines": { "node": ">=18.0.0" @@ -90,19 +91,21 @@ } }, "node_modules/@babel/core": { - "version": "7.26.10", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz", + "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.10", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.10", - "@babel/parser": "^7.26.10", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.10", - "@babel/types": "^7.26.10", + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.25.0", + "@babel/helper-compilation-targets": "^7.25.2", + "@babel/helper-module-transforms": "^7.25.2", + "@babel/helpers": "^7.25.0", + "@babel/parser": "^7.25.0", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.2", + "@babel/types": "^7.25.2", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -2535,7 +2538,9 @@ "license": "MIT" }, "node_modules/@eslint/js": { - "version": "8.57.1", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", "dev": true, "license": "MIT", "engines": { @@ -3051,11 +3056,14 @@ } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "deprecated": "Use @eslint/config-array instead", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", + "@humanwhocodes/object-schema": "^2.0.2", "debug": "^4.3.1", "minimatch": "^3.0.5" }, @@ -3077,6 +3085,9 @@ }, "node_modules/@humanwhocodes/object-schema": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", "dev": true, "license": "BSD-3-Clause" }, @@ -6360,15 +6371,18 @@ } }, "node_modules/eslint": { - "version": "8.57.1", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", @@ -7065,6 +7079,16 @@ "react-native": "*" } }, + "node_modules/expo-web-browser": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-14.0.2.tgz", + "integrity": "sha512-Hncv2yojhTpHbP6SGWARBFdl7P6wBHc1O8IKaNsH0a/IEakq887o1eRhLxZ5IwztPQyRDhpqHdgJ+BjWolOnwA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/exponential-backoff": { "version": "3.1.2", "license": "Apache-2.0" @@ -12757,7 +12781,9 @@ } }, "node_modules/typescript": { - "version": "5.8.2", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index d1b106d..b6b2f88 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "react-native-svg": "15.8.0", "react-native-webview": "13.12.5", "tailwindcss": "3.3.0", - "zod": "3.25.7" + "zod": "3.25.7", + "expo-web-browser": "~14.0.2" }, "devDependencies": { "@babel/core": "7.25.2",