social 계정 연동 refresh 기능 추가 .

get_video
hbyang 2026-02-09 10:59:44 +09:00
parent 89ea0c783e
commit 325fb9af69
4 changed files with 175 additions and 11 deletions

View File

@ -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
# ============================================================================= # =============================================================================

View File

@ -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

View File

@ -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] 예상치 못한 에러 - "

View File

@ -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