@kood/claude-code 0.6.6 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +7 -1
- package/package.json +1 -1
- package/templates/.claude/agents/analyst.md +5 -0
- package/templates/.claude/agents/architect.md +5 -0
- package/templates/.claude/agents/build-fixer.md +1 -0
- package/templates/.claude/agents/code-reviewer.md +1 -0
- package/templates/.claude/agents/critic.md +4 -0
- package/templates/.claude/agents/deep-executor.md +1 -0
- package/templates/.claude/agents/dependency-manager.md +2 -0
- package/templates/.claude/agents/deployment-validator.md +2 -0
- package/templates/.claude/agents/designer.md +2 -0
- package/templates/.claude/agents/document-writer.md +3 -0
- package/templates/.claude/agents/explore.md +1 -0
- package/templates/.claude/agents/git-operator.md +2 -0
- package/templates/.claude/agents/implementation-executor.md +2 -0
- package/templates/.claude/agents/ko-to-en-translator.md +3 -0
- package/templates/.claude/agents/lint-fixer.md +2 -0
- package/templates/.claude/agents/planner.md +3 -0
- package/templates/.claude/agents/pm.md +349 -0
- package/templates/.claude/agents/qa-tester.md +1 -0
- package/templates/.claude/agents/refactor-advisor.md +4 -0
- package/templates/.claude/agents/researcher.md +9 -1
- package/templates/.claude/agents/scientist.md +1 -0
- package/templates/.claude/agents/security-reviewer.md +1 -0
- package/templates/.claude/agents/tdd-guide.md +1 -0
- package/templates/.claude/agents/vision.md +1 -0
- package/templates/.claude/instructions/agent-patterns/agent-teams-usage.md +376 -0
- package/templates/.claude/instructions/sourcing/reliable-search.md +49 -2
- package/templates/.claude/scripts/agent-teams/check-availability.sh +238 -0
- package/templates/.claude/scripts/agent-teams/setup-tmux.sh +125 -0
- package/templates/.claude/skills/agent-teams-setup/SKILL.md +460 -0
- package/templates/.claude/skills/brainstorm/SKILL.md +1 -0
- package/templates/.claude/skills/bug-fix/SKILL.md +1 -0
- package/templates/.claude/skills/crawler/SKILL.md +2 -0
- package/templates/.claude/skills/docs-creator/SKILL.md +1 -0
- package/templates/.claude/skills/docs-fetch/SKILL.md +6 -4
- package/templates/.claude/skills/docs-refactor/SKILL.md +1 -0
- package/templates/.claude/skills/elon-musk/SKILL.md +1 -0
- package/templates/.claude/skills/execute/SKILL.md +1 -0
- package/templates/.claude/skills/feedback/SKILL.md +1 -0
- package/templates/.claude/skills/figma-to-code/SKILL.md +1 -0
- package/templates/.claude/skills/genius-thinking/SKILL.md +1 -0
- package/templates/.claude/skills/global-uiux-design/SKILL.md +1 -0
- package/templates/.claude/skills/korea-uiux-design/SKILL.md +1 -0
- package/templates/.claude/skills/nextjs-react-best-practices/SKILL.md +1 -0
- package/templates/.claude/skills/plan/SKILL.md +1 -0
- package/templates/.claude/skills/prd/SKILL.md +1 -0
- package/templates/.claude/skills/project-optimizer/AGENTS.md +275 -0
- package/templates/.claude/skills/project-optimizer/SKILL.md +375 -0
- package/templates/.claude/skills/project-optimizer/rules/arch-config-centralize.md +66 -0
- package/templates/.claude/skills/project-optimizer/rules/arch-hot-path.md +35 -0
- package/templates/.claude/skills/project-optimizer/rules/arch-interface-segregation.md +51 -0
- package/templates/.claude/skills/project-optimizer/rules/arch-module-boundary.md +42 -0
- package/templates/.claude/skills/project-optimizer/rules/build-cache.md +57 -0
- package/templates/.claude/skills/project-optimizer/rules/build-code-split.md +56 -0
- package/templates/.claude/skills/project-optimizer/rules/build-incremental.md +65 -0
- package/templates/.claude/skills/project-optimizer/rules/build-minify.md +61 -0
- package/templates/.claude/skills/project-optimizer/rules/build-tree-shake.md +60 -0
- package/templates/.claude/skills/project-optimizer/rules/code-complexity.md +65 -0
- package/templates/.claude/skills/project-optimizer/rules/code-dead-elimination.md +32 -0
- package/templates/.claude/skills/project-optimizer/rules/code-duplication.md +54 -0
- package/templates/.claude/skills/project-optimizer/rules/code-error-handling.md +75 -0
- package/templates/.claude/skills/project-optimizer/rules/code-naming.md +52 -0
- package/templates/.claude/skills/project-optimizer/rules/concurrency-defer-await.md +54 -0
- package/templates/.claude/skills/project-optimizer/rules/concurrency-parallel.md +90 -0
- package/templates/.claude/skills/project-optimizer/rules/concurrency-pipeline.md +68 -0
- package/templates/.claude/skills/project-optimizer/rules/concurrency-pool.md +68 -0
- package/templates/.claude/skills/project-optimizer/rules/deps-lightweight-alt.md +37 -0
- package/templates/.claude/skills/project-optimizer/rules/deps-peer-align.md +44 -0
- package/templates/.claude/skills/project-optimizer/rules/deps-security-audit.md +45 -0
- package/templates/.claude/skills/project-optimizer/rules/deps-unused-removal.md +25 -0
- package/templates/.claude/skills/project-optimizer/rules/deps-version-pin.md +40 -0
- package/templates/.claude/skills/project-optimizer/rules/dx-ci-speed.md +47 -0
- package/templates/.claude/skills/project-optimizer/rules/dx-dev-server.md +35 -0
- package/templates/.claude/skills/project-optimizer/rules/dx-lint-config.md +36 -0
- package/templates/.claude/skills/project-optimizer/rules/dx-test-coverage.md +34 -0
- package/templates/.claude/skills/project-optimizer/rules/dx-type-safety.md +49 -0
- package/templates/.claude/skills/project-optimizer/rules/io-batch-queries.md +67 -0
- package/templates/.claude/skills/project-optimizer/rules/io-cache-layer.md +67 -0
- package/templates/.claude/skills/project-optimizer/rules/io-connection-reuse.md +67 -0
- package/templates/.claude/skills/project-optimizer/rules/io-serialize-minimal.md +61 -0
- package/templates/.claude/skills/project-optimizer/rules/io-stream.md +75 -0
- package/templates/.claude/skills/project-optimizer/rules/memory-bounded-cache.md +65 -0
- package/templates/.claude/skills/project-optimizer/rules/memory-large-data.md +64 -0
- package/templates/.claude/skills/project-optimizer/rules/memory-lazy-init.md +78 -0
- package/templates/.claude/skills/project-optimizer/rules/memory-leak-prevention.md +79 -0
- package/templates/.claude/skills/project-optimizer/rules/memory-pool-reuse.md +70 -0
- package/templates/.claude/skills/ralph/SKILL.md +1 -0
- package/templates/.claude/skills/refactor/SKILL.md +1 -0
- package/templates/.claude/skills/research/SKILL.md +1 -0
- package/templates/.claude/skills/sql-optimizer/SKILL.md +438 -0
- package/templates/.claude/skills/sql-optimizer/orm-patterns.md +218 -0
- package/templates/.claude/skills/startup-validator/SKILL.md +1 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/AGENTS.md +53 -14
- package/templates/.claude/skills/tanstack-start-react-best-practices/SKILL.md +94 -27
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/bundle-defer-third-party.md +42 -19
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/client-optimistic-updates.md +109 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/client-suspense-query.md +74 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/client-use-hook.md +81 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/rerender-react-compiler.md +81 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-beforeload-auth.md +121 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-file-conventions.md +104 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-link-navigation.md +119 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-nested-layouts.md +155 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-path-params.md +89 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-pending-component.md +110 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-preload-strategy.md +91 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-router-context.md +120 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-search-params.md +114 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/server-deferred-data.md +1 -1
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/server-error-boundaries.md +79 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/server-middleware.md +85 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/server-serialization.md +56 -21
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/server-streaming.md +84 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/server-validator.md +71 -0
- package/templates/.claude/skills/tauri-react-best-practices/AGENTS.md +527 -0
- package/templates/.claude/skills/tauri-react-best-practices/SKILL.md +571 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/bundle-barrel-imports.md +140 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/bundle-cargo-profile.md +96 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/bundle-frontend-treeshake.md +242 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/bundle-lazy-components.md +255 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/bundle-remove-unused-commands.md +160 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/deploy-ci-pipeline.md +269 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/deploy-signing.md +207 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/deploy-updater.md +226 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/ipc-async-commands.md +172 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/ipc-batch-commands.md +133 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/ipc-binary-response.md +198 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/ipc-channel-streaming.md +186 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/ipc-error-handling.md +250 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/ipc-type-safe.md +227 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/perf-derived-state.md +231 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/perf-functional-setstate.md +191 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/perf-index-maps.md +276 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/perf-lazy-state-init.md +196 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/plugin-lifecycle.md +265 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/plugin-mobile-compat.md +199 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/plugin-permission-scope.md +193 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/react-error-boundary.md +239 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/react-event-listener.md +151 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/react-file-src.md +155 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/react-invoke-hook.md +139 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/react-optimistic-update.md +211 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/security-capability-split.md +205 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/security-csp.md +207 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/security-least-privilege.md +106 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/security-no-wildcard.md +253 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/security-scope-paths.md +160 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/state-async-mutex.md +270 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/state-mutex-pattern.md +265 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/state-react-sync.md +375 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/state-single-container.md +275 -0
- package/templates/tanstack-start/docs/architecture.md +238 -167
- package/templates/tanstack-start/docs/library/tanstack-router/error-handling.md +777 -38
- package/templates/tanstack-start/docs/library/tanstack-router/hooks.md +549 -37
- package/templates/tanstack-start/docs/library/tanstack-router/index.md +895 -111
- package/templates/tanstack-start/docs/library/tanstack-router/navigation.md +641 -43
- package/templates/tanstack-start/docs/library/tanstack-router/route-context.md +889 -38
- package/templates/tanstack-start/docs/library/tanstack-router/search-params.md +891 -29
- package/templates/tanstack-start/docs/library/tanstack-start/auth-patterns.md +972 -36
- package/templates/tanstack-start/docs/library/tanstack-start/index.md +1525 -881
- package/templates/tanstack-start/docs/library/tanstack-start/middleware.md +1099 -20
- package/templates/tanstack-start/docs/library/tanstack-start/routing.md +796 -30
- package/templates/tanstack-start/docs/library/tanstack-start/server-functions.md +953 -35
- package/templates/tanstack-start/docs/library/tanstack-start/setup.md +371 -15
- package/templates/tauri/CLAUDE.md +189 -0
- package/templates/tauri/docs/guides/distribution.md +261 -0
- package/templates/tauri/docs/guides/getting-started.md +302 -0
- package/templates/tauri/docs/guides/mobile.md +288 -0
- package/templates/tauri/docs/library/tauri/index.md +510 -0
|
@@ -1,70 +1,1006 @@
|
|
|
1
1
|
# TanStack Start - 인증 패턴
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> Better Auth 통합 및 인증 구현 (v1.159.4)
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<overview>
|
|
8
|
+
|
|
9
|
+
## 인증 시스템 구성
|
|
10
|
+
|
|
11
|
+
| 계층 | 담당 | 예시 |
|
|
12
|
+
|------|------|------|
|
|
13
|
+
| **인증 라이브러리** | 세션/토큰 관리 | Better Auth, Clerk, WorkOS, Auth.js |
|
|
14
|
+
| **Server Functions** | 로그인/로그아웃 | `login()`, `logout()`, `register()` |
|
|
15
|
+
| **미들웨어** | 인증 검증 | `authMiddleware` |
|
|
16
|
+
| **라우트 보호** | 접근 제어 | `beforeLoad`, `_authed` 패턴 |
|
|
17
|
+
| **컴포넌트** | 로그인 폼 | `LoginForm`, 인증 UI |
|
|
18
|
+
|
|
19
|
+
### 인증 vs 인가
|
|
20
|
+
|
|
21
|
+
- **인증 (Authentication)**: 이 사용자가 누구인가? (로그인/로그아웃)
|
|
22
|
+
- **인가 (Authorization)**: 이 사용자가 무엇을 할 수 있는가? (권한/역할)
|
|
23
|
+
|
|
24
|
+
### 아키텍처 모델
|
|
25
|
+
|
|
26
|
+
| 영역 | 역할 | 예시 |
|
|
27
|
+
|------|------|------|
|
|
28
|
+
| **서버 사이드 (보안)** | 세션 저장/검증, 자격 증명 확인, DB 작업, 토큰 생성 | Server Functions, 미들웨어 |
|
|
29
|
+
| **클라이언트 사이드 (공개)** | 인증 상태 관리, 라우트 보호 UI, 리다이렉트 | beforeLoad, 컴포넌트 |
|
|
30
|
+
| **이소모픽 (양쪽)** | 라우트 로더 인증 체크, 공유 검증 로직 | Loader, 스키마 |
|
|
31
|
+
|
|
32
|
+
</overview>
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
<auth_options>
|
|
37
|
+
|
|
38
|
+
## 인증 옵션 비교
|
|
39
|
+
|
|
40
|
+
### 호스팅 솔루션
|
|
41
|
+
|
|
42
|
+
| 솔루션 | 특징 |
|
|
43
|
+
|--------|------|
|
|
44
|
+
| **[Clerk](https://clerk.dev)** | UI 컴포넌트, 소셜 로그인 20+, MFA, 조직/팀 지원 |
|
|
45
|
+
| **[WorkOS](https://workos.com)** | SSO (SAML/OIDC), Directory Sync, SOC 2/GDPR 준수 |
|
|
46
|
+
|
|
47
|
+
### OSS 솔루션
|
|
48
|
+
|
|
49
|
+
| 솔루션 | 특징 |
|
|
50
|
+
|--------|------|
|
|
51
|
+
| **[Better Auth](https://www.better-auth.com/)** | TypeScript-first, 오픈소스 |
|
|
52
|
+
| **[Auth.js](https://authjs.dev/)** | 80+ OAuth 프로바이더, 커뮤니티 주도 |
|
|
53
|
+
| **[Supabase Auth](https://supabase.com/auth)** | Firebase 대안, 내장 인증 |
|
|
54
|
+
|
|
55
|
+
### DIY 구현
|
|
56
|
+
|
|
57
|
+
- **전체 제어**: 인증 흐름 완전 커스터마이징
|
|
58
|
+
- **벤더 종속 없음**: 인증 로직과 사용자 데이터 소유
|
|
59
|
+
- **비용 제어**: 사용자당 과금 없음
|
|
60
|
+
|
|
61
|
+
</auth_options>
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
<session_management>
|
|
66
|
+
|
|
67
|
+
## 세션 관리 패턴
|
|
68
|
+
|
|
69
|
+
### HTTP-Only 쿠키 (권장)
|
|
70
|
+
|
|
71
|
+
TanStack Start 내장 세션 관리:
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
// utils/session.ts
|
|
75
|
+
import { useSession } from '@tanstack/react-start/server'
|
|
76
|
+
|
|
77
|
+
type SessionData = {
|
|
78
|
+
userId?: string
|
|
79
|
+
email?: string
|
|
80
|
+
role?: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function useAppSession() {
|
|
84
|
+
return useSession<SessionData>({
|
|
85
|
+
name: 'app-session',
|
|
86
|
+
password: process.env.SESSION_SECRET!, // 32자 이상
|
|
87
|
+
cookie: {
|
|
88
|
+
secure: process.env.NODE_ENV === 'production',
|
|
89
|
+
sameSite: 'lax',
|
|
90
|
+
httpOnly: true,
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### 세션 패턴 비교
|
|
97
|
+
|
|
98
|
+
| 패턴 | 장점 | 단점 |
|
|
99
|
+
|------|------|------|
|
|
100
|
+
| **HTTP-Only 쿠키** | 가장 안전, 브라우저 자동 처리, CSRF 보호 | 전통적 웹앱에 적합 |
|
|
101
|
+
| **JWT 토큰** | Stateless, API-first에 적합 | XSS 취약, refresh token 관리 필요 |
|
|
102
|
+
| **서버 사이드 세션** | 즉시 세션 철회, 중앙 제어 | Redis/DB 스토리지 필요 |
|
|
103
|
+
|
|
104
|
+
</session_management>
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
<better_auth_setup>
|
|
109
|
+
|
|
110
|
+
## Better Auth 설정
|
|
111
|
+
|
|
112
|
+
### 설치
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
npm install better-auth
|
|
116
|
+
npm install -D @types/better-auth
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 기본 설정
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
// lib/auth.ts
|
|
123
|
+
import { betterAuth } from 'better-auth'
|
|
124
|
+
import { prismaAdapter } from 'better-auth/adapters/prisma'
|
|
125
|
+
import { prisma } from '@/database/prisma'
|
|
126
|
+
|
|
127
|
+
export const auth = betterAuth({
|
|
128
|
+
database: prismaAdapter(prisma),
|
|
129
|
+
emailAndPassword: {
|
|
130
|
+
enabled: true,
|
|
131
|
+
minPasswordLength: 8,
|
|
132
|
+
},
|
|
133
|
+
session: {
|
|
134
|
+
expiresIn: 60 * 60 * 24 * 7, // 7일
|
|
135
|
+
updateAge: 60 * 60 * 24, // 1일마다 세션 갱신
|
|
136
|
+
cookieCache: {
|
|
137
|
+
enabled: true,
|
|
138
|
+
maxAge: 5 * 60, // 5분
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
socialProviders: {
|
|
142
|
+
google: {
|
|
143
|
+
clientId: process.env.GOOGLE_CLIENT_ID || '',
|
|
144
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// 타입 내보내기
|
|
150
|
+
export type Session = typeof auth.$Infer.Session
|
|
151
|
+
export type User = typeof auth.$Infer.User
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Prisma 마이그레이션
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
npx better-auth-cli migration run
|
|
158
|
+
npx prisma migrate dev --name init
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
</better_auth_setup>
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
<server_functions>
|
|
166
|
+
|
|
167
|
+
## 인증 Server Functions
|
|
168
|
+
|
|
169
|
+
### 로그인 (Login)
|
|
4
170
|
|
|
5
171
|
```typescript
|
|
6
|
-
//
|
|
172
|
+
// functions/auth.ts
|
|
173
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
174
|
+
import { redirect } from '@tanstack/react-router'
|
|
175
|
+
import { z } from 'zod'
|
|
176
|
+
import { auth } from '@/lib/auth'
|
|
177
|
+
|
|
178
|
+
const loginSchema = z.object({
|
|
179
|
+
email: z.string().email('Invalid email address'),
|
|
180
|
+
password: z.string().min(8, 'Password must be at least 8 characters'),
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
export const login = createServerFn({ method: 'POST' })
|
|
184
|
+
.inputValidator(loginSchema)
|
|
185
|
+
.handler(async ({ data, request }): Promise<never> => {
|
|
186
|
+
try {
|
|
187
|
+
const result = await auth.api.signInEmail({
|
|
188
|
+
email: data.email,
|
|
189
|
+
password: data.password,
|
|
190
|
+
headers: request.headers,
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
if (!result.user) {
|
|
194
|
+
throw new Error('Invalid email or password')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
throw redirect({ to: '/dashboard' })
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (error instanceof Response) throw error
|
|
200
|
+
throw new Error('Login failed')
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### 로그아웃 (Logout)
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
export const logout = createServerFn({ method: 'POST' })
|
|
209
|
+
.handler(async ({ request }): Promise<never> => {
|
|
210
|
+
await auth.api.signOut({
|
|
211
|
+
headers: request.headers,
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
throw redirect({ to: '/' })
|
|
215
|
+
})
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### 회원가입 (Register)
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
const registerSchema = z.object({
|
|
222
|
+
email: z.string().email('Invalid email address'),
|
|
223
|
+
password: z.string().min(8, 'Password must be at least 8 characters'),
|
|
224
|
+
name: z.string().min(1, 'Name is required'),
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
export const register = createServerFn({ method: 'POST' })
|
|
228
|
+
.inputValidator(registerSchema)
|
|
229
|
+
.handler(async ({ data, request }): Promise<never> => {
|
|
230
|
+
try {
|
|
231
|
+
const existingUser = await prisma.user.findUnique({
|
|
232
|
+
where: { email: data.email },
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
if (existingUser) {
|
|
236
|
+
throw new Error('Email already registered')
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const result = await auth.api.signUpEmail({
|
|
240
|
+
email: data.email,
|
|
241
|
+
password: data.password,
|
|
242
|
+
name: data.name,
|
|
243
|
+
headers: request.headers,
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
if (!result.user) {
|
|
247
|
+
throw new Error('Registration failed')
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
throw redirect({ to: '/dashboard' })
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (error instanceof Response) throw error
|
|
253
|
+
throw new Error('Registration failed')
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### 현재 사용자 (Get Current User)
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
export const getCurrentUser = createServerFn({ method: 'GET' })
|
|
262
|
+
.handler(async ({ request }): Promise<Session['user'] | null> => {
|
|
263
|
+
const session = await auth.api.getSession({
|
|
264
|
+
headers: request.headers,
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
return session?.user ?? null
|
|
268
|
+
})
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### DIY 인증 (Better Auth 없이)
|
|
272
|
+
|
|
273
|
+
```typescript
|
|
274
|
+
import bcrypt from 'bcryptjs'
|
|
275
|
+
|
|
7
276
|
export const loginFn = createServerFn({ method: 'POST' })
|
|
8
277
|
.inputValidator((data: { email: string; password: string }) => data)
|
|
9
278
|
.handler(async ({ data }) => {
|
|
10
279
|
const user = await authenticateUser(data.email, data.password)
|
|
11
|
-
|
|
280
|
+
|
|
281
|
+
if (!user) {
|
|
282
|
+
return { error: 'Invalid credentials' }
|
|
283
|
+
}
|
|
284
|
+
|
|
12
285
|
const session = await useAppSession()
|
|
13
|
-
await session.update({
|
|
286
|
+
await session.update({
|
|
287
|
+
userId: user.id,
|
|
288
|
+
email: user.email,
|
|
289
|
+
})
|
|
290
|
+
|
|
14
291
|
throw redirect({ to: '/dashboard' })
|
|
15
292
|
})
|
|
16
293
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
294
|
+
async function authenticateUser(email: string, password: string) {
|
|
295
|
+
const user = await getUserByEmail(email)
|
|
296
|
+
if (!user) return null
|
|
297
|
+
|
|
298
|
+
const isValid = await bcrypt.compare(password, user.password)
|
|
299
|
+
return isValid ? user : null
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### 비밀번호 변경 / 재설정
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
const changePasswordSchema = z.object({
|
|
307
|
+
currentPassword: z.string().min(8),
|
|
308
|
+
newPassword: z.string().min(8),
|
|
309
|
+
confirmPassword: z.string().min(8),
|
|
310
|
+
}).refine((data) => data.newPassword === data.confirmPassword, {
|
|
311
|
+
message: 'Passwords do not match',
|
|
312
|
+
path: ['confirmPassword'],
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
export const changePassword = createServerFn({ method: 'POST' })
|
|
316
|
+
.middleware([authMiddleware])
|
|
317
|
+
.inputValidator(changePasswordSchema)
|
|
318
|
+
.handler(async ({ data, context }): Promise<{ success: true }> => {
|
|
319
|
+
await auth.api.changePassword({
|
|
320
|
+
newPassword: data.newPassword,
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
return { success: true }
|
|
23
324
|
})
|
|
24
325
|
|
|
25
|
-
//
|
|
26
|
-
export const
|
|
27
|
-
.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
326
|
+
// 비밀번호 재설정 요청
|
|
327
|
+
export const requestPasswordReset = createServerFn({ method: 'POST' })
|
|
328
|
+
.inputValidator(z.object({ email: z.string().email() }))
|
|
329
|
+
.handler(async ({ data }): Promise<{ sent: true }> => {
|
|
330
|
+
const user = await prisma.user.findUnique({
|
|
331
|
+
where: { email: data.email },
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
if (user) {
|
|
335
|
+
const resetToken = generateResetToken()
|
|
336
|
+
await prisma.passwordReset.create({
|
|
337
|
+
data: {
|
|
338
|
+
userId: user.id,
|
|
339
|
+
token: resetToken,
|
|
340
|
+
expiresAt: new Date(Date.now() + 1000 * 60 * 60),
|
|
341
|
+
},
|
|
342
|
+
})
|
|
343
|
+
await sendPasswordResetEmail(user.email, resetToken)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { sent: true } // 이메일 존재 여부 노출 방지
|
|
31
347
|
})
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
</server_functions>
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
<auth_middleware>
|
|
355
|
+
|
|
356
|
+
## 인증 미들웨어
|
|
357
|
+
|
|
358
|
+
### 기본 인증 미들웨어
|
|
359
|
+
|
|
360
|
+
```typescript
|
|
361
|
+
// middleware/auth.ts
|
|
362
|
+
import { createMiddleware } from '@tanstack/react-start'
|
|
363
|
+
import { redirect } from '@tanstack/react-router'
|
|
364
|
+
import { auth } from '@/lib/auth'
|
|
32
365
|
|
|
33
|
-
// 인증 미들웨어
|
|
34
366
|
export const authMiddleware = createMiddleware({ type: 'function' })
|
|
35
|
-
.server(async ({ next }) => {
|
|
36
|
-
const session = await
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
367
|
+
.server(async ({ next, request }) => {
|
|
368
|
+
const session = await auth.api.getSession({
|
|
369
|
+
headers: request.headers,
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
if (!session?.user) {
|
|
373
|
+
throw redirect({
|
|
374
|
+
to: '/login',
|
|
375
|
+
search: { returnUrl: new URL(request.url).pathname },
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return next({ context: { user: session.user } })
|
|
380
|
+
})
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### 역할 기반 미들웨어
|
|
384
|
+
|
|
385
|
+
```typescript
|
|
386
|
+
// middleware/roles.ts
|
|
387
|
+
|
|
388
|
+
export const adminMiddleware = createMiddleware({ type: 'function' })
|
|
389
|
+
.server(async ({ next, request }) => {
|
|
390
|
+
const session = await auth.api.getSession({
|
|
391
|
+
headers: request.headers,
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
if (session?.user?.role !== 'ADMIN') {
|
|
395
|
+
throw new Error('Forbidden: Admin access only')
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return next({ context: { user: session.user } })
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
export const moderatorMiddleware = createMiddleware({ type: 'function' })
|
|
402
|
+
.server(async ({ next, request }) => {
|
|
403
|
+
const session = await auth.api.getSession({
|
|
404
|
+
headers: request.headers,
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
const allowedRoles = ['ADMIN', 'MODERATOR']
|
|
408
|
+
if (!session?.user || !allowedRoles.includes(session.user.role)) {
|
|
409
|
+
throw new Error('Forbidden: Insufficient permissions')
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return next({ context: { user: session.user } })
|
|
413
|
+
})
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
</auth_middleware>
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
<protected_server_functions>
|
|
421
|
+
|
|
422
|
+
## 보호된 Server Functions
|
|
423
|
+
|
|
424
|
+
### 인증 필수
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
// functions/posts.ts
|
|
428
|
+
|
|
429
|
+
const createPostSchema = z.object({
|
|
430
|
+
title: z.string().min(1).max(200),
|
|
431
|
+
content: z.string().min(1).max(10000),
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
export const createPost = createServerFn({ method: 'POST' })
|
|
435
|
+
.middleware([authMiddleware])
|
|
436
|
+
.inputValidator(createPostSchema)
|
|
437
|
+
.handler(async ({ data, context }): Promise<Post> => {
|
|
438
|
+
return prisma.post.create({
|
|
439
|
+
data: {
|
|
440
|
+
...data,
|
|
441
|
+
authorId: context.user.id,
|
|
442
|
+
published: false,
|
|
443
|
+
},
|
|
444
|
+
})
|
|
40
445
|
})
|
|
41
446
|
|
|
42
|
-
|
|
43
|
-
export const protectedFn = createServerFn({ method: 'GET' })
|
|
447
|
+
export const getMyPosts = createServerFn({ method: 'GET' })
|
|
44
448
|
.middleware([authMiddleware])
|
|
45
|
-
.handler(async ({ context }) =>
|
|
449
|
+
.handler(async ({ context }): Promise<Post[]> => {
|
|
450
|
+
return prisma.post.findMany({
|
|
451
|
+
where: { authorId: context.user.id },
|
|
452
|
+
orderBy: { createdAt: 'desc' },
|
|
453
|
+
})
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
export const deletePost = createServerFn({ method: 'DELETE' })
|
|
457
|
+
.middleware([authMiddleware])
|
|
458
|
+
.inputValidator(z.object({ id: z.string() }))
|
|
459
|
+
.handler(async ({ data, context }): Promise<{ success: true }> => {
|
|
460
|
+
const post = await prisma.post.findUnique({
|
|
461
|
+
where: { id: data.id },
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
if (post?.authorId !== context.user.id && context.user.role !== 'ADMIN') {
|
|
465
|
+
throw new Error('Forbidden: Cannot delete this post')
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
await prisma.post.delete({ where: { id: data.id } })
|
|
469
|
+
return { success: true }
|
|
470
|
+
})
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
### 관리자 전용
|
|
46
474
|
|
|
47
|
-
|
|
475
|
+
```typescript
|
|
476
|
+
export const deleteAnyUser = createServerFn({ method: 'DELETE' })
|
|
477
|
+
.middleware([adminMiddleware])
|
|
478
|
+
.inputValidator(z.object({ id: z.string() }))
|
|
479
|
+
.handler(async ({ data }): Promise<{ success: true }> => {
|
|
480
|
+
await prisma.user.delete({ where: { id: data.id } })
|
|
481
|
+
return { success: true }
|
|
482
|
+
})
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
</protected_server_functions>
|
|
486
|
+
|
|
487
|
+
---
|
|
488
|
+
|
|
489
|
+
<route_protection>
|
|
490
|
+
|
|
491
|
+
## 라우트 보호
|
|
492
|
+
|
|
493
|
+
### beforeLoad로 인증 체크
|
|
494
|
+
|
|
495
|
+
```tsx
|
|
48
496
|
export const Route = createFileRoute('/dashboard')({
|
|
49
|
-
beforeLoad: async () => {
|
|
50
|
-
const user = await
|
|
51
|
-
|
|
497
|
+
beforeLoad: async ({ location }): Promise<{ user: Session['user'] }> => {
|
|
498
|
+
const user = await getCurrentUser()
|
|
499
|
+
|
|
500
|
+
if (!user) {
|
|
501
|
+
throw redirect({
|
|
502
|
+
to: '/login',
|
|
503
|
+
search: { redirect: location.href },
|
|
504
|
+
})
|
|
505
|
+
}
|
|
506
|
+
|
|
52
507
|
return { user }
|
|
53
508
|
},
|
|
54
|
-
component:
|
|
55
|
-
|
|
56
|
-
|
|
509
|
+
component: DashboardPage,
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
const DashboardPage = (): JSX.Element => {
|
|
513
|
+
const { user } = Route.useRouteContext()
|
|
514
|
+
return <h1>Welcome, {user?.name}!</h1>
|
|
515
|
+
}
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
### _authed 패턴 (Layout Route 보호 - 권장)
|
|
519
|
+
|
|
520
|
+
전체 라우트 서브트리를 한번에 보호:
|
|
521
|
+
|
|
522
|
+
```tsx
|
|
523
|
+
// routes/_authed.tsx (pathless layout)
|
|
524
|
+
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
|
525
|
+
import { redirect } from '@tanstack/react-router'
|
|
526
|
+
|
|
527
|
+
export const Route = createFileRoute('/_authed')({
|
|
528
|
+
beforeLoad: async ({ location }): Promise<{ user: Session['user'] }> => {
|
|
529
|
+
const user = await getCurrentUser()
|
|
530
|
+
|
|
531
|
+
if (!user) {
|
|
532
|
+
throw redirect({
|
|
533
|
+
to: '/login',
|
|
534
|
+
search: { redirect: location.href },
|
|
535
|
+
})
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return { user }
|
|
57
539
|
},
|
|
540
|
+
component: AuthedLayout,
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
const AuthedLayout = (): JSX.Element => {
|
|
544
|
+
const { user } = Route.useRouteContext()
|
|
545
|
+
|
|
546
|
+
return (
|
|
547
|
+
<div>
|
|
548
|
+
<header>
|
|
549
|
+
<div>Welcome, {user?.name}!</div>
|
|
550
|
+
<LogoutButton />
|
|
551
|
+
</header>
|
|
552
|
+
<Outlet />
|
|
553
|
+
</div>
|
|
554
|
+
)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// routes/_authed/dashboard.tsx -> /dashboard (자동 보호)
|
|
558
|
+
export const Route = createFileRoute('/_authed/dashboard')({
|
|
559
|
+
component: DashboardPage,
|
|
58
560
|
})
|
|
59
561
|
|
|
60
|
-
//
|
|
562
|
+
// routes/_authed/settings.tsx -> /settings (자동 보호)
|
|
563
|
+
export const Route = createFileRoute('/_authed/settings')({
|
|
564
|
+
component: SettingsPage,
|
|
565
|
+
})
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
### 권한별 라우트
|
|
569
|
+
|
|
570
|
+
```tsx
|
|
571
|
+
export const Route = createFileRoute('/admin')({
|
|
572
|
+
beforeLoad: async ({ context }): Promise<{ user: Session['user'] }> => {
|
|
573
|
+
const user = await getCurrentUser()
|
|
574
|
+
|
|
575
|
+
if (!user || user.role !== 'ADMIN') {
|
|
576
|
+
throw redirect({ to: '/' })
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return { user }
|
|
580
|
+
},
|
|
581
|
+
component: AdminPage,
|
|
582
|
+
})
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
### RBAC (역할 기반 접근 제어)
|
|
586
|
+
|
|
587
|
+
```typescript
|
|
588
|
+
export const roles = {
|
|
589
|
+
USER: 'user',
|
|
590
|
+
ADMIN: 'admin',
|
|
591
|
+
MODERATOR: 'moderator',
|
|
592
|
+
} as const
|
|
593
|
+
|
|
594
|
+
type Role = (typeof roles)[keyof typeof roles]
|
|
595
|
+
|
|
596
|
+
export function hasPermission(userRole: Role, requiredRole: Role): boolean {
|
|
597
|
+
const hierarchy = {
|
|
598
|
+
[roles.USER]: 0,
|
|
599
|
+
[roles.MODERATOR]: 1,
|
|
600
|
+
[roles.ADMIN]: 2,
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return hierarchy[userRole] >= hierarchy[requiredRole]
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// 사용
|
|
607
|
+
export const Route = createFileRoute('/_authed/admin/')({
|
|
608
|
+
beforeLoad: async ({ context }) => {
|
|
609
|
+
if (!hasPermission(context.user.role, roles.ADMIN)) {
|
|
610
|
+
throw redirect({ to: '/unauthorized' })
|
|
611
|
+
}
|
|
612
|
+
},
|
|
613
|
+
})
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
</route_protection>
|
|
617
|
+
|
|
618
|
+
---
|
|
619
|
+
|
|
620
|
+
<login_form>
|
|
621
|
+
|
|
622
|
+
## 로그인 폼 (TanStack Query)
|
|
623
|
+
|
|
624
|
+
```tsx
|
|
625
|
+
// routes/login.tsx
|
|
626
|
+
import { useMutation } from '@tanstack/react-query'
|
|
627
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
628
|
+
import { useState } from 'react'
|
|
629
|
+
|
|
630
|
+
export const Route = createFileRoute('/login')({
|
|
631
|
+
component: LoginPage,
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
const LoginPage = (): JSX.Element => {
|
|
635
|
+
const [formData, setFormData] = useState({
|
|
636
|
+
email: '',
|
|
637
|
+
password: '',
|
|
638
|
+
})
|
|
639
|
+
const [error, setError] = useState<string | null>(null)
|
|
640
|
+
|
|
641
|
+
const mutation = useMutation({
|
|
642
|
+
mutationFn: (data: typeof formData) => login(data),
|
|
643
|
+
onError: (err) => {
|
|
644
|
+
setError((err as Error).message)
|
|
645
|
+
},
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
649
|
+
const { name, value } = e.target
|
|
650
|
+
setFormData((prev) => ({ ...prev, [name]: value }))
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
|
654
|
+
e.preventDefault()
|
|
655
|
+
setError(null)
|
|
656
|
+
mutation.mutate(formData)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
return (
|
|
660
|
+
<div style={{ maxWidth: '400px', margin: '0 auto', padding: '2rem' }}>
|
|
661
|
+
<h1>Login</h1>
|
|
662
|
+
|
|
663
|
+
<form onSubmit={handleSubmit}>
|
|
664
|
+
<div style={{ marginBottom: '1rem' }}>
|
|
665
|
+
<label htmlFor="email">Email:</label>
|
|
666
|
+
<input
|
|
667
|
+
id="email"
|
|
668
|
+
name="email"
|
|
669
|
+
type="email"
|
|
670
|
+
value={formData.email}
|
|
671
|
+
onChange={handleChange}
|
|
672
|
+
disabled={mutation.isPending}
|
|
673
|
+
required
|
|
674
|
+
/>
|
|
675
|
+
</div>
|
|
676
|
+
|
|
677
|
+
<div style={{ marginBottom: '1rem' }}>
|
|
678
|
+
<label htmlFor="password">Password:</label>
|
|
679
|
+
<input
|
|
680
|
+
id="password"
|
|
681
|
+
name="password"
|
|
682
|
+
type="password"
|
|
683
|
+
value={formData.password}
|
|
684
|
+
onChange={handleChange}
|
|
685
|
+
disabled={mutation.isPending}
|
|
686
|
+
required
|
|
687
|
+
/>
|
|
688
|
+
</div>
|
|
689
|
+
|
|
690
|
+
{error && <p style={{ color: 'red' }}>{error}</p>}
|
|
691
|
+
|
|
692
|
+
<button type="submit" disabled={mutation.isPending}>
|
|
693
|
+
{mutation.isPending ? 'Logging in...' : 'Login'}
|
|
694
|
+
</button>
|
|
695
|
+
</form>
|
|
696
|
+
|
|
697
|
+
<p>
|
|
698
|
+
No account? <a href="/register">Register</a>
|
|
699
|
+
</p>
|
|
700
|
+
</div>
|
|
701
|
+
)
|
|
702
|
+
}
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
</login_form>
|
|
706
|
+
|
|
707
|
+
---
|
|
708
|
+
|
|
709
|
+
<register_form>
|
|
710
|
+
|
|
711
|
+
## 회원가입 폼
|
|
712
|
+
|
|
713
|
+
```tsx
|
|
714
|
+
// routes/register.tsx
|
|
715
|
+
import { useMutation } from '@tanstack/react-query'
|
|
716
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
717
|
+
import { useState } from 'react'
|
|
718
|
+
|
|
719
|
+
export const Route = createFileRoute('/register')({
|
|
720
|
+
component: RegisterPage,
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
const RegisterPage = (): JSX.Element => {
|
|
724
|
+
const [formData, setFormData] = useState({
|
|
725
|
+
name: '',
|
|
726
|
+
email: '',
|
|
727
|
+
password: '',
|
|
728
|
+
confirmPassword: '',
|
|
729
|
+
})
|
|
730
|
+
const [error, setError] = useState<string | null>(null)
|
|
731
|
+
|
|
732
|
+
const mutation = useMutation({
|
|
733
|
+
mutationFn: (data) => register(data),
|
|
734
|
+
onError: (err) => {
|
|
735
|
+
setError((err as Error).message)
|
|
736
|
+
},
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
740
|
+
const { name, value } = e.target
|
|
741
|
+
setFormData((prev) => ({ ...prev, [name]: value }))
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
|
745
|
+
e.preventDefault()
|
|
746
|
+
setError(null)
|
|
747
|
+
|
|
748
|
+
if (formData.password !== formData.confirmPassword) {
|
|
749
|
+
setError('Passwords do not match')
|
|
750
|
+
return
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const { confirmPassword, ...registerData } = formData
|
|
754
|
+
mutation.mutate(registerData)
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return (
|
|
758
|
+
<div style={{ maxWidth: '400px', margin: '0 auto', padding: '2rem' }}>
|
|
759
|
+
<h1>Register</h1>
|
|
760
|
+
|
|
761
|
+
<form onSubmit={handleSubmit}>
|
|
762
|
+
<div style={{ marginBottom: '1rem' }}>
|
|
763
|
+
<label htmlFor="name">Name:</label>
|
|
764
|
+
<input id="name" name="name" type="text" value={formData.name} onChange={handleChange} disabled={mutation.isPending} required />
|
|
765
|
+
</div>
|
|
766
|
+
|
|
767
|
+
<div style={{ marginBottom: '1rem' }}>
|
|
768
|
+
<label htmlFor="email">Email:</label>
|
|
769
|
+
<input id="email" name="email" type="email" value={formData.email} onChange={handleChange} disabled={mutation.isPending} required />
|
|
770
|
+
</div>
|
|
771
|
+
|
|
772
|
+
<div style={{ marginBottom: '1rem' }}>
|
|
773
|
+
<label htmlFor="password">Password:</label>
|
|
774
|
+
<input id="password" name="password" type="password" value={formData.password} onChange={handleChange} disabled={mutation.isPending} required />
|
|
775
|
+
</div>
|
|
776
|
+
|
|
777
|
+
<div style={{ marginBottom: '1rem' }}>
|
|
778
|
+
<label htmlFor="confirmPassword">Confirm Password:</label>
|
|
779
|
+
<input id="confirmPassword" name="confirmPassword" type="password" value={formData.confirmPassword} onChange={handleChange} disabled={mutation.isPending} required />
|
|
780
|
+
</div>
|
|
781
|
+
|
|
782
|
+
{error && <p style={{ color: 'red' }}>{error}</p>}
|
|
783
|
+
|
|
784
|
+
<button type="submit" disabled={mutation.isPending}>
|
|
785
|
+
{mutation.isPending ? 'Registering...' : 'Register'}
|
|
786
|
+
</button>
|
|
787
|
+
</form>
|
|
788
|
+
|
|
789
|
+
<p>
|
|
790
|
+
Already have an account? <a href="/login">Login</a>
|
|
791
|
+
</p>
|
|
792
|
+
</div>
|
|
793
|
+
)
|
|
794
|
+
}
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
</register_form>
|
|
798
|
+
|
|
799
|
+
---
|
|
800
|
+
|
|
801
|
+
<session_context>
|
|
802
|
+
|
|
803
|
+
## Session Context 제공
|
|
804
|
+
|
|
805
|
+
```tsx
|
|
806
|
+
// lib/session-context.tsx
|
|
807
|
+
import { createContext, useContext } from 'react'
|
|
808
|
+
import { Session } from '@/lib/auth'
|
|
809
|
+
|
|
810
|
+
const SessionContext = createContext<{
|
|
811
|
+
user: Session['user'] | null
|
|
812
|
+
isLoading: boolean
|
|
813
|
+
} | null>(null)
|
|
814
|
+
|
|
815
|
+
export const SessionProvider = ({
|
|
816
|
+
children,
|
|
817
|
+
}: {
|
|
818
|
+
children: React.ReactNode
|
|
819
|
+
}): JSX.Element => {
|
|
820
|
+
const { data: user, isLoading } = useQuery({
|
|
821
|
+
queryKey: ['currentUser'],
|
|
822
|
+
queryFn: () => getCurrentUser(),
|
|
823
|
+
staleTime: Infinity,
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
return (
|
|
827
|
+
<SessionContext.Provider value={{ user: user || null, isLoading }}>
|
|
828
|
+
{children}
|
|
829
|
+
</SessionContext.Provider>
|
|
830
|
+
)
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
export const useSession = () => {
|
|
834
|
+
const context = useContext(SessionContext)
|
|
835
|
+
if (!context) {
|
|
836
|
+
throw new Error('useSession must be used within SessionProvider')
|
|
837
|
+
}
|
|
838
|
+
return context
|
|
839
|
+
}
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
### 상태 관리 패턴 비교
|
|
843
|
+
|
|
844
|
+
| 패턴 | 설명 | 적합한 경우 |
|
|
845
|
+
|------|------|-----------|
|
|
846
|
+
| **서버 주도 (권장)** | 매 요청마다 서버에서 인증 상태 확인 | SSR, 최고 보안 |
|
|
847
|
+
| **Context 기반** | 클라이언트에서 인증 상태 관리 | 서드파티 인증 (Auth0, Firebase) |
|
|
848
|
+
| **하이브리드** | 서버 초기 상태 + 클라이언트 업데이트 | 보안과 UX 균형 |
|
|
849
|
+
|
|
850
|
+
</session_context>
|
|
851
|
+
|
|
852
|
+
---
|
|
853
|
+
|
|
854
|
+
<oauth_integration>
|
|
855
|
+
|
|
856
|
+
## OAuth 통합 (Google)
|
|
857
|
+
|
|
858
|
+
```typescript
|
|
859
|
+
// lib/auth.ts
|
|
61
860
|
export const auth = betterAuth({
|
|
62
861
|
database: prismaAdapter(prisma),
|
|
63
862
|
emailAndPassword: { enabled: true },
|
|
863
|
+
socialProviders: {
|
|
864
|
+
google: {
|
|
865
|
+
clientId: process.env.GOOGLE_CLIENT_ID || '',
|
|
866
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
|
|
867
|
+
},
|
|
868
|
+
},
|
|
64
869
|
})
|
|
870
|
+
```
|
|
65
871
|
|
|
66
|
-
|
|
67
|
-
|
|
872
|
+
```tsx
|
|
873
|
+
const GoogleLoginButton = (): JSX.Element => {
|
|
874
|
+
return (
|
|
875
|
+
<form action={auth.api.signInSocial('google')}>
|
|
876
|
+
<button type="submit">Login with Google</button>
|
|
877
|
+
</form>
|
|
878
|
+
)
|
|
879
|
+
}
|
|
68
880
|
```
|
|
69
881
|
|
|
70
|
-
</
|
|
882
|
+
</oauth_integration>
|
|
883
|
+
|
|
884
|
+
---
|
|
885
|
+
|
|
886
|
+
<security_best_practices>
|
|
887
|
+
|
|
888
|
+
## 보안 모범 사례
|
|
889
|
+
|
|
890
|
+
### 비밀번호 보안
|
|
891
|
+
|
|
892
|
+
```typescript
|
|
893
|
+
import bcrypt from 'bcryptjs'
|
|
894
|
+
|
|
895
|
+
const saltRounds = 12 // 보안 요구에 맞게 조정
|
|
896
|
+
const hashedPassword = await bcrypt.hash(password, saltRounds)
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
### 세션 보안
|
|
900
|
+
|
|
901
|
+
```typescript
|
|
902
|
+
export function useAppSession() {
|
|
903
|
+
return useSession({
|
|
904
|
+
name: 'app-session',
|
|
905
|
+
password: process.env.SESSION_SECRET!, // 32자 이상
|
|
906
|
+
cookie: {
|
|
907
|
+
secure: process.env.NODE_ENV === 'production', // HTTPS only
|
|
908
|
+
sameSite: 'lax', // CSRF 보호
|
|
909
|
+
httpOnly: true, // XSS 보호
|
|
910
|
+
maxAge: 7 * 24 * 60 * 60, // 7일
|
|
911
|
+
},
|
|
912
|
+
})
|
|
913
|
+
}
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
### Rate Limiting
|
|
917
|
+
|
|
918
|
+
```typescript
|
|
919
|
+
const loginAttempts = new Map<string, { count: number; resetTime: number }>()
|
|
920
|
+
|
|
921
|
+
export const rateLimitLogin = (ip: string): boolean => {
|
|
922
|
+
const now = Date.now()
|
|
923
|
+
const attempts = loginAttempts.get(ip)
|
|
924
|
+
|
|
925
|
+
if (!attempts || now > attempts.resetTime) {
|
|
926
|
+
loginAttempts.set(ip, { count: 1, resetTime: now + 15 * 60 * 1000 })
|
|
927
|
+
return true
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
if (attempts.count >= 5) {
|
|
931
|
+
return false // 너무 많은 시도
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
attempts.count++
|
|
935
|
+
return true
|
|
936
|
+
}
|
|
937
|
+
```
|
|
938
|
+
|
|
939
|
+
### 입력 검증
|
|
940
|
+
|
|
941
|
+
```typescript
|
|
942
|
+
import { z } from 'zod'
|
|
943
|
+
|
|
944
|
+
const loginSchema = z.object({
|
|
945
|
+
email: z.string().email().max(255),
|
|
946
|
+
password: z.string().min(8).max(100),
|
|
947
|
+
})
|
|
948
|
+
|
|
949
|
+
export const loginFn = createServerFn({ method: 'POST' })
|
|
950
|
+
.inputValidator(loginSchema)
|
|
951
|
+
.handler(async ({ data }) => {
|
|
952
|
+
// data는 검증 완료 상태
|
|
953
|
+
})
|
|
954
|
+
```
|
|
955
|
+
|
|
956
|
+
### 보안 체크리스트
|
|
957
|
+
|
|
958
|
+
| 항목 | 구현 |
|
|
959
|
+
|------|------|
|
|
960
|
+
| HTTPS | 프로덕션에서 필수 |
|
|
961
|
+
| HTTP-Only 쿠키 | 가능한 경우 항상 사용 |
|
|
962
|
+
| 서버 입력 검증 | 모든 입력을 서버에서 검증 |
|
|
963
|
+
| 시크릿 보호 | 서버 전용 함수에서만 사용 |
|
|
964
|
+
| Rate Limiting | 인증 엔드포인트에 적용 |
|
|
965
|
+
| CSRF 보호 | 폼 제출 시 적용 |
|
|
966
|
+
|
|
967
|
+
</security_best_practices>
|
|
968
|
+
|
|
969
|
+
---
|
|
970
|
+
|
|
971
|
+
<best_practices>
|
|
972
|
+
|
|
973
|
+
## 인증 모범 사례
|
|
974
|
+
|
|
975
|
+
| 원칙 | 구현 |
|
|
976
|
+
|------|------|
|
|
977
|
+
| **Server Functions 사용** | 로그인/로그아웃은 Server Function |
|
|
978
|
+
| **미들웨어 분리** | 인증/권한 검증은 미들웨어 |
|
|
979
|
+
| **beforeLoad 사용** | 라우트 접근 전 인증 체크 |
|
|
980
|
+
| **_authed 패턴** | Layout Route로 라우트 서브트리 한번에 보호 |
|
|
981
|
+
| **TanStack Query** | 로그인 폼은 useMutation |
|
|
982
|
+
| **환경변수 보안** | Better Auth secrets는 .env |
|
|
983
|
+
| **에러 처리** | 명확한 에러 메시지 제공 |
|
|
984
|
+
| **세션 갱신** | 주기적 세션 갱신 설정 |
|
|
985
|
+
| **이메일 미노출** | 비밀번호 재설정 시 이메일 존재 여부 노출 방지 |
|
|
986
|
+
|
|
987
|
+
</best_practices>
|
|
988
|
+
|
|
989
|
+
---
|
|
990
|
+
|
|
991
|
+
<version_info>
|
|
992
|
+
|
|
993
|
+
**Version:** TanStack Start/Router v1.159.4 with Better Auth latest
|
|
994
|
+
|
|
995
|
+
**Key Points:**
|
|
996
|
+
- Better Auth 2.x for session management
|
|
997
|
+
- TanStack Start 내장 useSession 지원 (HTTP-Only 쿠키)
|
|
998
|
+
- Server Functions for auth actions
|
|
999
|
+
- Middleware for role-based access
|
|
1000
|
+
- beforeLoad for route protection
|
|
1001
|
+
- _authed Pathless Layout 패턴 (권장)
|
|
1002
|
+
- TanStack Query for form handling
|
|
1003
|
+
- RBAC 역할 계층 패턴
|
|
1004
|
+
- Rate Limiting, CSRF, XSS 보호
|
|
1005
|
+
|
|
1006
|
+
</version_info>
|