Skip to content

Commit c282d4b

Browse files
author
chanrhan
committed
로그인 페이지 및 카카오 로그인 API 추가
1 parent 5a25dcd commit c282d4b

7 files changed

Lines changed: 450 additions & 5 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
alwaysApply: false
3+
---
4+
5+
## 기능 명세 : 카카오 로그인 (oAuth) 추가
6+
> kakao-login-developers : https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#before-you-begin-process
7+
- 메인 페이지는 대시보드 페이지가 보여야 한다.
8+
- 우상단, 그리고 대시보드 페이지 첫 화면에 로그인 버튼이 각각 존재해야 한다.
9+
- 로그인 버튼을 누르면 로그인 페이지로 이동한다.
10+
- 사용자는 "카카오 로그인" 버튼을 눌러서 로그인할 수 있다.
11+
- 카카오 로그인 버튼을 누르면 /api/v1/users/login/kakao/authorization API 를 요청한다.
12+
- redirect URL 이 응답된다.
13+
- 해당 redirect URL 에서는 사용자에게 카카오 로그인을 요청한다.
14+
- 요청이 완료되면 /api/v1/users/login/kakao 로 redirect 응답이 온다.
15+
- 성공 응답이 반환되면, 사진-지도 페이지로 이동한다.
16+
17+
### 신규 API
18+
- GET /api/v1/users/login/kakao/authorization
19+
20+
> 엔드포인트 형태
21+
```
22+
@GetMapping("/login/kakao/authorization")
23+
public ResponseEntity<Void> authorize() {
24+
return ResponseEntity
25+
.status(HttpStatus.FOUND)
26+
.location(URI.create(kakaoAuthService.getAuthorizationRedirectUrl()))
27+
.build();
28+
}
29+
```
30+
31+
- GET /api/v1/users/login/kakao?code={authorizationCode}
32+
- redirect용 URL (아마 딱히 구현 안해도 될거같은데)

src/App.tsx

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,89 @@
11
import { useEffect } from 'react';
2+
import { BrowserRouter, Routes, Route, useNavigate } from 'react-router-dom';
23
import { MainPage } from './pages/MainPage';
4+
import { LoginPage } from './pages/LoginPage';
35
import { validateEnv } from './config/env';
6+
import { isAuthenticated, setAuthToken, setRefreshToken, setUserInfo } from './utils/auth';
47
import './App.css';
58

