Merge branch 'refresh'

리프레쉬 토큰 관련 기능 병합
main
Dohyun Lim 2026-02-12 17:20:31 +09:00
commit c89e510c98
3 changed files with 39 additions and 176 deletions

View File

@ -28,6 +28,7 @@ from app.user.schemas.user_schema import (
KakaoLoginResponse,
LoginResponse,
RefreshTokenRequest,
TokenResponse,
UserResponse,
)
from app.user.services import auth_service, kakao_client
@ -240,19 +241,19 @@ async def kakao_verify(
@router.post(
"/refresh",
response_model=AccessTokenResponse,
summary="토큰 갱신",
description="리프레시 토큰으로 새 액세스 토큰을 발급합니다.",
response_model=TokenResponse,
summary="토큰 갱신 (Refresh Token Rotation)",
description="리프레시 토큰으로 새 액세스 토큰과 새 리프레시 토큰함께 발급합니다. 사용된 기존 리프레시 토큰은 즉시 폐기됩니다.",
)
async def refresh_token(
body: RefreshTokenRequest,
session: AsyncSession = Depends(get_session),
) -> AccessTokenResponse:
) -> TokenResponse:
"""
액세스 토큰 갱신
토큰 갱신 (Refresh Token Rotation)
유효한 리프레시 토큰을 제출하면 액세스 토큰발급합니다.
리프레시 토큰은 변경되지 않습니다.
유효한 리프레시 토큰을 제출하면 액세스 토큰 리프레시 토큰발급합니다.
사용된 기존 리프레시 토큰은 즉시 폐기(revoke)니다.
"""
return await auth_service.refresh_tokens(
refresh_token=body.refresh_token,

View File

@ -84,6 +84,7 @@ from app.user.schemas.user_schema import (
AccessTokenResponse,
KakaoUserInfo,
LoginResponse,
TokenResponse,
)
from app.user.services.jwt import (
create_access_token,
@ -188,22 +189,27 @@ class AuthService:
self,
refresh_token: str,
session: AsyncSession,
) -> AccessTokenResponse:
) -> TokenResponse:
"""
리프레시 토큰으로 액세스 토큰 갱신
리프레시 토큰으로 액세스 토큰 + 리프레시 토큰 갱신 (Refresh Token Rotation)
기존 리프레시 토큰을 폐기하고, 액세스 토큰과 리프레시 토큰을 함께 발급합니다.
사용자가 서비스를 지속 사용하는 세션이 자동 유지됩니다.
Args:
refresh_token: 리프레시 토큰
session: DB 세션
Returns:
AccessTokenResponse: 액세스 토큰
TokenResponse: 액세스 토큰 + 리프레시 토큰
Raises:
InvalidTokenError: 토큰이 유효하지 않은 경우
TokenExpiredError: 토큰이 만료된 경우
TokenRevokedError: 토큰이 폐기된 경우
"""
logger.info("[AUTH] 토큰 갱신 시작 (Refresh Token Rotation)")
# 1. 토큰 디코딩 및 검증
payload = decode_token(refresh_token)
if payload is None:
@ -236,11 +242,30 @@ class AuthService:
if not user.is_active:
raise UserInactiveError()
# 5. 새 액세스 토큰 발급
new_access_token = create_access_token(user.user_uuid)
# 5. 기존 리프레시 토큰 폐기 (ORM 직접 수정 — _revoke_refresh_token_by_hash는 내부 commit이 있어 사용하지 않음)
db_token.is_revoked = True
db_token.revoked_at = now().replace(tzinfo=None)
return AccessTokenResponse(
# 6. 새 토큰 발급
new_access_token = create_access_token(user.user_uuid)
new_refresh_token = create_refresh_token(user.user_uuid)
# 7. 새 리프레시 토큰 DB 저장 (_save_refresh_token은 flush만 수행)
await self._save_refresh_token(
user_id=user.id,
user_uuid=user.user_uuid,
token=new_refresh_token,
session=session,
)
# 8. 폐기 + 저장을 하나의 트랜잭션으로 커밋
await session.commit()
logger.info(f"[AUTH] 토큰 갱신 완료 - user_uuid: {user.user_uuid}")
return TokenResponse(
access_token=new_access_token,
refresh_token=new_refresh_token,
token_type="Bearer",
expires_in=get_access_token_expire_seconds(),
)

View File

@ -1,163 +0,0 @@
# 타임존 검수 보고서
**검수일**: 2026-02-10
**대상**: o2o-castad-backend 전체 프로젝트
**기준**: 서울 타임존(Asia/Seoul, KST +09:00)
---
## 1. 타임존 설정 현황
### 1.1 FastAPI 전역 타임존 (`config.py:15`)
```python
TIMEZONE = ZoneInfo(os.getenv("TIMEZONE", "Asia/Seoul"))
```
- `ZoneInfo`를 사용한 aware datetime 기반
- `.env`로 오버라이드 가능 (기본값: `Asia/Seoul`)
### 1.2 타임존 유틸리티 (`app/utils/timezone.py`)
```python
def now() -> datetime:
return datetime.now(TIMEZONE) # aware datetime (tzinfo=Asia/Seoul)
def today_str(fmt="%Y-%m-%d") -> str:
return datetime.now(TIMEZONE).strftime(fmt)
```
- 모든 모듈에서 `from app.utils.timezone import now` 사용 권장
- 반환값은 **aware datetime** (tzinfo 포함)
### 1.3 데이터베이스 타임존
- MySQL `server_default=func.now()` → DB 서버의 시스템 타임존 사용
- DB 생성 시 서울 타임존으로 설정됨 → `func.now()`는 KST 반환
---
## 2. 검수 결과 요약
| 구분 | 상태 | 비고 |
|------|------|------|
| `datetime.now()` 직접 호출 | ✅ 정상 | 앱 코드에서 bare `datetime.now()` 사용 없음 |
| `datetime.utcnow()` 사용 | ✅ 정상 | 프로젝트 전체에서 사용하지 않음 |
| `app.utils.timezone.now()` 사용 | ✅ 정상 | 필요한 모든 곳에서 사용 중 |
| 모델 `server_default=func.now()` | ✅ 정상 | DB 서버 타임존(서울) 기준 |
| naive/aware datetime 혼합 비교 | ⚠️ 주의 | `now().replace(tzinfo=None)` 패턴으로 처리됨 |
---
## 3. 모듈별 상세 검수
### 3.1 `app/user/services/jwt.py` — ✅ 정상
| 위치 | 코드 | 판정 |
|------|------|------|
| L27 | `now() + timedelta(minutes=...)` | ✅ 토큰 만료시간 계산에 서울 타임존 사용 |
| L52 | `now() + timedelta(days=...)` | ✅ 리프레시 토큰 만료시간 |
| L110 | `now().replace(tzinfo=None) + timedelta(days=...)` | ✅ DB 저장용 naive datetime 변환 |
### 3.2 `app/user/services/auth.py` — ✅ 정상
| 위치 | 코드 | 판정 |
|------|------|------|
| L171 | `user.last_login_at = now().replace(tzinfo=None)` | ✅ DB 저장용 naive 변환 |
| L226 | `db_token.expires_at < now().replace(tzinfo=None)` | ✅ naive datetime끼리 비교 |
| L486 | `revoked_at=now().replace(tzinfo=None)` | ✅ DB 저장용 naive 변환 |
| L511 | `revoked_at=now().replace(tzinfo=None)` | ✅ DB 저장용 naive 변환 |
### 3.3 `app/user/api/routers/v1/auth.py` — ✅ 정상
| 위치 | 코드 | 판정 |
|------|------|------|
| L464 | `user.last_login_at = now().replace(tzinfo=None)` | ✅ 테스트 엔드포인트, DB 저장용 |
### 3.4 `app/social/services.py` — ✅ 정상
| 위치 | 코드 | 판정 |
|------|------|------|
| L308 | `current_time = now().replace(tzinfo=None)` | ✅ DB datetime과 비교용 |
| L506 | `current_time = now().replace(tzinfo=None)` | ✅ 토큰 만료 확인 |
| L577 | `now().replace(tzinfo=None) + timedelta(seconds=...)` | ✅ 토큰 만료시간 DB 저장 |
| L639 | `now().replace(tzinfo=None) + timedelta(seconds=...)` | ✅ 신규 계정 토큰 만료시간 |
| L693 | `now().replace(tzinfo=None) + timedelta(seconds=...)` | ✅ 토큰 업데이트 시 만료시간 |
| L709 | `account.connected_at = now().replace(tzinfo=None)` | ✅ 재연결 시간 DB 저장 |
### 3.5 `app/social/worker/upload_task.py` — ✅ 정상
| 위치 | 코드 | 판정 |
|------|------|------|
| L74 | `upload.uploaded_at = now().replace(tzinfo=None)` | ✅ 업로드 완료시간 DB 저장 |
### 3.6 `app/utils/logger.py` — ✅ 정상
| 위치 | 코드 | 판정 |
|------|------|------|
| L27 | `from app.utils.timezone import today_str` | ✅ 로그 파일명에 서울 기준 날짜 사용 |
| L89 | `today = today_str()` | ✅ `{날짜}_app.log` 파일명 |
---
## 4. 모델 `created_at` / `updated_at` 패턴 검수
모든 모델의 `created_at`, `updated_at` 컬럼은 `server_default=func.now()`를 사용합니다.
| 모델 | 파일 | `created_at` | `updated_at` | 판정 |
|------|------|:---:|:---:|:---:|
| User | `app/user/models.py` | `func.now()` | `func.now()` + `onupdate` | ✅ |
| RefreshToken | `app/user/models.py` | `func.now()` | — | ✅ |
| SocialAccount | `app/user/models.py` | `func.now()` | `func.now()` + `onupdate` | ✅ |
| Project | `app/home/models.py` | `func.now()` | — | ✅ |
| Image | `app/home/models.py` | `func.now()` | — | ✅ |
| Lyric | `app/lyric/models.py` | `func.now()` | — | ✅ |
| Song | `app/song/models.py` | `func.now()` | — | ✅ |
| SongTimestamp | `app/song/models.py` | `func.now()` | — | ✅ |
| Video | `app/video/models.py` | `func.now()` | — | ✅ |
| SNSUploadTask | `app/sns/models.py` | `func.now()` | — | ✅ |
| SocialUpload | `app/social/models.py` | `func.now()` | `func.now()` + `onupdate` | ✅ |
> `func.now()`는 MySQL 서버의 `NOW()` 함수를 호출하므로 DB 서버 타임존(서울)이 적용됩니다.
---
## 5. `now().replace(tzinfo=None)` 패턴 분석
이 프로젝트에서는 **aware datetime → naive datetime** 변환 패턴이 일관되게 사용됩니다:
```python
now().replace(tzinfo=None) # Asia/Seoul aware → naive (값은 KST 유지)
```
**이유**: MySQL의 `DateTime` 타입은 타임존 정보를 저장하지 않으므로(naive datetime), DB에 저장하거나 DB 값과 비교할 때 `tzinfo`를 제거해야 합니다.
**검증**: 이 패턴은 `now()`가 이미 서울 타임존 기준이므로, `.replace(tzinfo=None)` 후에도 **값 자체는 KST 시간**을 유지합니다. DB의 `func.now()`도 KST이므로 비교 시 일관성이 보장됩니다.
| 사용처 | 목적 | 일관성 |
|--------|------|:------:|
| `jwt.py:110` | refresh token 만료시간 DB 저장 | ✅ |
| `auth.py:171` | 마지막 로그인 시간 DB 저장 | ✅ |
| `auth.py:226` | refresh token 만료 여부 비교 | ✅ |
| `auth.py:486,511` | token 폐기 시간 DB 저장 | ✅ |
| `auth.py(router):464` | 테스트 엔드포인트 로그인 시간 | ✅ |
| `social/services.py:308,506` | 토큰 만료 비교 | ✅ |
| `social/services.py:577,639,693` | 토큰 만료시간 DB 저장 | ✅ |
| `social/services.py:709` | 재연결 시간 DB 저장 | ✅ |
| `social/worker/upload_task.py:74` | 업로드 완료시간 DB 저장 | ✅ |
---
## 6. 최종 결론
### ✅ 전체 판정: 정상 (PASS)
프로젝트 전반에 걸쳐 타임존 처리가 **일관되게** 구현되어 있습니다:
1. **bare `datetime.now()` 미사용** — 앱 코드에서 타임존 없는 `datetime.now()` 직접 호출이 없음
2. **`datetime.utcnow()` 미사용** — UTC 기반 시간 생성 없음
3. **`app.utils.timezone.now()` 일관 사용** — 모든 서비스/라우터에서 유틸리티 함수 사용
4. **DB 저장 시 naive 변환 일관**`now().replace(tzinfo=None)` 패턴 통일
5. **모델 기본값 `func.now()` 통일** — DB 서버 타임존(서울) 기준으로 자동 설정
6. **비교 연산 안전** — DB의 naive datetime과 비교 시 항상 naive로 변환 후 비교
### 주의사항 (현재 문제 아님, 향후 참고용)
1. **DB 서버 타임존 변경 주의**: `func.now()`는 DB 서버 타임존에 의존하므로, DB 서버의 타임존이 변경되면 `created_at`/`updated_at` 등의 자동 생성 시간이 영향을 받습니다.
2. **다중 타임존 확장 시**: 현재는 단일 타임존(서울)만 사용하므로 문제없지만, 다국적 서비스 확장 시 UTC 기반 저장 + 표시 시 변환 패턴으로 전환을 고려할 수 있습니다.
3. **`replace(tzinfo=None)` 패턴**: 값은 유지하면서 타임존 정보만 제거하므로 안전하지만, 코드 리뷰 시 의도를 명확히 하기 위해 주석을 유지하는 것이 좋습니다(현재 `social/services.py:307`에 주석 존재).