social 계정 연동 refresh 기능 추가 .
parent
89ea0c783e
commit
325fb9af69
|
|
@ -123,6 +123,7 @@ class TokenExpiredError(OAuthException):
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
code="TOKEN_EXPIRED",
|
code="TOKEN_EXPIRED",
|
||||||
)
|
)
|
||||||
|
self.platform = platform
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,10 @@ redis_client = Redis(
|
||||||
from app.social.exceptions import (
|
from app.social.exceptions import (
|
||||||
InvalidStateError,
|
InvalidStateError,
|
||||||
OAuthStateExpiredError,
|
OAuthStateExpiredError,
|
||||||
|
OAuthTokenRefreshError,
|
||||||
SocialAccountAlreadyConnectedError,
|
SocialAccountAlreadyConnectedError,
|
||||||
SocialAccountNotFoundError,
|
SocialAccountNotFoundError,
|
||||||
|
TokenExpiredError,
|
||||||
)
|
)
|
||||||
from app.social.oauth import get_oauth_client
|
from app.social.oauth import get_oauth_client
|
||||||
from app.social.schemas import (
|
from app.social.schemas import (
|
||||||
|
|
@ -209,13 +211,15 @@ class SocialAccountService:
|
||||||
self,
|
self,
|
||||||
user_uuid: str,
|
user_uuid: str,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
|
auto_refresh: bool = True,
|
||||||
) -> list[SocialAccountResponse]:
|
) -> list[SocialAccountResponse]:
|
||||||
"""
|
"""
|
||||||
연동된 소셜 계정 목록 조회
|
연동된 소셜 계정 목록 조회 (토큰 자동 갱신 포함)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_uuid: 사용자 UUID
|
user_uuid: 사용자 UUID
|
||||||
session: DB 세션
|
session: DB 세션
|
||||||
|
auto_refresh: 토큰 자동 갱신 여부 (기본 True)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[SocialAccountResponse]: 연동된 계정 목록
|
list[SocialAccountResponse]: 연동된 계정 목록
|
||||||
|
|
@ -233,8 +237,95 @@ class SocialAccountService:
|
||||||
|
|
||||||
logger.debug(f"[SOCIAL] 연동 계정 {len(accounts)}개 조회됨")
|
logger.debug(f"[SOCIAL] 연동 계정 {len(accounts)}개 조회됨")
|
||||||
|
|
||||||
|
# 토큰 자동 갱신
|
||||||
|
if auto_refresh:
|
||||||
|
for account in accounts:
|
||||||
|
await self._try_refresh_token(account, session)
|
||||||
|
|
||||||
return [self._to_response(account) for account in accounts]
|
return [self._to_response(account) for account in accounts]
|
||||||
|
|
||||||
|
async def refresh_all_tokens(
|
||||||
|
self,
|
||||||
|
user_uuid: str,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> dict[str, bool]:
|
||||||
|
"""
|
||||||
|
사용자의 모든 연동 계정 토큰 갱신 (로그인 시 호출)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_uuid: 사용자 UUID
|
||||||
|
session: DB 세션
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, bool]: 플랫폼별 갱신 성공 여부
|
||||||
|
"""
|
||||||
|
logger.info(f"[SOCIAL] 모든 연동 계정 토큰 갱신 시작 - user_uuid: {user_uuid}")
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(SocialAccount).where(
|
||||||
|
SocialAccount.user_uuid == user_uuid,
|
||||||
|
SocialAccount.is_active == True, # noqa: E712
|
||||||
|
SocialAccount.is_deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
accounts = result.scalars().all()
|
||||||
|
|
||||||
|
refresh_results = {}
|
||||||
|
for account in accounts:
|
||||||
|
success = await self._try_refresh_token(account, session)
|
||||||
|
refresh_results[f"{account.platform}_{account.id}"] = success
|
||||||
|
|
||||||
|
logger.info(f"[SOCIAL] 토큰 갱신 완료 - results: {refresh_results}")
|
||||||
|
return refresh_results
|
||||||
|
|
||||||
|
async def _try_refresh_token(
|
||||||
|
self,
|
||||||
|
account: SocialAccount,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
토큰 갱신 시도 (실패해도 예외 발생하지 않음)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account: 소셜 계정
|
||||||
|
session: DB 세션
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 갱신 성공 여부
|
||||||
|
"""
|
||||||
|
# refresh_token이 없으면 갱신 불가
|
||||||
|
if not account.refresh_token:
|
||||||
|
logger.debug(
|
||||||
|
f"[SOCIAL] refresh_token 없음, 갱신 스킵 - account_id: {account.id}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 만료 시간 확인 (만료 1시간 전이면 갱신)
|
||||||
|
should_refresh = False
|
||||||
|
if account.token_expires_at is None:
|
||||||
|
should_refresh = True
|
||||||
|
else:
|
||||||
|
buffer_time = datetime.now() + timedelta(hours=1)
|
||||||
|
if account.token_expires_at <= buffer_time:
|
||||||
|
should_refresh = True
|
||||||
|
|
||||||
|
if not should_refresh:
|
||||||
|
logger.debug(
|
||||||
|
f"[SOCIAL] 토큰 아직 유효, 갱신 스킵 - account_id: {account.id}"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 갱신 시도
|
||||||
|
try:
|
||||||
|
await self._refresh_account_token(account, session)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[SOCIAL] 토큰 갱신 실패 (재연동 필요) - "
|
||||||
|
f"account_id: {account.id}, error: {e}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
async def get_account_by_platform(
|
async def get_account_by_platform(
|
||||||
self,
|
self,
|
||||||
user_uuid: str,
|
user_uuid: str,
|
||||||
|
|
@ -402,14 +493,33 @@ class SocialAccountService:
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: 유효한 access_token
|
str: 유효한 access_token
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TokenExpiredError: 토큰 갱신 실패 시 (재연동 필요)
|
||||||
"""
|
"""
|
||||||
# 만료 시간 확인 (만료 10분 전이면 갱신)
|
# refresh_token이 없으면 갱신 불가 → 재연동 필요
|
||||||
if account.token_expires_at:
|
if not account.refresh_token:
|
||||||
|
logger.warning(
|
||||||
|
f"[SOCIAL] refresh_token 없음, 재연동 필요 - account_id: {account.id}"
|
||||||
|
)
|
||||||
|
raise TokenExpiredError(platform=account.platform)
|
||||||
|
|
||||||
|
# 만료 시간 확인 (만료 10분 전이면 갱신, 만료 시간 없어도 갱신 시도)
|
||||||
|
should_refresh = False
|
||||||
|
if account.token_expires_at is None:
|
||||||
|
logger.info(
|
||||||
|
f"[SOCIAL] token_expires_at 없음, 갱신 시도 - account_id: {account.id}"
|
||||||
|
)
|
||||||
|
should_refresh = True
|
||||||
|
else:
|
||||||
buffer_time = datetime.now() + timedelta(minutes=10)
|
buffer_time = datetime.now() + timedelta(minutes=10)
|
||||||
if account.token_expires_at <= buffer_time:
|
if account.token_expires_at <= buffer_time:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}"
|
f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}"
|
||||||
)
|
)
|
||||||
|
should_refresh = True
|
||||||
|
|
||||||
|
if should_refresh:
|
||||||
return await self._refresh_account_token(account, session)
|
return await self._refresh_account_token(account, session)
|
||||||
|
|
||||||
return account.access_token
|
return account.access_token
|
||||||
|
|
@ -428,17 +538,33 @@ class SocialAccountService:
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: 새 access_token
|
str: 새 access_token
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TokenExpiredError: 갱신 실패 시 (재연동 필요)
|
||||||
"""
|
"""
|
||||||
if not account.refresh_token:
|
if not account.refresh_token:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[SOCIAL] refresh_token 없음, 갱신 불가 - account_id: {account.id}"
|
f"[SOCIAL] refresh_token 없음, 재연동 필요 - account_id: {account.id}"
|
||||||
)
|
)
|
||||||
return account.access_token
|
raise TokenExpiredError(platform=account.platform)
|
||||||
|
|
||||||
platform = SocialPlatform(account.platform)
|
platform = SocialPlatform(account.platform)
|
||||||
oauth_client = get_oauth_client(platform)
|
oauth_client = get_oauth_client(platform)
|
||||||
|
|
||||||
|
try:
|
||||||
token_response = await oauth_client.refresh_token(account.refresh_token)
|
token_response = await oauth_client.refresh_token(account.refresh_token)
|
||||||
|
except OAuthTokenRefreshError as e:
|
||||||
|
logger.error(
|
||||||
|
f"[SOCIAL] 토큰 갱신 실패, 재연동 필요 - "
|
||||||
|
f"account_id: {account.id}, error: {e}"
|
||||||
|
)
|
||||||
|
raise TokenExpiredError(platform=account.platform)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[SOCIAL] 토큰 갱신 중 예외 발생, 재연동 필요 - "
|
||||||
|
f"account_id: {account.id}, error: {e}"
|
||||||
|
)
|
||||||
|
raise TokenExpiredError(platform=account.platform)
|
||||||
|
|
||||||
# 토큰 업데이트
|
# 토큰 업데이트
|
||||||
account.access_token = token_response.access_token
|
account.access_token = token_response.access_token
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||||
from config import social_upload_settings
|
from config import social_upload_settings
|
||||||
from app.database.session import BackgroundSessionLocal
|
from app.database.session import BackgroundSessionLocal
|
||||||
from app.social.constants import SocialPlatform, UploadStatus
|
from app.social.constants import SocialPlatform, UploadStatus
|
||||||
from app.social.exceptions import UploadError, UploadQuotaExceededError
|
from app.social.exceptions import TokenExpiredError, UploadError, UploadQuotaExceededError
|
||||||
from app.social.models import SocialUpload
|
from app.social.models import SocialUpload
|
||||||
from app.social.services import social_account_service
|
from app.social.services import social_account_service
|
||||||
from app.social.uploader import get_uploader
|
from app.social.uploader import get_uploader
|
||||||
|
|
@ -352,6 +352,17 @@ async def process_social_upload(upload_id: int) -> None:
|
||||||
error_message="플랫폼 API 일일 할당량이 초과되었습니다. 내일 다시 시도해주세요.",
|
error_message="플랫폼 API 일일 할당량이 초과되었습니다. 내일 다시 시도해주세요.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except TokenExpiredError as e:
|
||||||
|
logger.error(
|
||||||
|
f"[SOCIAL_UPLOAD] 토큰 만료, 재연동 필요 - "
|
||||||
|
f"upload_id: {upload_id}, platform: {e.platform}"
|
||||||
|
)
|
||||||
|
await _update_upload_status(
|
||||||
|
upload_id=upload_id,
|
||||||
|
status=UploadStatus.FAILED,
|
||||||
|
error_message=f"{e.platform} 계정 인증이 만료되었습니다. 계정을 다시 연동해주세요.",
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[SOCIAL_UPLOAD] 예상치 못한 에러 - "
|
f"[SOCIAL_UPLOAD] 예상치 못한 에러 - "
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,12 @@ from app.user.services import auth_service, kakao_client
|
||||||
from app.user.services.jwt import (
|
from app.user.services.jwt import (
|
||||||
create_access_token,
|
create_access_token,
|
||||||
create_refresh_token,
|
create_refresh_token,
|
||||||
|
decode_token,
|
||||||
get_access_token_expire_seconds,
|
get_access_token_expire_seconds,
|
||||||
get_refresh_token_expires_at,
|
get_refresh_token_expires_at,
|
||||||
get_token_hash,
|
get_token_hash,
|
||||||
)
|
)
|
||||||
|
from app.social.services import social_account_service
|
||||||
from app.utils.common import generate_uuid
|
from app.utils.common import generate_uuid
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -140,6 +142,19 @@ async def kakao_callback(
|
||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 로그인 성공 후 연동된 소셜 계정 토큰 자동 갱신
|
||||||
|
try:
|
||||||
|
payload = decode_token(result.access_token)
|
||||||
|
if payload and payload.get("sub"):
|
||||||
|
user_uuid = payload.get("sub")
|
||||||
|
await social_account_service.refresh_all_tokens(
|
||||||
|
user_uuid=user_uuid,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# 토큰 갱신 실패해도 로그인은 성공 처리
|
||||||
|
logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}")
|
||||||
|
|
||||||
# 프론트엔드로 토큰과 함께 리다이렉트
|
# 프론트엔드로 토큰과 함께 리다이렉트
|
||||||
redirect_url = (
|
redirect_url = (
|
||||||
f"{prj_settings.PROJECT_DOMAIN}"
|
f"{prj_settings.PROJECT_DOMAIN}"
|
||||||
|
|
@ -205,9 +220,20 @@ async def kakao_verify(
|
||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
# 로그인 성공 후 연동된 소셜 계정 토큰 자동 갱신
|
||||||
f"[ROUTER] 카카오 인가 코드 검증 완료 - user_id: {result.user.id}, is_new_user: {result.user.is_new_user}"
|
try:
|
||||||
|
payload = decode_token(result.access_token)
|
||||||
|
if payload and payload.get("sub"):
|
||||||
|
user_uuid = payload.get("sub")
|
||||||
|
await social_account_service.refresh_all_tokens(
|
||||||
|
user_uuid=user_uuid,
|
||||||
|
session=session,
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# 토큰 갱신 실패해도 로그인은 성공 처리
|
||||||
|
logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}")
|
||||||
|
|
||||||
|
logger.info(f"[ROUTER] 카카오 인가 코드 검증 완료 - is_new_user: {result.is_new_user}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue