1212 lines
47 KiB
Markdown
1212 lines
47 KiB
Markdown
# JWT 토큰 기반 인증 시스템 설계
|
|
|
|
## 목차
|
|
|
|
1. [개요](#1-개요)
|
|
2. [인증 정책](#2-인증-정책)
|
|
3. [토큰 구조](#3-토큰-구조)
|
|
4. [인증 흐름](#4-인증-흐름)
|
|
5. [토큰 검증](#5-토큰-검증)
|
|
6. [토큰 갱신 (Refresh)](#6-토큰-갱신-refresh)
|
|
7. [보안 고려사항](#7-보안-고려사항)
|
|
8. [에러 처리](#8-에러-처리)
|
|
9. [클라이언트 구현 가이드](#9-클라이언트-구현-가이드)
|
|
10. [API 엔드포인트 정리](#10-api-엔드포인트-정리)
|
|
11. [테스트용 엔드포인트 (DEBUG 모드)](#11-테스트용-엔드포인트-debug-모드)
|
|
|
|
---
|
|
|
|
## 1. 개요
|
|
|
|
### 1.1 인증 방식
|
|
|
|
본 시스템은 **JWT (JSON Web Token)** 기반의 **이중 토큰 방식**을 사용합니다.
|
|
|
|
| 토큰 종류 | 용도 | 유효 기간 | 저장 위치 |
|
|
|----------|------|----------|----------|
|
|
| Access Token | API 요청 인증 | 60분 | 클라이언트 메모리 |
|
|
| Refresh Token | Access Token 갱신 | 7일 | 클라이언트 + DB(해시) |
|
|
|
|
### 1.2 설계 원칙
|
|
|
|
1. **Stateless 인증**: Access Token만으로 인증 가능 (DB 조회 불필요)
|
|
2. **보안 강화**: Refresh Token은 해시로만 DB에 저장
|
|
3. **다중 기기 지원**: 사용자당 여러 Refresh Token 허용
|
|
4. **명시적 로그아웃**: 토큰 폐기(revoke) 기능 제공
|
|
|
|
---
|
|
|
|
## 2. 인증 정책
|
|
|
|
### 2.1 인증 필요 여부 분류
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────────┐
|
|
│ 엔드포인트 인증 정책 │
|
|
├─────────────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ [인증 불필요 - Public] │
|
|
│ ├── POST /api/v1/home/crawling # 네이버 지도 크롤링 │
|
|
│ ├── POST /api/v1/home/autocomplete # 네이버 자동완성 크롤링 │
|
|
│ ├── GET /api/v1/user/auth/kakao/login # 카카오 로그인 URL │
|
|
│ ├── GET /api/v1/user/auth/kakao/callback # 카카오 콜백 │
|
|
│ ├── POST /api/v1/user/auth/kakao/verify # 카카오 코드 검증 │
|
|
│ └── POST /api/v1/user/auth/refresh # 토큰 갱신 │
|
|
│ │
|
|
│ [인증 필수 - Protected] │
|
|
│ ├── /api/v1/home/* (crawling, autocomplete 제외) │
|
|
│ ├── /api/v1/lyric/* │
|
|
│ ├── /api/v1/song/* │
|
|
│ ├── /api/v1/video/* │
|
|
│ └── /api/v1/user/auth/me, logout, logout/all │
|
|
│ │
|
|
│ [관리자 전용 - Admin Only] │
|
|
│ └── /admin/* │
|
|
│ │
|
|
│ [테스트 전용 - DEBUG 모드에서만 활성화] │
|
|
│ ├── POST /api/v1/user/auth/test/create-user # 테스트 사용자 생성 │
|
|
│ └── POST /api/v1/user/auth/test/generate-token # 테스트 토큰 발급 │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### 2.2 의존성 주입 패턴
|
|
|
|
```python
|
|
# 인증 필수 엔드포인트
|
|
@router.get("/protected-endpoint")
|
|
async def protected_endpoint(
|
|
current_user: User = Depends(get_current_user), # 필수 인증
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
pass
|
|
|
|
# 인증 선택적 엔드포인트
|
|
@router.get("/optional-auth-endpoint")
|
|
async def optional_auth_endpoint(
|
|
current_user: User | None = Depends(get_current_user_optional), # 선택적
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
if current_user:
|
|
# 로그인 사용자용 로직
|
|
else:
|
|
# 비로그인 사용자용 로직
|
|
|
|
# 관리자 전용 엔드포인트
|
|
@router.get("/admin-only-endpoint")
|
|
async def admin_only_endpoint(
|
|
current_user: User = Depends(get_current_admin), # 관리자 필수
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
pass
|
|
```
|
|
|
|
---
|
|
|
|
## 3. 토큰 구조
|
|
|
|
### 3.1 Access Token
|
|
|
|
```json
|
|
{
|
|
"header": {
|
|
"alg": "HS256",
|
|
"typ": "JWT"
|
|
},
|
|
"payload": {
|
|
"sub": "01234567-89ab-7cde-8f01-23456789abcd", // user_uuid (36자)
|
|
"exp": 1706500000, // 만료 시간 (Unix timestamp)
|
|
"type": "access" // 토큰 타입 구분
|
|
},
|
|
"signature": "..."
|
|
}
|
|
```
|
|
|
|
### 3.2 Refresh Token
|
|
|
|
```json
|
|
{
|
|
"header": {
|
|
"alg": "HS256",
|
|
"typ": "JWT"
|
|
},
|
|
"payload": {
|
|
"sub": "01234567-89ab-7cde-8f01-23456789abcd", // user_uuid (36자)
|
|
"exp": 1707104800, // 만료 시간 (Unix timestamp, 7일 후)
|
|
"type": "refresh" // 토큰 타입 구분
|
|
},
|
|
"signature": "..."
|
|
}
|
|
```
|
|
|
|
### 3.3 토큰 설정 (.env)
|
|
|
|
```env
|
|
# JWT 토큰 설정
|
|
JWT_SECRET=oa8SBEXdDdbYmGRnIVhpLWQNJjW6yD9kL8N5DMHHCImxgvreXEd1bSxkgtXpDpqW
|
|
JWT_ALGORITHM=HS256
|
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60
|
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
|
|
```
|
|
|
|
---
|
|
|
|
## 4. 인증 흐름
|
|
|
|
### 4.1 로그인 흐름 (카카오 OAuth)
|
|
|
|
```
|
|
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
|
│ Client │ │ Server │ │ Kakao │ │ DB │
|
|
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
|
|
│ │ │ │
|
|
│ 1. GET /auth/kakao/login │ │
|
|
│───────────────>│ │ │
|
|
│ │ │ │
|
|
│ 2. auth_url 반환 │ │
|
|
│<───────────────│ │ │
|
|
│ │ │ │
|
|
│ 3. 카카오 로그인 페이지 이동 │ │
|
|
│────────────────────────────────>│ │
|
|
│ │ │ │
|
|
│ 4. 사용자 로그인/동의 │ │
|
|
│<────────────────────────────────│ │
|
|
│ │ │ │
|
|
│ 5. 인가 코드 (code) 받음 │ │
|
|
│<────────────────────────────────│ │
|
|
│ │ │ │
|
|
│ 6. POST /auth/kakao/verify {code} │
|
|
│───────────────>│ │ │
|
|
│ │ │ │
|
|
│ │ 7. 카카오 토큰 요청 │
|
|
│ │───────────────>│ │
|
|
│ │ │ │
|
|
│ │ 8. access_token 반환 │
|
|
│ │<───────────────│ │
|
|
│ │ │ │
|
|
│ │ 9. 사용자 정보 조회 │
|
|
│ │───────────────>│ │
|
|
│ │ │ │
|
|
│ │ 10. 사용자 정보 반환 │
|
|
│ │<───────────────│ │
|
|
│ │ │ │
|
|
│ │ 11. 사용자 조회/생성 │
|
|
│ │───────────────────────────────>│
|
|
│ │ │ │
|
|
│ │ 12. User 정보 │
|
|
│ │<───────────────────────────────│
|
|
│ │ │ │
|
|
│ │ 13. JWT 토큰 생성 │
|
|
│ │ (Access + Refresh) │
|
|
│ │ │ │
|
|
│ │ 14. Refresh Token 해시 저장 │
|
|
│ │───────────────────────────────>│
|
|
│ │ │ │
|
|
│ 15. LoginResponse 반환 │ │
|
|
│ {access_token, refresh_token, │ │
|
|
│ expires_in, is_new_user} │ │
|
|
│<───────────────│ │ │
|
|
│ │ │ │
|
|
```
|
|
|
|
### 4.2 API 요청 흐름 (인증된 요청)
|
|
|
|
```
|
|
┌──────────┐ ┌──────────┐ ┌──────────┐
|
|
│ Client │ │ Server │ │ DB │
|
|
└────┬─────┘ └────┬─────┘ └────┬─────┘
|
|
│ │ │
|
|
│ 1. API 요청 │ │
|
|
│ Authorization: Bearer <access_token> │ │
|
|
│─────────────────────────────────────>│ │
|
|
│ │ │
|
|
│ 2. JWT 디코딩 & 검증 │
|
|
│ - 서명 검증 │
|
|
│ - 만료 시간 확인 │
|
|
│ - type = "access" 확인 │
|
|
│ │ │
|
|
│ 3. user_uuid 추출 │
|
|
│ │ │
|
|
│ │ 4. 사용자 조회 │
|
|
│ │───────────────>│
|
|
│ │ │
|
|
│ │ 5. User 정보 │
|
|
│ │<───────────────│
|
|
│ │ │
|
|
│ 6. 사용자 상태 확인 │
|
|
│ - is_deleted = False │
|
|
│ - is_active = True │
|
|
│ │ │
|
|
│ 7. API 응답 │ │
|
|
│<─────────────────────────────────────│ │
|
|
│ │ │
|
|
```
|
|
|
|
### 4.3 인증 실패 흐름
|
|
|
|
```
|
|
┌──────────┐ ┌──────────┐
|
|
│ Client │ │ Server │
|
|
└────┬─────┘ └────┬─────┘
|
|
│ │
|
|
│ 토큰 없이 요청 │
|
|
│─────────────────────────────────────>│
|
|
│ │
|
|
│ 401 MissingTokenError │
|
|
│ {"code": "MISSING_TOKEN"} │
|
|
│<─────────────────────────────────────│
|
|
│ │
|
|
│ │
|
|
│ 만료된 토큰으로 요청 │
|
|
│─────────────────────────────────────>│
|
|
│ │
|
|
│ 401 TokenExpiredError │
|
|
│ {"code": "TOKEN_EXPIRED"} │
|
|
│<─────────────────────────────────────│
|
|
│ │
|
|
│ │
|
|
│ 잘못된 토큰으로 요청 │
|
|
│─────────────────────────────────────>│
|
|
│ │
|
|
│ 401 InvalidTokenError │
|
|
│ {"code": "INVALID_TOKEN"} │
|
|
│<─────────────────────────────────────│
|
|
│ │
|
|
```
|
|
|
|
---
|
|
|
|
## 5. 토큰 검증
|
|
|
|
### 5.1 검증 프로세스
|
|
|
|
```python
|
|
async def get_current_user(
|
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> User:
|
|
"""
|
|
인증된 사용자 반환 (필수 인증)
|
|
|
|
검증 단계:
|
|
1. Bearer 토큰 존재 여부 확인
|
|
2. JWT 서명 및 구조 검증
|
|
3. 토큰 만료 시간 확인
|
|
4. 토큰 타입 확인 (type = "access")
|
|
5. 사용자 존재 여부 확인
|
|
6. 사용자 활성화 상태 확인
|
|
"""
|
|
|
|
# 1. 토큰 존재 확인
|
|
if credentials is None:
|
|
raise MissingTokenError()
|
|
|
|
token = credentials.credentials
|
|
|
|
# 2-3. JWT 디코딩 (서명 검증 + 만료 확인 포함)
|
|
payload = decode_token(token)
|
|
if payload is None:
|
|
raise InvalidTokenError()
|
|
|
|
# 4. 토큰 타입 확인
|
|
token_type = payload.get("type")
|
|
if token_type != "access":
|
|
raise InvalidTokenError(detail="Access token이 아닙니다")
|
|
|
|
# 5. 사용자 조회
|
|
user_uuid = payload.get("sub")
|
|
user = await get_user_by_uuid(session, user_uuid)
|
|
|
|
if user is None or user.is_deleted:
|
|
raise UserNotFoundError()
|
|
|
|
# 6. 활성화 상태 확인
|
|
if not user.is_active:
|
|
raise UserInactiveError()
|
|
|
|
return user
|
|
```
|
|
|
|
### 5.2 JWT 디코딩 함수
|
|
|
|
```python
|
|
def decode_token(token: str) -> dict | None:
|
|
"""
|
|
JWT 토큰 디코딩 및 검증
|
|
|
|
검증 항목:
|
|
- 서명 유효성 (JWT_SECRET으로 검증)
|
|
- 만료 시간 (exp 클레임)
|
|
- 토큰 구조
|
|
|
|
Returns:
|
|
dict: 디코딩된 페이로드
|
|
None: 검증 실패 시
|
|
"""
|
|
try:
|
|
payload = jwt.decode(
|
|
token,
|
|
jwt_settings.JWT_SECRET,
|
|
algorithms=[jwt_settings.JWT_ALGORITHM],
|
|
)
|
|
return payload
|
|
except jwt.ExpiredSignatureError:
|
|
raise TokenExpiredError()
|
|
except jwt.InvalidTokenError:
|
|
return None
|
|
```
|
|
|
|
### 5.3 검증 체크리스트
|
|
|
|
| 순서 | 검증 항목 | 실패 시 예외 | HTTP 코드 |
|
|
|-----|----------|-------------|----------|
|
|
| 1 | Bearer 토큰 존재 | MissingTokenError | 401 |
|
|
| 2 | JWT 구조 유효성 | InvalidTokenError | 401 |
|
|
| 3 | JWT 서명 검증 | InvalidTokenError | 401 |
|
|
| 4 | 토큰 만료 시간 | TokenExpiredError | 401 |
|
|
| 5 | 토큰 타입 (access) | InvalidTokenError | 401 |
|
|
| 6 | 사용자 존재 여부 | UserNotFoundError | 404 |
|
|
| 7 | 소프트 삭제 여부 | UserNotFoundError | 404 |
|
|
| 8 | 계정 활성화 상태 | UserInactiveError | 403 |
|
|
|
|
---
|
|
|
|
## 6. 토큰 갱신 (Refresh)
|
|
|
|
### 6.1 갱신 흐름
|
|
|
|
```
|
|
┌──────────┐ ┌──────────┐ ┌──────────┐
|
|
│ Client │ │ Server │ │ DB │
|
|
└────┬─────┘ └────┬─────┘ └────┬─────┘
|
|
│ │ │
|
|
│ 1. POST /auth/refresh │ │
|
|
│ {"refresh_token": "..."} │ │
|
|
│─────────────────────────────────────>│ │
|
|
│ │ │
|
|
│ 2. JWT 디코딩 & 검증 │
|
|
│ - 서명 검증 │
|
|
│ - 만료 시간 확인 │
|
|
│ - type = "refresh" 확인 │
|
|
│ │ │
|
|
│ 3. 토큰 해시 계산 │
|
|
│ token_hash = SHA256(refresh_token)│
|
|
│ │ │
|
|
│ │ 4. DB에서 토큰 │
|
|
│ │ 해시로 조회 │
|
|
│ │───────────────>│
|
|
│ │ │
|
|
│ │ 5. RefreshToken│
|
|
│ │ 레코드 │
|
|
│ │<───────────────│
|
|
│ │ │
|
|
│ 6. 토큰 상태 확인 │
|
|
│ - is_revoked = False │
|
|
│ - expires_at > now │
|
|
│ │ │
|
|
│ 7. 새 Access Token 생성 │
|
|
│ │ │
|
|
│ 8. AccessTokenResponse 반환 │ │
|
|
│ {"access_token": "...", │ │
|
|
│ "token_type": "bearer", │ │
|
|
│ "expires_in": 3600} │ │
|
|
│<─────────────────────────────────────│ │
|
|
│ │ │
|
|
```
|
|
|
|
### 6.2 갱신 API 상세
|
|
|
|
**요청**
|
|
```http
|
|
POST /api/v1/user/auth/refresh
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
|
}
|
|
```
|
|
|
|
**성공 응답 (200 OK)**
|
|
```json
|
|
{
|
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
|
"token_type": "bearer",
|
|
"expires_in": 3600
|
|
}
|
|
```
|
|
|
|
**실패 응답**
|
|
```json
|
|
// 401 - 토큰 만료
|
|
{
|
|
"code": "TOKEN_EXPIRED",
|
|
"message": "리프레시 토큰이 만료되었습니다"
|
|
}
|
|
|
|
// 401 - 토큰 폐기됨
|
|
{
|
|
"code": "TOKEN_REVOKED",
|
|
"message": "취소된 토큰입니다"
|
|
}
|
|
|
|
// 401 - 유효하지 않은 토큰
|
|
{
|
|
"code": "INVALID_TOKEN",
|
|
"message": "유효하지 않은 토큰입니다"
|
|
}
|
|
```
|
|
|
|
### 6.3 갱신 로직 구현
|
|
|
|
```python
|
|
async def refresh_tokens(
|
|
refresh_token: str,
|
|
session: AsyncSession,
|
|
) -> AccessTokenResponse:
|
|
"""
|
|
Refresh Token으로 새 Access Token 발급
|
|
|
|
검증 단계:
|
|
1. JWT 디코딩 (서명, 만료 확인)
|
|
2. 토큰 타입 확인 (type = "refresh")
|
|
3. DB에서 토큰 해시로 조회
|
|
4. 토큰 폐기 여부 확인
|
|
5. DB 만료 시간 재확인
|
|
6. 새 Access Token 생성
|
|
"""
|
|
|
|
# 1. JWT 디코딩
|
|
payload = decode_token(refresh_token)
|
|
if payload is None:
|
|
raise InvalidTokenError()
|
|
|
|
# 2. 토큰 타입 확인
|
|
if payload.get("type") != "refresh":
|
|
raise InvalidTokenError(detail="Refresh token이 아닙니다")
|
|
|
|
user_uuid = payload.get("sub")
|
|
|
|
# 3. DB에서 토큰 조회
|
|
token_hash = get_token_hash(refresh_token)
|
|
db_token = await get_refresh_token_by_hash(session, token_hash)
|
|
|
|
if db_token is None:
|
|
raise InvalidTokenError(detail="등록되지 않은 토큰입니다")
|
|
|
|
# 4. 폐기 여부 확인
|
|
if db_token.is_revoked:
|
|
raise TokenRevokedError()
|
|
|
|
# 5. 만료 시간 확인 (DB 기준)
|
|
if db_token.expires_at < datetime.now(UTC):
|
|
raise TokenExpiredError()
|
|
|
|
# 6. 새 Access Token 생성
|
|
new_access_token = create_access_token(user_uuid)
|
|
|
|
return AccessTokenResponse(
|
|
access_token=new_access_token,
|
|
token_type="bearer",
|
|
expires_in=get_access_token_expire_seconds(),
|
|
)
|
|
```
|
|
|
|
### 6.4 Refresh Token Rotation (선택적 강화)
|
|
|
|
보안 강화를 위해 Refresh Token도 함께 갱신하는 방식:
|
|
|
|
```python
|
|
async def refresh_tokens_with_rotation(
|
|
refresh_token: str,
|
|
session: AsyncSession,
|
|
user_agent: str | None = None,
|
|
ip_address: str | None = None,
|
|
) -> LoginResponse:
|
|
"""
|
|
Refresh Token Rotation 방식
|
|
|
|
동작:
|
|
1. 기존 Refresh Token 검증
|
|
2. 기존 토큰 폐기 (is_revoked = True)
|
|
3. 새 Access Token + 새 Refresh Token 발급
|
|
4. 새 Refresh Token을 DB에 저장
|
|
|
|
장점:
|
|
- Refresh Token 탈취 시 빠른 감지 가능
|
|
- 토큰 재사용 공격 방지
|
|
|
|
단점:
|
|
- 네트워크 문제로 클라이언트가 새 토큰을 받지 못하면 로그아웃됨
|
|
- 동시 요청 시 레이스 컨디션 발생 가능
|
|
"""
|
|
|
|
# ... 기존 검증 로직 ...
|
|
|
|
# 기존 토큰 폐기
|
|
await revoke_refresh_token(session, db_token)
|
|
|
|
# 새 토큰 쌍 생성
|
|
new_access_token = create_access_token(user_uuid)
|
|
new_refresh_token = create_refresh_token(user_uuid)
|
|
|
|
# 새 Refresh Token 저장
|
|
await save_refresh_token(
|
|
session=session,
|
|
user_id=db_token.user_id,
|
|
user_uuid=user_uuid,
|
|
refresh_token=new_refresh_token,
|
|
user_agent=user_agent,
|
|
ip_address=ip_address,
|
|
)
|
|
|
|
return LoginResponse(
|
|
access_token=new_access_token,
|
|
refresh_token=new_refresh_token,
|
|
token_type="bearer",
|
|
expires_in=get_access_token_expire_seconds(),
|
|
is_new_user=False,
|
|
)
|
|
```
|
|
|
|
### 6.5 자동 갱신 타이밍
|
|
|
|
클라이언트에서 토큰 갱신을 요청하는 적절한 시점:
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────────┐
|
|
│ Access Token 생명주기 (60분) │
|
|
├─────────────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ 0분 15분 30분 45분 55분 60분 │
|
|
│ ├──────────┼──────────┼──────────┼──────────┼─────────┤ │
|
|
│ │ 생성 │ │ │ │ 갱신 │ 만료 │
|
|
│ │ │ │ │ │ 권장 │ │
|
|
│ │ │ │ │ │ 시점 │ │
|
|
│ │ │
|
|
│ │ [정상 사용 구간] [갱신 구간] │
|
|
│ │ 토큰으로 API 요청 토큰 갱신 요청 │
|
|
│ │ │
|
|
└─────────────────────────────────────────────────────────────────────┘
|
|
|
|
권장 갱신 시점:
|
|
- 만료 5분 전 (expires_in < 300초)
|
|
- 또는 API 요청 시 남은 시간 확인 후 자동 갱신
|
|
```
|
|
|
|
---
|
|
|
|
## 7. 보안 고려사항
|
|
|
|
### 7.1 토큰 저장 보안
|
|
|
|
| 저장 위치 | Access Token | Refresh Token | 권장 여부 |
|
|
|----------|-------------|---------------|----------|
|
|
| localStorage | 가능 | 위험 | X |
|
|
| sessionStorage | 권장 | 가능 | O |
|
|
| httpOnly Cookie | 권장 | 권장 | O |
|
|
| 메모리 (변수) | 권장 | 가능 | O |
|
|
|
|
**권장 방식:**
|
|
```javascript
|
|
// Access Token: 메모리에 저장 (SPA)
|
|
let accessToken = null;
|
|
|
|
// Refresh Token: httpOnly Cookie (서버 설정) 또는 secure storage
|
|
// 현재 구현: 클라이언트에서 관리
|
|
```
|
|
|
|
### 7.2 Refresh Token 보안
|
|
|
|
**DB 저장 방식:**
|
|
```python
|
|
# 원본 저장 X, 해시만 저장
|
|
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
|
|
|
|
# RefreshToken 테이블
|
|
class RefreshToken(Base):
|
|
token_hash: str # SHA-256 해시 (64자)
|
|
is_revoked: bool # 폐기 여부
|
|
expires_at: datetime # 만료 시간
|
|
user_agent: str # 접속 기기 정보
|
|
ip_address: str # 접속 IP
|
|
```
|
|
|
|
### 7.3 토큰 폐기 시나리오
|
|
|
|
| 시나리오 | 처리 방법 |
|
|
|---------|----------|
|
|
| 사용자 로그아웃 | 해당 Refresh Token `is_revoked = True` |
|
|
| 모든 기기 로그아웃 | 사용자의 모든 Refresh Token `is_revoked = True` |
|
|
| 비밀번호 변경 | 모든 Refresh Token 폐기 (미구현) |
|
|
| 의심스러운 활동 감지 | 모든 Refresh Token 폐기 |
|
|
| 계정 비활성화 | `User.is_active = False` (토큰 검증 시 실패) |
|
|
|
|
### 7.4 XSS/CSRF 방지
|
|
|
|
**XSS 방지:**
|
|
- Access Token을 DOM에 노출하지 않음
|
|
- httpOnly Cookie 사용 권장
|
|
- Content Security Policy (CSP) 헤더 설정
|
|
|
|
**CSRF 방지:**
|
|
- Bearer 토큰 사용 (쿠키 자동 전송 아님)
|
|
- SameSite Cookie 속성 설정 (쿠키 사용 시)
|
|
|
|
### 7.5 토큰 갱신 공격 방지
|
|
|
|
```python
|
|
# Rate Limiting (권장 추가 구현)
|
|
@limiter.limit("10/minute")
|
|
async def refresh_endpoint(request: RefreshTokenRequest):
|
|
pass
|
|
|
|
# Refresh Token 재사용 탐지
|
|
async def detect_token_reuse(token_hash: str, session: AsyncSession):
|
|
"""
|
|
이미 폐기된 토큰으로 갱신 시도 시 경고
|
|
→ 토큰 탈취 의심 → 모든 토큰 폐기
|
|
"""
|
|
db_token = await get_refresh_token_by_hash(session, token_hash)
|
|
if db_token and db_token.is_revoked:
|
|
# 보안 경고: 토큰 재사용 시도
|
|
await revoke_all_user_tokens(session, db_token.user_id)
|
|
raise SecurityException("의심스러운 활동이 감지되어 모든 세션이 로그아웃되었습니다")
|
|
```
|
|
|
|
---
|
|
|
|
## 8. 에러 처리
|
|
|
|
### 8.1 인증 관련 예외 클래스
|
|
|
|
```python
|
|
# app/user/exceptions.py
|
|
|
|
class AuthException(Exception):
|
|
"""인증 관련 기본 예외"""
|
|
def __init__(
|
|
self,
|
|
code: str,
|
|
message: str,
|
|
status_code: int = 401,
|
|
):
|
|
self.code = code
|
|
self.message = message
|
|
self.status_code = status_code
|
|
|
|
class MissingTokenError(AuthException):
|
|
def __init__(self):
|
|
super().__init__(
|
|
code="MISSING_TOKEN",
|
|
message="인증 토큰이 필요합니다",
|
|
status_code=401,
|
|
)
|
|
|
|
class InvalidTokenError(AuthException):
|
|
def __init__(self, detail: str = "유효하지 않은 토큰입니다"):
|
|
super().__init__(
|
|
code="INVALID_TOKEN",
|
|
message=detail,
|
|
status_code=401,
|
|
)
|
|
|
|
class TokenExpiredError(AuthException):
|
|
def __init__(self):
|
|
super().__init__(
|
|
code="TOKEN_EXPIRED",
|
|
message="토큰이 만료되었습니다",
|
|
status_code=401,
|
|
)
|
|
|
|
class TokenRevokedError(AuthException):
|
|
def __init__(self):
|
|
super().__init__(
|
|
code="TOKEN_REVOKED",
|
|
message="취소된 토큰입니다",
|
|
status_code=401,
|
|
)
|
|
|
|
class UserNotFoundError(AuthException):
|
|
def __init__(self):
|
|
super().__init__(
|
|
code="USER_NOT_FOUND",
|
|
message="가입되지 않은 사용자 입니다.",
|
|
status_code=404,
|
|
)
|
|
|
|
class UserInactiveError(AuthException):
|
|
def __init__(self):
|
|
super().__init__(
|
|
code="USER_INACTIVE",
|
|
message="활성화 상태가 아닌 사용자 입니다.",
|
|
status_code=403,
|
|
)
|
|
|
|
class AdminRequiredError(AuthException):
|
|
def __init__(self):
|
|
super().__init__(
|
|
code="ADMIN_REQUIRED",
|
|
message="관리자 권한이 필요합니다",
|
|
status_code=403,
|
|
)
|
|
```
|
|
|
|
### 8.2 에러 응답 형식
|
|
|
|
```json
|
|
{
|
|
"code": "TOKEN_EXPIRED",
|
|
"message": "토큰이 만료되었습니다",
|
|
"detail": null
|
|
}
|
|
```
|
|
|
|
### 8.3 전역 예외 핸들러
|
|
|
|
```python
|
|
# app/core/exceptions.py
|
|
|
|
@app.exception_handler(AuthException)
|
|
async def auth_exception_handler(request: Request, exc: AuthException):
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
content={
|
|
"code": exc.code,
|
|
"message": exc.message,
|
|
},
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## 9. 클라이언트 구현 가이드
|
|
|
|
### 9.1 토큰 관리 클래스 (JavaScript/TypeScript)
|
|
|
|
```typescript
|
|
class AuthManager {
|
|
private accessToken: string | null = null;
|
|
private refreshToken: string | null = null;
|
|
private tokenExpiry: number | null = null;
|
|
|
|
// 로그인 후 토큰 저장
|
|
setTokens(loginResponse: LoginResponse) {
|
|
this.accessToken = loginResponse.access_token;
|
|
this.refreshToken = loginResponse.refresh_token;
|
|
this.tokenExpiry = Date.now() + (loginResponse.expires_in * 1000);
|
|
|
|
// Refresh Token은 안전한 저장소에 저장
|
|
localStorage.setItem('refresh_token', this.refreshToken);
|
|
}
|
|
|
|
// Access Token 가져오기 (자동 갱신)
|
|
async getAccessToken(): Promise<string | null> {
|
|
// 만료 5분 전이면 갱신
|
|
if (this.isTokenExpiringSoon()) {
|
|
await this.refreshAccessToken();
|
|
}
|
|
return this.accessToken;
|
|
}
|
|
|
|
// 토큰 만료 임박 확인
|
|
isTokenExpiringSoon(): boolean {
|
|
if (!this.tokenExpiry) return true;
|
|
const fiveMinutes = 5 * 60 * 1000;
|
|
return Date.now() > (this.tokenExpiry - fiveMinutes);
|
|
}
|
|
|
|
// Access Token 갱신
|
|
async refreshAccessToken(): Promise<void> {
|
|
const refreshToken = this.refreshToken || localStorage.getItem('refresh_token');
|
|
if (!refreshToken) {
|
|
throw new Error('No refresh token available');
|
|
}
|
|
|
|
const response = await fetch('/api/v1/user/auth/refresh', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
// 갱신 실패 시 로그아웃 처리
|
|
this.clearTokens();
|
|
throw new Error('Token refresh failed');
|
|
}
|
|
|
|
const data = await response.json();
|
|
this.accessToken = data.access_token;
|
|
this.tokenExpiry = Date.now() + (data.expires_in * 1000);
|
|
}
|
|
|
|
// 토큰 삭제 (로그아웃)
|
|
clearTokens() {
|
|
this.accessToken = null;
|
|
this.refreshToken = null;
|
|
this.tokenExpiry = null;
|
|
localStorage.removeItem('refresh_token');
|
|
}
|
|
}
|
|
|
|
// 싱글톤 인스턴스
|
|
export const authManager = new AuthManager();
|
|
```
|
|
|
|
### 9.2 API 요청 인터셉터 (Axios)
|
|
|
|
```typescript
|
|
import axios from 'axios';
|
|
import { authManager } from './auth';
|
|
|
|
const api = axios.create({
|
|
baseURL: '/api/v1',
|
|
});
|
|
|
|
// 요청 인터셉터: Authorization 헤더 추가
|
|
api.interceptors.request.use(async (config) => {
|
|
const token = await authManager.getAccessToken();
|
|
if (token) {
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
return config;
|
|
});
|
|
|
|
// 응답 인터셉터: 401 에러 처리
|
|
api.interceptors.response.use(
|
|
(response) => response,
|
|
async (error) => {
|
|
if (error.response?.status === 401) {
|
|
const errorCode = error.response.data?.code;
|
|
|
|
if (errorCode === 'TOKEN_EXPIRED') {
|
|
// 토큰 만료: 갱신 시도
|
|
try {
|
|
await authManager.refreshAccessToken();
|
|
// 원래 요청 재시도
|
|
return api.request(error.config);
|
|
} catch (refreshError) {
|
|
// 갱신 실패: 로그인 페이지로 리다이렉트
|
|
authManager.clearTokens();
|
|
window.location.href = '/login';
|
|
}
|
|
} else {
|
|
// 기타 인증 에러: 로그인 페이지로 리다이렉트
|
|
authManager.clearTokens();
|
|
window.location.href = '/login';
|
|
}
|
|
}
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
|
|
export default api;
|
|
```
|
|
|
|
### 9.3 로그인 플로우 구현
|
|
|
|
```typescript
|
|
// 1. 카카오 로그인 URL 가져오기
|
|
async function getKakaoLoginUrl(): Promise<string> {
|
|
const response = await fetch('/api/v1/user/auth/kakao/login');
|
|
const data = await response.json();
|
|
return data.auth_url;
|
|
}
|
|
|
|
// 2. 카카오 로그인 페이지로 이동
|
|
function redirectToKakaoLogin() {
|
|
getKakaoLoginUrl().then(url => {
|
|
window.location.href = url;
|
|
});
|
|
}
|
|
|
|
// 3. 콜백 처리 (카카오에서 리다이렉트 후)
|
|
async function handleKakaoCallback(code: string): Promise<void> {
|
|
const response = await fetch('/api/v1/user/auth/kakao/verify', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ code }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Login failed');
|
|
}
|
|
|
|
const loginResponse = await response.json();
|
|
authManager.setTokens(loginResponse);
|
|
|
|
// 로그인 후 리다이렉트
|
|
if (loginResponse.is_new_user) {
|
|
window.location.href = '/welcome'; // 신규 사용자
|
|
} else {
|
|
window.location.href = '/dashboard'; // 기존 사용자
|
|
}
|
|
}
|
|
|
|
// 4. 로그아웃
|
|
async function logout(): Promise<void> {
|
|
const token = await authManager.getAccessToken();
|
|
const refreshToken = localStorage.getItem('refresh_token');
|
|
|
|
await fetch('/api/v1/user/auth/logout', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
});
|
|
|
|
authManager.clearTokens();
|
|
window.location.href = '/login';
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 10. API 엔드포인트 정리
|
|
|
|
### 10.1 인증 API
|
|
|
|
| 메서드 | 엔드포인트 | 설명 | 인증 필요 | 요청 본문 | 응답 |
|
|
|-------|-----------|------|----------|----------|------|
|
|
| GET | `/auth/kakao/login` | 카카오 로그인 URL | X | - | `{auth_url}` |
|
|
| GET | `/auth/kakao/callback` | 카카오 콜백 | X | code (query) | 리다이렉트 |
|
|
| POST | `/auth/kakao/verify` | 코드 검증 & 로그인 | X | `{code}` | `LoginResponse` |
|
|
| POST | `/auth/refresh` | 토큰 갱신 | X | `{refresh_token}` | `AccessTokenResponse` |
|
|
| POST | `/auth/logout` | 로그아웃 | O | `{refresh_token}` | 204 |
|
|
| POST | `/auth/logout/all` | 모든 기기 로그아웃 | O | - | 204 |
|
|
| GET | `/auth/me` | 현재 사용자 정보 | O | - | `UserResponse` |
|
|
| POST | `/auth/test/create-user` | [DEBUG] 테스트 사용자 생성 | X | `{nickname}` | `TestUserCreateResponse` |
|
|
| POST | `/auth/test/generate-token` | [DEBUG] 테스트 토큰 발급 | X | `{user_uuid}` | `TestTokenResponse` |
|
|
|
|
### 10.2 응답 스키마
|
|
|
|
```typescript
|
|
// LoginResponse
|
|
interface LoginResponse {
|
|
access_token: string;
|
|
refresh_token: string;
|
|
token_type: "bearer";
|
|
expires_in: number; // 초 단위 (3600)
|
|
is_new_user: boolean;
|
|
redirect_url?: string;
|
|
}
|
|
|
|
// AccessTokenResponse
|
|
interface AccessTokenResponse {
|
|
access_token: string;
|
|
token_type: "bearer";
|
|
expires_in: number;
|
|
}
|
|
|
|
// UserResponse
|
|
interface UserResponse {
|
|
id: number;
|
|
kakao_id: number;
|
|
user_uuid: string;
|
|
email?: string;
|
|
nickname?: string;
|
|
profile_image_url?: string;
|
|
is_active: boolean;
|
|
is_admin: boolean;
|
|
role: string;
|
|
last_login_at?: string;
|
|
created_at: string;
|
|
}
|
|
```
|
|
|
|
### 10.3 에러 응답 코드
|
|
|
|
| HTTP 코드 | 에러 코드 | 메시지 | 클라이언트 처리 |
|
|
|----------|----------|------|---------------|
|
|
| 401 | MISSING_TOKEN | 인증 토큰이 필요합니다. | 로그인 페이지 이동 |
|
|
| 401 | INVALID_TOKEN | 유효하지 않은 토큰입니다. | 로그인 페이지 이동 |
|
|
| 401 | TOKEN_EXPIRED | 토큰이 만료되었습니다. 다시 로그인해주세요. | 토큰 갱신 시도 |
|
|
| 401 | TOKEN_REVOKED | 취소된 토큰입니다. 다시 로그인해주세요. | 로그인 페이지 이동 |
|
|
| 403 | USER_INACTIVE | 활성화 상태가 아닌 사용자 입니다. | 안내 메시지 표시 |
|
|
| 403 | ADMIN_REQUIRED | 관리자 권한이 필요합니다. | 접근 거부 메시지 |
|
|
| 404 | USER_NOT_FOUND | 가입되지 않은 사용자 입니다. | 로그인 페이지 이동 |
|
|
|
|
---
|
|
|
|
## 부록: 참고 파일 경로
|
|
|
|
| 파일 | 설명 |
|
|
|-----|------|
|
|
| `app/user/services/jwt.py` | JWT 생성/검증 유틸리티 |
|
|
| `app/user/services/auth.py` | 인증 비즈니스 로직 |
|
|
| `app/user/api/routers/v1/auth.py` | 인증 API 라우터 |
|
|
| `app/user/dependencies/auth.py` | FastAPI 의존성 주입 |
|
|
| `app/user/models.py` | User, RefreshToken 모델 |
|
|
| `app/user/schemas/user_schema.py` | Pydantic 스키마 |
|
|
| `app/user/exceptions.py` | 인증 예외 클래스 |
|
|
| `config.py` | JWT 설정 (jwt_settings) |
|
|
|
|
---
|
|
|
|
## 11. 테스트용 엔드포인트 (DEBUG 모드)
|
|
|
|
### 11.1 개요
|
|
|
|
카카오 로그인 없이 테스트 목적으로 사용자를 생성하고 토큰을 발급받을 수 있는 엔드포인트입니다.
|
|
|
|
**중요:** `DEBUG=True` 환경에서만 동작하며, 프로덕션 환경에서는 403 에러를 반환합니다.
|
|
|
|
### 11.2 테스트 사용자 생성
|
|
|
|
**요청**
|
|
```http
|
|
POST /api/v1/auth/test/create-user
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"nickname": "테스트유저"
|
|
}
|
|
```
|
|
|
|
**응답 (200 OK)**
|
|
```json
|
|
{
|
|
"user_id": 1,
|
|
"user_uuid": "01234567-89ab-7cde-8f01-23456789abcd",
|
|
"nickname": "테스트유저",
|
|
"message": "테스트 사용자가 생성되었습니다."
|
|
}
|
|
```
|
|
|
|
### 11.3 테스트 토큰 발급
|
|
|
|
**요청**
|
|
```http
|
|
POST /api/v1/auth/test/generate-token
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"user_uuid": "01234567-89ab-7cde-8f01-23456789abcd"
|
|
}
|
|
```
|
|
|
|
**응답 (200 OK)**
|
|
```json
|
|
{
|
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
|
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600
|
|
}
|
|
```
|
|
|
|
### 11.4 사용 예시 (cURL)
|
|
|
|
```bash
|
|
# 1. 테스트 사용자 생성
|
|
curl -X POST "http://localhost:8000/api/v1/auth/test/create-user" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"nickname": "테스트유저"}'
|
|
|
|
# 응답에서 user_uuid 확인: "01234567-89ab-7cde-8f01-23456789abcd"
|
|
|
|
# 2. 토큰 발급
|
|
curl -X POST "http://localhost:8000/api/v1/auth/test/generate-token" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"user_uuid": "01234567-89ab-7cde-8f01-23456789abcd"}'
|
|
|
|
# 응답에서 access_token 확인
|
|
|
|
# 3. 인증이 필요한 API 호출
|
|
curl -X GET "http://localhost:8000/api/v1/auth/me" \
|
|
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
|
```
|
|
|
|
### 11.5 테스트 흐름 다이어그램
|
|
|
|
```
|
|
┌──────────────────────────────────────────────────────────────────────┐
|
|
│ 테스트 인증 흐름 (DEBUG 모드) │
|
|
├──────────────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ ┌─────────┐ ┌─────────┐ │
|
|
│ │ Client │ │ Server │ │
|
|
│ └────┬────┘ └────┬────┘ │
|
|
│ │ │ │
|
|
│ │ 1. POST /auth/test/create-user │ │
|
|
│ │ {"nickname": "테스트유저"} │ │
|
|
│ │─────────────────────────────────────────────────>│ │
|
|
│ │ │ │
|
|
│ │ 2. DEBUG 모드 확인 │ │
|
|
│ │ 3. 테스트 User 생성 │ │
|
|
│ │ 4. UUID7 발급 │ │
|
|
│ │ │ │
|
|
│ │ 5. {user_id, user_uuid, nickname, message} │ │
|
|
│ │<─────────────────────────────────────────────────│ │
|
|
│ │ │ │
|
|
│ │ 6. POST /auth/test/generate-token │ │
|
|
│ │ {"user_uuid": "..."} │ │
|
|
│ │─────────────────────────────────────────────────>│ │
|
|
│ │ │ │
|
|
│ │ 7. DEBUG 모드 확인 │ │
|
|
│ │ 8. 사용자 조회 │ │
|
|
│ │ 9. JWT 토큰 생성 │ │
|
|
│ │ 10. RefreshToken │ │
|
|
│ │ DB 저장 │ │
|
|
│ │ │ │
|
|
│ │ 11. {access_token, refresh_token, ...} │ │
|
|
│ │<─────────────────────────────────────────────────│ │
|
|
│ │ │ │
|
|
│ │ 12. 보호된 API 요청 │ │
|
|
│ │ Authorization: Bearer <access_token> │ │
|
|
│ │─────────────────────────────────────────────────>│ │
|
|
│ │ │ │
|
|
│ │ 13. API 응답 │ │
|
|
│ │<─────────────────────────────────────────────────│ │
|
|
│ │
|
|
└──────────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### 11.6 응답 스키마
|
|
|
|
```typescript
|
|
// TestUserCreateRequest
|
|
interface TestUserCreateRequest {
|
|
nickname: string; // 기본값: "테스트유저"
|
|
}
|
|
|
|
// TestUserCreateResponse
|
|
interface TestUserCreateResponse {
|
|
user_id: number;
|
|
user_uuid: string;
|
|
nickname: string;
|
|
message: string;
|
|
}
|
|
|
|
// TestTokenRequest
|
|
interface TestTokenRequest {
|
|
user_uuid: string;
|
|
}
|
|
|
|
// TestTokenResponse
|
|
interface TestTokenResponse {
|
|
access_token: string;
|
|
refresh_token: string;
|
|
token_type: "Bearer";
|
|
expires_in: number;
|
|
}
|
|
```
|
|
|
|
### 11.7 에러 응답
|
|
|
|
| HTTP 코드 | 조건 | 응답 |
|
|
|----------|------|------|
|
|
| 403 | DEBUG=False (프로덕션) | `{"detail": "테스트 엔드포인트는 DEBUG 모드에서만 사용 가능합니다."}` |
|
|
| 404 | 사용자 없음 | `{"detail": "사용자를 찾을 수 없습니다: {user_uuid}"}` |
|
|
| 403 | 비활성화 사용자 | `{"detail": "비활성화된 사용자입니다."}` |
|
|
|
|
### 11.8 보안 고려사항
|
|
|
|
| 항목 | 설명 |
|
|
|------|------|
|
|
| 환경 분리 | `DEBUG=True` 일 때만 엔드포인트 활성화 |
|
|
| 가짜 kakao_id | 9000000000~9999999999 범위의 랜덤 값 사용 (실제 카카오 ID와 충돌 방지) |
|
|
| 로깅 | 모든 테스트 요청/응답에 `[TEST]` 프리픽스로 로깅 |
|
|
| 프로덕션 차단 | `config.py`의 `DEBUG` 설정으로 완전 차단 |
|