From b2d06c048317c3cd38530bd06009bc8626051b73 Mon Sep 17 00:00:00 2001 From: 0xC0FFE2 Date: Tue, 21 Jan 2025 18:48:14 +0900 Subject: [PATCH] =?UTF-8?q?feat=20:=20recaptcha=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 83 +++++++++++- package.json | 4 + src/App.tsx | 18 ++- src/components/MobileProtectedRoute.tsx | 19 +++ src/components/ProtectedRoute.tsx | 19 +++ src/components/auth/LoginForm.tsx | 122 +++++++++++++++--- src/components/auth/PinAuthModal.tsx | 118 +++++++++++++++++ src/services/AuthService.ts | 94 ++++++++++---- src/services/ServiceEndPoint.ts | 1 + src/services/dto/request/AuthType.ts | 4 + src/services/dto/request/LoginRequest.ts | 16 +++ .../dto/request/LoginRequestViaApp.ts | 8 ++ .../dto/request/LoginRequestViaPin.ts | 10 ++ src/services/dto/request/RequestType.ts | 4 + src/services/dto/response/LoginResponse.ts | 4 + 15 files changed, 485 insertions(+), 39 deletions(-) create mode 100644 src/components/MobileProtectedRoute.tsx create mode 100644 src/components/ProtectedRoute.tsx create mode 100644 src/components/auth/PinAuthModal.tsx create mode 100644 src/services/ServiceEndPoint.ts create mode 100644 src/services/dto/request/AuthType.ts create mode 100644 src/services/dto/request/LoginRequest.ts create mode 100644 src/services/dto/request/LoginRequestViaApp.ts create mode 100644 src/services/dto/request/LoginRequestViaPin.ts create mode 100644 src/services/dto/request/RequestType.ts create mode 100644 src/services/dto/response/LoginResponse.ts diff --git a/package-lock.json b/package-lock.json index d8a1a63..5c374b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,16 +9,20 @@ "version": "0.0.0", "dependencies": { "framer-motion": "^11.18.1", + "js-cookie": "^3.0.5", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-google-recaptcha": "^3.1.0", "react-icons": "^5.4.0", "react-router-dom": "^7.1.2", "react-toastify": "^11.0.3" }, "devDependencies": { "@eslint/js": "^9.17.0", + "@types/js-cookie": "^3.0.6", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", + "@types/react-google-recaptcha": "^2.1.9", "@vitejs/plugin-react-swc": "^3.5.0", "autoprefixer": "^10.4.20", "eslint": "^9.17.0", @@ -1316,6 +1320,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1351,6 +1362,16 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-google-recaptcha": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.9.tgz", + "integrity": "sha512-nT31LrBDuoSZJN4QuwtQSF3O89FVHC4jLhM+NtKEmVF5R1e8OY0Jo4//x2Yapn2aNHguwgX5doAq8Zo+Ehd0ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.20.0.tgz", @@ -2741,6 +2762,15 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2883,6 +2913,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3160,7 +3199,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3610,6 +3648,17 @@ "node": ">= 0.8" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3653,6 +3702,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-async-script": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz", + "integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.0", + "prop-types": "^15.5.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -3666,6 +3728,19 @@ "react": "^18.3.1" } }, + "node_modules/react-google-recaptcha": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz", + "integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.5.0", + "react-async-script": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, "node_modules/react-icons": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz", @@ -3675,6 +3750,12 @@ "react": "*" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-router": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.2.tgz", diff --git a/package.json b/package.json index 42bbf46..648b259 100644 --- a/package.json +++ b/package.json @@ -11,16 +11,20 @@ }, "dependencies": { "framer-motion": "^11.18.1", + "js-cookie": "^3.0.5", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-google-recaptcha": "^3.1.0", "react-icons": "^5.4.0", "react-router-dom": "^7.1.2", "react-toastify": "^11.0.3" }, "devDependencies": { "@eslint/js": "^9.17.0", + "@types/js-cookie": "^3.0.6", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", + "@types/react-google-recaptcha": "^2.1.9", "@vitejs/plugin-react-swc": "^3.5.0", "autoprefixer": "^10.4.20", "eslint": "^9.17.0", diff --git a/src/App.tsx b/src/App.tsx index 92503ba..8708e8c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,8 @@ import "react-toastify/dist/ReactToastify.css"; import { ToastContainer } from "react-toastify"; import MobileLoginPage from "./pages/mobile/MobileLoginPage"; import MobileRegisterPage from "./pages/mobile/MobileRegisterPage"; +import ProtectedRoute from "./components/ProtectedRoute"; +import MobileProtectedRoute from "./components/MobileProtectedRoute"; const App = () => { return ( @@ -18,11 +20,25 @@ const App = () => { rtl={false} /> + + + + } + /> } /> } /> + + + + + } + /> } /> } /> - } /> + + ); diff --git a/src/components/MobileProtectedRoute.tsx b/src/components/MobileProtectedRoute.tsx new file mode 100644 index 0000000..a718638 --- /dev/null +++ b/src/components/MobileProtectedRoute.tsx @@ -0,0 +1,19 @@ +import React, { ReactNode } from 'react'; +import { Navigate } from 'react-router-dom'; +import Cookies from 'js-cookie'; + +interface ProtectedProps { + children: ReactNode; +} + +const MobileProtectedRoute = ({ children }: ProtectedProps) => { + const userCookie = Cookies.get("SYS-REFRESH"); + + if (!userCookie) { + return ; + } + + return children; +}; + +export default MobileProtectedRoute; diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..b0e86f8 --- /dev/null +++ b/src/components/ProtectedRoute.tsx @@ -0,0 +1,19 @@ +import React, { ReactNode } from 'react'; +import { Navigate } from 'react-router-dom'; +import Cookies from 'js-cookie'; + +interface ProtectedProps { + children: ReactNode; +} + +const ProtectedRoute = ({ children }: ProtectedProps) => { + const userCookie = Cookies.get("SYS-REFRESH"); + + if (!userCookie) { + return ; + } + + return children; +}; + +export default ProtectedRoute; diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index 06010ce..ef13211 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -1,5 +1,11 @@ -import React from 'react'; -import { Link } from 'react-router-dom'; +import React, { useState } from "react"; +import { Link } from "react-router-dom"; +import { ToastContainer, toast } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; +import { AuthService } from "../../services/AuthService"; +import { RequestType } from "../../services/dto/request/RequestType"; +import { AuthType } from "../../services/dto/request/AuthType"; +import PinAuthModal from "./PinAuthModal"; interface LoginFormProps { formData: { @@ -8,15 +14,78 @@ interface LoginFormProps { rememberMe: boolean; }; onInputChange: (e: React.ChangeEvent) => void; - onLogin: (type: 'app' | 'pin') => void; } -const LoginForm: React.FC = ({ formData, onInputChange, onLogin }) => { +const LoginForm: React.FC = ({ formData, onInputChange }) => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isPinModalOpen, setIsPinModalOpen] = useState(false); + + const handleAppLogin = async () => { + try { + setIsLoading(true); + setError(null); + + const loginData = { + email: formData.email, + password: formData.password, + rememberMe: formData.rememberMe, + authType: AuthType.APP, + requestType: RequestType.DASHBOARD, + redirectUrl: "/home", + }; + + await AuthService.execute(loginData); + toast.success("로그인 성공!"); + } catch (err) { + setError( + err instanceof Error ? err.message : "로그인 중 오류가 발생했습니다." + ); + toast.error("로그인 실패! 다시 시도해주세요."); + } finally { + setIsLoading(false); + } + }; + + const handlePinSubmit = async (pin: string, captchaToken: string) => { + try { + setIsLoading(true); + setError(null); + + const loginData = { + email: formData.email, + password: formData.password, + rememberMe: formData.rememberMe, + pin, + recaptchaToken: captchaToken, + requestType: RequestType.DASHBOARD, + authType: AuthType.PIN, + redirectUrl: "/home", + }; + + await AuthService.execute(loginData); + toast.success("로그인 성공!"); + } catch (err) { + setError( + err instanceof Error ? err.message : "로그인 중 오류가 발생했습니다." + ); + toast.error("로그인 실패! 다시 시도해주세요."); + } finally { + setIsLoading(false); + } + }; + return (

로그인

-
+ e.preventDefault()}> + {error && ( +
+ {error} +
+ )} +
= ({ formData, onInputChange, onLogin className="w-full px-0 py-2 border-b border-gray-300 focus:border-blue-500 focus:outline-none transition-colors" value={formData.email} onChange={onInputChange} + disabled={isLoading} /> = ({ formData, onInputChange, onLogin className="w-full px-0 py-2 border-b border-gray-300 focus:border-blue-500 focus:outline-none transition-colors" value={formData.password} onChange={onInputChange} + disabled={isLoading} />
@@ -45,13 +116,20 @@ const LoginForm: React.FC = ({ formData, onInputChange, onLogin checked={formData.rememberMe} onChange={onInputChange} className="h-4 w-4 text-blue-600 rounded border-gray-300" + disabled={isLoading} /> -
- + 비밀번호를 잊으셨나요?
@@ -59,27 +137,41 @@ const LoginForm: React.FC = ({ formData, onInputChange, onLogin
- 계정이 없으신가요? NANU ID 생성하기 + 계정이 없으신가요?{" "} + + NANU ID 생성하기 +
+ + setIsPinModalOpen(false)} + onSubmit={(pin, captchaToken) => handlePinSubmit(pin, captchaToken)} + /> ); }; -export default LoginForm; +export default LoginForm; \ No newline at end of file diff --git a/src/components/auth/PinAuthModal.tsx b/src/components/auth/PinAuthModal.tsx new file mode 100644 index 0000000..f297d46 --- /dev/null +++ b/src/components/auth/PinAuthModal.tsx @@ -0,0 +1,118 @@ +import React, { useState } from 'react'; +import ReCAPTCHA from "react-google-recaptcha"; +import VirtualKeypad from "./VirtualKeypad"; + +interface PinAuthModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (pin: string, captchaToken: string) => void; +} + +const PinAuthModal: React.FC = ({ isOpen, onClose, onSubmit }) => { + const [step, setStep] = useState<'captcha' | 'pin'>('captcha'); + const [recaptchaToken, setRecaptchaToken] = useState(null); + const [pin, setPin] = useState(''); + + const handleRecaptchaChange = (token: string | null) => { + setRecaptchaToken(token); + if (token) { + setStep('pin'); + } + }; + + const handleKeyPress = (key: string) => { + if (pin.length < 6) { + setPin(prev => prev + key); + } + }; + + const handleDelete = () => { + setPin(prev => prev.slice(0, -1)); + }; + + const handleClear = () => { + setPin(''); + }; + + const handleSubmit = () => { + if (pin.length === 6 && recaptchaToken) { + onSubmit(pin, recaptchaToken); + setPin(''); + onClose(); + } + }; + + if (!isOpen) return null; + + return ( +
+
+ {step === 'captcha' ? ( +
+
+

보안 인증

+

+ NANU ID 앱을 설치하면 불필요한 캡챠 인증을 생략하고
+ 빠르게 로그인할 수 있습니다 +

+
+
+ +
+ +
+ ) : ( +
+
+

PIN 입력

+

6자리 PIN 번호를 입력해주세요

+
+ +
+ {Array(6).fill(0).map((_, i) => ( +
+ {pin[i] ? '•' : ''} +
+ ))} +
+ + + +
+ + +
+
+ )} +
+
+ ); +}; + +export default PinAuthModal; diff --git a/src/services/AuthService.ts b/src/services/AuthService.ts index d335fc0..9aab4fb 100644 --- a/src/services/AuthService.ts +++ b/src/services/AuthService.ts @@ -1,23 +1,73 @@ -import { LoginFormData, AuthResponse } from '../types/Auth'; - -export const authService = { - async login(credentials: LoginFormData): Promise { - try { - const response = await fetch('/api/auth/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(credentials), - }); - - if (!response.ok) { - throw new Error('Login failed'); - } - - return response.json(); - } catch (error) { - throw error; +import { LoginFormData, AuthResponse } from "../types/Auth"; +import { AuthType } from "./dto/request/AuthType"; +import { LoginRequest } from "./dto/request/LoginRequest"; +import { loginRequestViaApp } from "./dto/request/LoginRequestViaApp"; +import { loginRequestViaPin } from "./dto/request/LoginRequestViaPin"; +import { RequestType } from "./dto/request/RequestType"; +import { LoginResponse } from "./dto/response/LoginResponse"; +import SERVICE_API_URL from "./ServiceEndPoint"; + +const BASE_URL = SERVICE_API_URL.BASE_URL; + +export class AuthService { + static async execute(data: LoginRequest): Promise { + let endpoint = ""; + if (data.requestType === RequestType.DASHBOARD) { + let endpoint = `${BASE_URL}/auth/login`; + } else { + let endpoint = `${BASE_URL}/auth/oauth_login`; } - }, -}; \ No newline at end of file + console.log(endpoint) + console.log(data) + const requestBody = this.getRequestDtoByAuthType(data); + + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.message || "로그인 중 오류가 발생했습니다."); + } + + if (data.requestType !== RequestType.DASHBOARD) { + this.handleOAuthSuccess(result, data.redirectUrl); + return; + } + + this.storeTokens(result); + window.location.href = data.redirectUrl; + } + + private static getRequestDtoByAuthType(data: LoginRequest): any { + if (data.authType === AuthType.APP) { + return loginRequestViaApp(data); + } else { + return loginRequestViaPin(data); + } + } + + private static handleOAuthSuccess(result: any, redirectUrl?: string): void { + if (!redirectUrl) { + throw new Error("리디렉션 URL이 필요합니다."); + } + + const authCode = result.code; + if (!authCode) { + throw new Error("응답에 인증 코드가 포함되어 있지 않습니다."); + } + + const redirectWithAuthCode = `${redirectUrl}?AUTH_CODE=${authCode}`; + window.location.href = redirectWithAuthCode; + } + + private static storeTokens(result: LoginResponse): void { + document.cookie = `access_token=${result.access_token}; path=/; secure; HttpOnly`; + document.cookie = `refresh_token=${result.refresh_token}; path=/; secure; HttpOnly`; + } +} diff --git a/src/services/ServiceEndPoint.ts b/src/services/ServiceEndPoint.ts new file mode 100644 index 0000000..8912786 --- /dev/null +++ b/src/services/ServiceEndPoint.ts @@ -0,0 +1 @@ +export default { BASE_URL : "https://auth.nanu.cc" } \ No newline at end of file diff --git a/src/services/dto/request/AuthType.ts b/src/services/dto/request/AuthType.ts new file mode 100644 index 0000000..59b237d --- /dev/null +++ b/src/services/dto/request/AuthType.ts @@ -0,0 +1,4 @@ +export enum AuthType { + APP = "APP", + PIN = "PIN", +} diff --git a/src/services/dto/request/LoginRequest.ts b/src/services/dto/request/LoginRequest.ts new file mode 100644 index 0000000..f18a0a6 --- /dev/null +++ b/src/services/dto/request/LoginRequest.ts @@ -0,0 +1,16 @@ +import { AuthType } from "./AuthType"; +import { RequestType } from "./RequestType"; + +export interface LoginRequest { + email: string; + password: string; + rememberMe: boolean; + requestType: RequestType; + authType: AuthType; + + recaptchaToken?: string; // 일반 PIN인증 DTO + pin? : string; + + applicationId? : string; // OAuth 인증 DTO + redirectUrl : string; +} diff --git a/src/services/dto/request/LoginRequestViaApp.ts b/src/services/dto/request/LoginRequestViaApp.ts new file mode 100644 index 0000000..cdebff3 --- /dev/null +++ b/src/services/dto/request/LoginRequestViaApp.ts @@ -0,0 +1,8 @@ +import { LoginRequest } from "./LoginRequest"; + +export function loginRequestViaApp(data: LoginRequest): any { + return { + email: data.email, + password: data.password + }; +} diff --git a/src/services/dto/request/LoginRequestViaPin.ts b/src/services/dto/request/LoginRequestViaPin.ts new file mode 100644 index 0000000..0f255ef --- /dev/null +++ b/src/services/dto/request/LoginRequestViaPin.ts @@ -0,0 +1,10 @@ +import { LoginRequest } from './LoginRequest'; + +export function loginRequestViaPin(data: LoginRequest): any { + return { + email: data.email, + password: data.password, + recaptchaToken: data.recaptchaToken, + pin: data.password, + }; +} \ No newline at end of file diff --git a/src/services/dto/request/RequestType.ts b/src/services/dto/request/RequestType.ts new file mode 100644 index 0000000..90c2d37 --- /dev/null +++ b/src/services/dto/request/RequestType.ts @@ -0,0 +1,4 @@ +export enum RequestType { + DASHBOARD = "DASHBOARD", + OAUTH = "OAUTH" +} \ No newline at end of file diff --git a/src/services/dto/response/LoginResponse.ts b/src/services/dto/response/LoginResponse.ts new file mode 100644 index 0000000..faedc58 --- /dev/null +++ b/src/services/dto/response/LoginResponse.ts @@ -0,0 +1,4 @@ +export interface LoginResponse { + access_token: string; + refresh_token: string; +}