6-
function App() {
9+
// 로그인 콜백 처리 컴포넌트
10+
// 서버가 JWT 토큰을 URL Fragment(#)로 전달하여 프론트엔드로 리다이렉트합니다
11+
// Fragment는 서버로 전송되지 않고 브라우저 히스토리에도 남지 않아 보안상 더 안전합니다
12+
const KakaoCallbackHandler = () => {
13+
const navigate = useNavigate();
14+
15+
useEffect(() => {
16+
// URL Fragment에서 토큰 정보 추출
17+
// 예: http://localhost:5173/login/kakao#accessToken=xxx&refreshToken=yyy
18+
const hash = window.location.hash.substring(1); // # 제거
19+
const params = new URLSearchParams(hash);
20+
21+
const accessToken = params.get('accessToken');
22+
const refreshToken = params.get('refreshToken');
23+
24+
if (accessToken) {
25+
// 서버에서 전달받은 JWT 토큰 저장
26+
setAuthToken(accessToken);
27+
28+
// 리프레시 토큰이 있으면 저장
29+
if (refreshToken) {
30+
setRefreshToken(refreshToken);
31+
}
32+
33+
// 사용자 정보가 별도로 전달되는 경우 (선택사항)
34+
const userInfo = params.get('userInfo');
35+
if (userInfo) {
36+
try {
37+
setUserInfo(JSON.parse(decodeURIComponent(userInfo)));
38+
} catch (error) {
39+
console.warn('사용자 정보 파싱 실패:', error);
40+
}
41+
}
42+
43+
// Fragment 제거 (보안을 위해 URL에서 토큰 정보 제거)
44+
window.history.replaceState(null, '', window.location.pathname);
45+
46+
// 로그인 성공 후 메인 페이지로 이동
47+
navigate('/', { replace: true });
48+
} else {
49+
// 토큰이 없으면 로그인 페이지로 리다이렉트
50+
console.error('토큰이 전달되지 않았습니다.');
51+
alert('로그인에 실패했습니다. 다시 시도해주세요.');
52+
navigate('/login', { replace: true });
53+
}
54+
}, [navigate]);
55+
56+
return (
57+
<div style={{
58+
display: 'flex',
59+
alignItems: 'center',
60+
justifyContent: 'center',
61+
minHeight: '100vh',
62+
backgroundColor: '#F2F2F7',
63+
}}>
64+
<p style={{ fontSize: '16px', color: '#8E8E93' }}>로그인 처리 중...</p>
65+
</div>
66+
);
67+
};
68+
69+
// 인증이 필요한 라우트 보호 컴포넌트
70+
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
71+
const navigate = useNavigate();
72+
73+
useEffect(() => {
74+
if (!isAuthenticated()) {
75+
navigate('/login', { replace: true });
76+
}
77+
}, [navigate]);
78+
79+
if (!isAuthenticated()) {
80+
return null;
81+
}
82+
83+
return <>{children}</>;
84+
};
85+
86+
function AppContent() {
787
useEffect(() => {
888
try {
989
validateEnv();
@@ -13,9 +93,25 @@ function App() {
1393
}, []);
1494

1595
try {
16-
return <MainPage />;
96+
return (
97+
<Routes>
98+
<Route path="/login" element={<LoginPage />} />
99+
<Route
100+
path="/login/kakao"
101+
element={<KakaoCallbackHandler />}
102+
/>
103+
<Route
104+
path="/"
105+
element={
106+
<ProtectedRoute>
107+
<MainPage />
108+
</ProtectedRoute>
109+
}
110+
/>
111+
</Routes>
112+
);
17113
} catch (error) {
18-
console.error('MainPage render error:', error);
114+
console.error('App render error:', error);
19115
return (
20116
<div style={{
21117
padding: '20px',
@@ -28,4 +124,12 @@ function App() {
28124
}
29125
}
30126

127+
function App() {
128+
return (
129+
<BrowserRouter>
130+
<AppContent />
131+
</BrowserRouter>
132+
);
133+
}
134+
31135
export default App;

src/api/client.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import axios, { type AxiosInstance } from 'axios';
1+
import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios';
22
import { config } from '../config/env';
3+
import { getAuthToken } from '../utils/auth';
34

45
class ApiClient {
56
private client: AxiosInstance;
@@ -15,7 +16,13 @@ class ApiClient {
1516

1617
// Request interceptor
1718
this.client.interceptors.request.use(
18-
(config) => {
19+
(config: InternalAxiosRequestConfig) => {
20+
// 인증 토큰 추가
21+
const token = getAuthToken();
22+
if (token && config.headers) {
23+
config.headers.Authorization = `Bearer ${token}`;
24+
}
25+
1926
// 요청 전 로깅 (개발 환경에서만)
2027
if (import.meta.env.DEV) {
2128
console.log(`[API Request] ${config.method?.toUpperCase()} ${config.url}`);

src/api/loginApi.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { apiClient } from './client';
2+
3+
/**
4+
* 카카오 로그인 인증 URL 요청
5+
* 서버는 302 FOUND 응답과 함께 Location 헤더에 카카오 인증 URL을 반환합니다.
6+
* @returns redirect URL (카카오 인증 페이지 URL)
7+
*/
8+
export const getKakaoAuthorizationUrl = async (): Promise<string> => {
9+
try {
10+
const response = await apiClient.get('/users/login/kakao/authorization', {
11+
maxRedirects: 0, // 리다이렉트를 자동으로 따라가지 않음
12+
validateStatus: (status) => status === 302 || status === 200,
13+
});
14+
15+
// 302 FOUND 응답인 경우 Location 헤더에서 URL 추출
16+
if (response.status === 302) {
17+
// Location 헤더는 대소문자 구분 없이 접근 가능
18+
const location = response.headers.location || response.headers.Location;
19+
if (location) {
20+
return location;
21+
}
22+
throw new Error('Location 헤더가 없습니다.');
23+
}
24+
25+
// 200 응답인 경우 (일부 서버는 200으로 리다이렉트 URL 반환)
26+
if (response.data && typeof response.data === 'string') {
27+
return response.data;
28+
}
29+
30+
throw new Error('카카오 인증 URL을 가져올 수 없습니다.');
31+
} catch (error: any) {
32+
// axios가 302를 에러로 처리하는 경우 Location 헤더 확인
33+
if (error.response?.status === 302) {
34+
const location = error.response.headers.location || error.response.headers.Location;
35+
if (location) {
36+
return location;
37+
}
38+
}
39+
throw error;
40+
}
41+
};
42+
43+
/**
44+
* 카카오 로그인 콜백 처리
45+
* @param code 카카오 인증 코드
46+
* @returns 로그인 성공 응답
47+
*/
48+
export const loginWithKakao = async (code: string): Promise<any> => {
49+
const response = await apiClient.get('/users/login/kakao', {
50+
params: { code },
51+
});
52+
return response.data;
53+
};
54+

src/pages/LoginPage.tsx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { config } from '../config/env';
2+
3+
export const LoginPage = () => {
4+
const handleKakaoLogin = () => {
5+
// XHR 없이 브라우저가 직접 서버 URL로 이동
6+
// 서버가 302 응답을 반환하면 브라우저가 자동으로 Location 헤더의 URL로 리다이렉트됩니다
7+
// 이 방법은 CORS 문제를 피할 수 있습니다
8+
window.location.href = `${config.apiBaseUrl}/users/login/kakao/authorization`;
9+
};
10+
11+
return (
12+
<div style={{
13+
display: 'flex',
14+
flexDirection: 'column',
15+
alignItems: 'center',
16+
justifyContent: 'center',
17+
minHeight: '100vh',
18+
backgroundColor: '#F2F2F7',
19+
padding: '20px',
20+
}}>
21+
<div style={{
22+
backgroundColor: '#FFFFFF',
23+
borderRadius: '16px',
24+
padding: '48px 32px',
25+
boxShadow: '0 4px 16px rgba(0,0,0,0.1)',
26+
maxWidth: '400px',
27+
width: '100%',
28+
}}>
29+
<h1 style={{
30+
margin: '0 0 8px 0',
31+
fontSize: '32px',
32+
fontWeight: '700',
33+
color: '#000000',
34+
textAlign: 'center',
35+
letterSpacing: '-0.5px',
36+
}}>
37+
Photo Liner
38+
</h1>
39+
<p style={{
40+
margin: '0 0 40px 0',
41+
fontSize: '16px',
42+
color: '#8E8E93',
43+
textAlign: 'center',
44+
}}>
45+
사진을 지도에 기록하세요
46+
</p>
47+
48+
<button
49+
onClick={handleKakaoLogin}
50+
style={{
51+
width: '100%',
52+
padding: '16px',
53+
backgroundColor: '#FEE500',
54+
color: '#000000',
55+
border: 'none',
56+
borderRadius: '12px',
57+
fontSize: '16px',
58+
fontWeight: '600',
59+
cursor: 'pointer',
60+
transition: 'opacity 0.2s',
61+
display: 'flex',
62+
alignItems: 'center',
63+
justifyContent: 'center',
64+
gap: '8px',
65+
}}
66+
>
67+
<span>카카오 로그인</span>
68+
</button>
69+
</div>
70+
</div>
71+
);
72+
};
73+

0 commit comments

Comments
 (0)