commit
c89e510c98
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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`에 주석 존재).
|
||||
Loading…
Reference in New Issue