diff --git a/app/social/api/routers/v1/oauth.py b/app/social/api/routers/v1/oauth.py index 4688de0..3919e6d 100644 --- a/app/social/api/routers/v1/oauth.py +++ b/app/social/api/routers/v1/oauth.py @@ -62,6 +62,7 @@ def _build_redirect_url(is_success: bool, params: dict) -> str: async def start_connect( platform: SocialPlatform, current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), ) -> SocialConnectResponse: """ 소셜 계정 연동 시작 @@ -77,6 +78,7 @@ async def start_connect( return await social_account_service.start_connect( user_uuid=current_user.user_uuid, platform=platform, + session=session, ) diff --git a/app/social/oauth/base.py b/app/social/oauth/base.py index 3472030..f2b457e 100644 --- a/app/social/oauth/base.py +++ b/app/social/oauth/base.py @@ -24,12 +24,13 @@ class BaseOAuthClient(ABC): platform: SocialPlatform @abstractmethod - def get_authorization_url(self, state: str) -> str: + def get_authorization_url(self, state: str, force_consent: bool = False) -> str: """ OAuth 인증 URL 생성 Args: state: CSRF 방지용 state 토큰 + force_consent: True면 동의 화면 강제 표시 (refresh_token 재발급 필요 시) Returns: str: OAuth 인증 페이지 URL diff --git a/app/social/oauth/youtube.py b/app/social/oauth/youtube.py index e2b89e2..92196f4 100644 --- a/app/social/oauth/youtube.py +++ b/app/social/oauth/youtube.py @@ -43,12 +43,13 @@ class YouTubeOAuthClient(BaseOAuthClient): self.client_secret = social_oauth_settings.YOUTUBE_CLIENT_SECRET self.redirect_uri = social_oauth_settings.YOUTUBE_REDIRECT_URI - def get_authorization_url(self, state: str) -> str: + def get_authorization_url(self, state: str, force_consent: bool = False) -> str: """ Google OAuth 인증 URL 생성 Args: state: CSRF 방지용 state 토큰 + force_consent: True면 동의 화면 강제 표시하여 refresh_token 재발급 Returns: str: Google OAuth 인증 페이지 URL @@ -58,12 +59,12 @@ class YouTubeOAuthClient(BaseOAuthClient): "redirect_uri": self.redirect_uri, "response_type": "code", "scope": " ".join(YOUTUBE_SCOPES), - "access_type": "offline", # refresh_token 받기 위해 필요 - "prompt": "select_account", # 계정 선택만 표시 (동의 화면은 최초 1회만) + "access_type": "offline", + "prompt": "consent" if force_consent else "select_account", "state": state, } url = f"{self.AUTHORIZATION_URL}?{urlencode(params)}" - logger.debug(f"[YOUTUBE_OAUTH] 인증 URL 생성: {url[:100]}...") + logger.debug(f"[YOUTUBE_OAUTH] 인증 URL 생성 - force_consent: {force_consent}, url: {url[:100]}...") return url async def exchange_code(self, code: str) -> OAuthTokenResponse: diff --git a/app/social/services/account_service.py b/app/social/services/account_service.py index 0947ad0..0c45a93 100644 --- a/app/social/services/account_service.py +++ b/app/social/services/account_service.py @@ -59,15 +59,18 @@ class SocialAccountService: self, user_uuid: str, platform: SocialPlatform, + session: AsyncSession, ) -> SocialConnectResponse: """ 소셜 계정 연동 시작 OAuth 인증 URL을 생성하고 state 토큰을 저장합니다. + 기존 연동 계정에 refresh_token이 없으면 동의 화면을 강제 표시합니다. Args: user_uuid: 사용자 UUID platform: 연동할 플랫폼 + session: DB 세션 Returns: SocialConnectResponse: OAuth 인증 URL 및 state 토큰 @@ -77,10 +80,19 @@ class SocialAccountService: f"user_uuid: {user_uuid}, platform: {platform.value}" ) - # 1. state 토큰 생성 (CSRF 방지) + # 1. 기존 계정의 refresh_token 존재 여부 확인 + existing_account = await self.get_account_by_platform(user_uuid, platform, session) + force_consent = not (existing_account and existing_account.refresh_token) + logger.debug( + f"[SOCIAL] OAuth prompt 결정 - force_consent: {force_consent}, " + f"has_account: {existing_account is not None}, " + f"has_refresh_token: {bool(existing_account and existing_account.refresh_token)}" + ) + + # 2. state 토큰 생성 (CSRF 방지) state = secrets.token_urlsafe(32) - # 2. state를 Redis에 저장 (user_uuid 포함) + # 3. state를 Redis에 저장 (user_uuid 포함) state_key = f"{self.STATE_KEY_PREFIX}{state}" state_data = { "user_uuid": user_uuid, @@ -89,13 +101,13 @@ class SocialAccountService: await redis_client.setex( state_key, social_oauth_settings.OAUTH_STATE_TTL_SECONDS, - json.dumps(state_data), # JSON으로 직렬화 + json.dumps(state_data), ) logger.debug(f"[SOCIAL] OAuth state 저장 - key: {state_key}") - # 3. OAuth 클라이언트에서 인증 URL 생성 + # 4. OAuth 클라이언트에서 인증 URL 생성 oauth_client = get_oauth_client(platform) - auth_url = oauth_client.get_authorization_url(state) + auth_url = oauth_client.get_authorization_url(state, force_consent=force_consent) logger.info(f"[SOCIAL] OAuth URL 생성 완료 - platform: {platform.value}")