Skip to content

Commit 4cc6396

Browse files
authored
Merge pull request #38 from Searchweb-Dev/feat/SW-65
Feat/sw 65 - 세션 기반 인증에서 JWT 기반 인증 전환
2 parents 59f22b9 + fbbe02c commit 4cc6396

109 files changed

Lines changed: 5464 additions & 2711 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,14 @@ out/
4545
.claude/
4646
CLAUDE.md
4747

48+
### OMC ###
49+
.omc/
50+
4851
### HTTP Tests ###
4952
*.http
53+
54+
### Temporary Files ###
55+
.gradle-user/
56+
status.txt
57+
status_utf8.txt
58+
docs/Backend/memo/

build.gradle

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,19 @@ repositories {
2626

2727
dependencies {
2828
implementation 'org.springframework.boot:spring-boot-starter-security'
29-
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
3029
implementation 'org.springframework.boot:spring-boot-starter-web'
3130
implementation 'org.springframework.boot:spring-boot-starter-aop'
3231
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
3332
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
34-
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
3533
implementation 'org.springframework.boot:spring-boot-starter-validation'
3634
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
3735
implementation 'me.paulschwarz:spring-dotenv:4.0.0'
3836
implementation 'org.jsoup:jsoup:1.17.2'
37+
38+
// JWT
39+
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
40+
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
41+
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
3942
implementation platform('org.springframework.ai:spring-ai-bom:1.1.2')
4043
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
4144
implementation 'org.springframework.ai:spring-ai-starter-model-google-genai'

docs/Backend/02. jwt-oauth2-architecture.md

Lines changed: 897 additions & 0 deletions
Large diffs are not rendered by default.

docs/Backend/03. jwt-oauth2-test-scenarios.md

Lines changed: 1282 additions & 0 deletions
Large diffs are not rendered by default.

frontend/next.config.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,29 @@
11
import type { NextConfig } from "next";
22

3+
const isDev = process.env.NODE_ENV === 'development';
4+
const backendOrigin = (process.env.NEXT_PUBLIC_BACKEND_ORIGIN || (isDev ? 'http://localhost:8080' : '')).replace(/\/+$/, '');
5+
36
const nextConfig: NextConfig = {
4-
// [BACKEND_CONNECT] 백엔드 API 프록시 설정 (CORS 해결)
7+
// [BACKEND_CONNECT] 백엔드 API 프록시 설정
8+
// 인증 관련 엔드포인트(/api/auth/*)는 프론트엔드에서 백엔드로 직접 호출한다.
9+
// (buildBackendUrl 참고) Set-Cookie 가 프록시를 거치며 누락되는 문제를 방지하기 위함.
10+
// 아래 rewrite 는 인증 외 일반 API 용이며, Phase 3(Nginx 단일 origin) 도입 시 제거된다.
511
async rewrites() {
12+
// 운영 환경에서 백엔드 주소가 없는 경우(예: Nginx 단일 오리진 사용) rewrite 생략
13+
if (!backendOrigin) return [];
14+
615
return [
716
{
817
source: '/api/:path*',
9-
destination: 'http://localhost:8080/api/:path*',
18+
destination: `${backendOrigin}/api/:path*`,
19+
},
20+
{
21+
source: '/oauth2/:path*',
22+
destination: `${backendOrigin}/oauth2/:path*`,
23+
},
24+
{
25+
source: '/login/oauth2/:path*',
26+
destination: `${backendOrigin}/login/oauth2/:path*`,
1027
},
1128
];
1229
},
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"use client";
2+
3+
import { useEffect, useRef } from 'react';
4+
import { useRouter } from 'next/navigation';
5+
import { useAuthStore } from '@/lib/store/authStore';
6+
7+
/**
8+
* OAuth 로그인 완료 후 백엔드가 리디렉션하는 콜백 페이지
9+
* - 마운트 시 initialize()를 직접 호출하여 토큰 갱신 및 상태 초기화 진행 (authStore 내부에서 중복 호출 방지)
10+
* - initialize()는 내부적으로 한 번만 실행되도록 싱글톤(Promise) 처리되어 있어 안전함
11+
*/
12+
export default function AuthCallbackPage() {
13+
const router = useRouter();
14+
const initialize = useAuthStore((s) => s.initialize);
15+
const isInitializing = useAuthStore((s) => s.isInitializing);
16+
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
17+
const initialized = useRef(false);
18+
19+
// 컴포넌트 마운트 시 초기화 실행
20+
// - authStore.initialize() 내부에서 이미 진행 중인 Promise가 있다면 재사용하므로 안전함
21+
useEffect(() => {
22+
if (!initialized.current) {
23+
initialize();
24+
initialized.current = true;
25+
}
26+
}, [initialize]);
27+
28+
useEffect(() => {
29+
// 초기화가 끝날 때까지 대기
30+
if (isInitializing) return;
31+
32+
// 인증 성공 여부에 따라 페이지 이동
33+
router.replace(isAuthenticated ? '/my-links' : '/login');
34+
}, [isInitializing, isAuthenticated, router]);
35+
36+
return (
37+
<div className="flex min-h-screen items-center justify-center">
38+
<div className="flex flex-col items-center gap-3">
39+
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
40+
<p className="text-sm text-text-sub">로그인 처리 중...</p>
41+
</div>
42+
</div>
43+
);
44+
}

frontend/src/app/layout.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import "./globals.css";
44
import { AppLayout } from "@/components/layout/AppLayout";
55
import { SaveLinkDialog } from "@/components/dialogs/SaveLinkDialog";
66
import { CreateFolderDialog } from "@/components/dialogs/CreateFolderDialog";
7+
import { AuthProvider } from "@/components/providers/AuthProvider";
78
import { QueryProvider } from "@/components/providers/QueryProvider";
89

910

@@ -31,11 +32,13 @@ export default function RootLayout({
3132
</head>
3233
<body className={`${inter.variable} font-sans antialiased bg-background-light dark:bg-background-dark text-text-main h-screen overflow-hidden flex transition-colors duration-200`}>
3334
<QueryProvider>
34-
<AppLayout>
35-
{children}
36-
<SaveLinkDialog />
37-
<CreateFolderDialog />
38-
</AppLayout>
35+
<AuthProvider>
36+
<AppLayout>
37+
{children}
38+
</AppLayout>
39+
</AuthProvider>
40+
<SaveLinkDialog />
41+
<CreateFolderDialog />
3942
</QueryProvider>
4043
</body>
4144
</html>

frontend/src/app/login/page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import React from "react";
44
import NextLink from "next/link";
55
import { Input } from "@/components/ui/input";
6+
import { buildBackendUrl } from "@/lib/config/backend";
67

78
export default function LoginPage() {
89
return (
@@ -137,7 +138,8 @@ export default function LoginPage() {
137138
</div>
138139

139140
{/* Google Sign In - Purple Gradient Background */}
140-
<button type="button" className="group relative mb-6 flex h-11 w-full items-center justify-center gap-3 rounded-lg border-t border-white/20 bg-[linear-gradient(135deg,#6d28d9,#8b5cf6)] text-white shadow-md transition-all hover:brightness-110 hover:shadow-primary/25">
141+
{/* 소셜 로그인 시작도 백엔드 직통으로 보내야 OAuth 콜백과 쿠키 발급 주체가 일관된다. */}
142+
<button type="button" onClick={() => { window.location.href = buildBackendUrl('/oauth2/authorization/google'); }} className="group relative mb-6 flex h-11 w-full items-center justify-center gap-3 rounded-lg border-t border-white/20 bg-[linear-gradient(135deg,#6d28d9,#8b5cf6)] text-white shadow-md transition-all hover:brightness-110 hover:shadow-primary/25">
141143
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-white p-1">
142144
<svg className="h-full w-full" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
143145
<title>Google Logo</title>

frontend/src/app/my-links/page.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,19 @@ import { RightPanel } from '@/components/my-links/RightPanel';
44
import { useUIStore } from '@/lib/store/uiStore';
55
import { useFolders } from '@/lib/api/folderApi';
66
import { useFolderStore } from '@/lib/store/folderStore';
7-
import { TEMP_MEMBER_ID } from '@/lib/auth/currentUser';
7+
import { useAuthStore } from '@/lib/store/authStore';
88

99
const PINNED_COLORS = ['bg-blue-600', 'bg-indigo-500', 'bg-teal-500', 'bg-amber-500', 'bg-emerald-600'];
1010

1111
export default function MyLinksPage() {
1212
const { toggleRightPanel } = useUIStore();
1313
const setSelectedFolderId = useFolderStore((s) => s.setSelectedFolderId);
14-
15-
const { data: folders, isLoading, error } = useFolders(TEMP_MEMBER_ID);
14+
const memberId = useAuthStore((s) => s.member?.memberId);
15+
const isAuthInitializing = useAuthStore((s) => s.isInitializing);
16+
const { data: folders, isLoading: isFoldersLoading, error } = useFolders(memberId);
17+
18+
// 인증 정보를 복구 중이거나 아직 폴더 목록을 가져오는 중이면 로딩 상태로 간주
19+
const isLoading = isAuthInitializing || isFoldersLoading;
1620
const pinnedFolders = folders?.slice(0, 5) ?? [];
1721

1822
return (
@@ -162,8 +166,8 @@ export default function MyLinksPage() {
162166
</div>
163167
)}
164168

165-
{/* ── 빈 상태 ── */}
166-
{folders && folders.length === 0 && (
169+
{/* ── 빈 상태 (로딩이 끝난 후 데이터가 없는 경우) ── */}
170+
{!isLoading && folders && folders.length === 0 && (
167171
<div className="col-span-full flex items-center justify-center py-8">
168172
<div className="flex flex-col items-center gap-1 text-gray-400 text-xs">
169173
<span className="material-symbols-outlined text-2xl">folder_off</span>

frontend/src/components/dialogs/CreateFolderDialog.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useUIStore } from '@/lib/store/uiStore';
44
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
55
import { useState } from 'react';
66
import { useCreateFolder } from '@/lib/api/folderApi';
7-
import { TEMP_MEMBER_ID } from '@/lib/auth/currentUser';
7+
import { useAuthStore } from '@/lib/store/authStore';
88

99
const COLORS = [
1010
{ id: 'purple', base: 'bg-purple-500 hover:ring-purple-500', active: 'ring-purple-500 shadow-[0_0_8px_rgba(168,85,247,0.5)]' },
@@ -28,6 +28,7 @@ export function CreateFolderDialog() {
2828
const [selectedIcon, setSelectedIcon] = useState('work'); // 선택된 아이콘 이름
2929
const [selectedColor, setSelectedColor] = useState('purple'); // 선택된 색상 ID
3030
const [isPinned, setIsPinned] = useState(true); // 상단 고정 여부
31+
const memberId = useAuthStore((s) => s.member?.memberId);
3132

3233
// 폴더 생성 API 연동 (React Query Mutation)
3334
const createFolderMutation = useCreateFolder();
@@ -38,12 +39,12 @@ export function CreateFolderDialog() {
3839
*/
3940
const handleCreateFolder = () => {
4041
// 유효성 검사: 이름이 비어있으면 중단
41-
if (!folderName.trim()) return;
42+
if (!folderName.trim() || !memberId) return;
4243

4344
// API 호출
4445
createFolderMutation.mutate(
4546
{
46-
ownerMemberId: TEMP_MEMBER_ID, // 현재는 테스트용 ID 사용
47+
ownerMemberId: memberId,
4748
folderName: folderName.trim(),
4849
},
4950
{

0 commit comments

Comments
 (0)