From 77261679f80829aae542ebe5d42e1cf8ca052115 Mon Sep 17 00:00:00 2001 From: Dongwoo Kang Date: Thu, 22 Jan 2026 23:33:03 +0900 Subject: [PATCH 1/2] =?UTF-8?q?Feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/user/views.py | 37 ++++++++++++++++++++++++++++++++++++- core/utils/cookie.py | 12 ++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/core/user/views.py b/core/user/views.py index 7dc4433..fece8fb 100644 --- a/core/user/views.py +++ b/core/user/views.py @@ -5,8 +5,10 @@ from rest_framework_simplejwt.tokens import RefreshToken from drf_spectacular.utils import extend_schema +from core.models import Study from core.utils.cookie import ( set_secure_cookie, + delete_auth_cookies, ACCESS_TOKEN_LIFETIME, REFRESH_TOKEN_LIFETIME, ) @@ -22,7 +24,7 @@ @extend_schema(tags=["user"]) class UserMeView(APIView): - """현재 로그인한 사용자 정보 조회 API""" + """현재 로그인한 사용자 정보 조회 및 탈퇴 API""" permission_classes = [IsAuthenticated] @@ -35,6 +37,39 @@ def get(self, request): serializer = UserInfoSerializer(request.user) return Response(serializer.data, status=status.HTTP_200_OK) + @extend_schema( + summary="회원 탈퇴", + description="현재 로그인한 사용자의 계정을 삭제합니다. 스터디장인 경우 먼저 스터디장 권한을 위임해야 합니다.", + responses={ + 204: None, + 400: ErrorEnvelopeSerializer, + }, + ) + def delete(self, request): + user = request.user + + # 스터디 오너인 경우 탈퇴 불가 + owned_studies = Study.objects.filter(owner=user) + if owned_studies.exists(): + study_names = list(owned_studies.values_list("name", flat=True)) + return Response( + { + "error_code": "STUDY_OWNER_CANNOT_DELETE", + "message": "스터디장인 스터디가 있어 탈퇴할 수 없습니다. 먼저 스터디장을 위임해주세요.", + "studies": study_names, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # 사용자 삭제 + user.delete() + + # 로그아웃 처리 (쿠키 삭제) + response = Response(status=status.HTTP_204_NO_CONTENT) + delete_auth_cookies(response) + + return response + @extend_schema(tags=["user"]) class UserProfileView(APIView): diff --git a/core/utils/cookie.py b/core/utils/cookie.py index cbd74e0..36ed7ca 100644 --- a/core/utils/cookie.py +++ b/core/utils/cookie.py @@ -35,3 +35,15 @@ def set_secure_cookie(response, key, value, max_age): max_age: 쿠키 만료 시간 (초 단위) """ response.set_cookie(key, value, **_get_secure_cookie_kwargs(max_age)) + + +def delete_auth_cookies(response): + """ + 인증 관련 쿠키를 삭제하는 헬퍼 함수 + + Args: + response: Django Response 객체 + """ + response.delete_cookie("access_token") + response.delete_cookie("refresh_token") + response.delete_cookie("is_registered") From b90fea4c22830a4f3a6cb7cdb97964f977c2d67f Mon Sep 17 00:00:00 2001 From: Dongwoo Kang Date: Fri, 23 Jan 2026 12:29:52 +0900 Subject: [PATCH 2/2] =?UTF-8?q?Feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EB=A1=9C=EC=A7=81=EC=9D=84=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EB=A0=88=EC=9D=B4=EC=96=B4=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 쿠키 이름 상수화 - 스터디장이 회원 탈퇴할 때 발생하는 에러의 리스폰스에 스터디 목록 추가 --- core/auth/authentication.py | 6 ++++-- core/auth/views.py | 31 +++++++++++++++++-------------- core/user/services.py | 36 ++++++++++++++++++++++++++++++++++++ core/user/views.py | 26 +++++++++++++------------- core/utils/cookie.py | 11 ++++++++--- 5 files changed, 78 insertions(+), 32 deletions(-) create mode 100644 core/user/services.py diff --git a/core/auth/authentication.py b/core/auth/authentication.py index 0bb6782..15b6e63 100644 --- a/core/auth/authentication.py +++ b/core/auth/authentication.py @@ -2,10 +2,12 @@ from rest_framework.exceptions import AuthenticationFailed from drf_spectacular.extensions import OpenApiAuthenticationExtension +from core.utils.cookie import ACCESS_TOKEN_COOKIE + class CustomJWTAuthentication(JWTAuthentication): def authenticate(self, request): - raw_token = request.COOKIES.get("access_token") + raw_token = request.COOKIES.get(ACCESS_TOKEN_COOKIE) if raw_token is None: return None @@ -26,5 +28,5 @@ def get_security_definition(self, auto_schema): return { "type": "apiKey", "in": "cookie", - "name": "access_token", + "name": ACCESS_TOKEN_COOKIE, } diff --git a/core/auth/views.py b/core/auth/views.py index 1f2a1e5..1be4e97 100644 --- a/core/auth/views.py +++ b/core/auth/views.py @@ -14,6 +14,9 @@ set_secure_cookie, ACCESS_TOKEN_LIFETIME, REFRESH_TOKEN_LIFETIME, + ACCESS_TOKEN_COOKIE, + REFRESH_TOKEN_COOKIE, + IS_REGISTERED_COOKIE, ) from core.auth.services import SocialLoginService from .serializers import ( @@ -106,19 +109,19 @@ def post(self, request): set_secure_cookie( response, - "access_token", + ACCESS_TOKEN_COOKIE, access_token, max_age=int(ACCESS_TOKEN_LIFETIME.total_seconds()), ) set_secure_cookie( response, - "refresh_token", + REFRESH_TOKEN_COOKIE, refresh_token, max_age=int(REFRESH_TOKEN_LIFETIME.total_seconds()), ) set_secure_cookie( response, - "is_registered", + IS_REGISTERED_COOKIE, str(is_registered).lower(), max_age=int(REFRESH_TOKEN_LIFETIME.total_seconds()), ) @@ -142,7 +145,7 @@ class LogoutView(APIView): }, ) def post(self, request): - refresh_token = request.COOKIES.get("refresh_token") + refresh_token = request.COOKIES.get(REFRESH_TOKEN_COOKIE) if refresh_token: try: @@ -159,9 +162,9 @@ def post(self, request): response = Response(status=status.HTTP_204_NO_CONTENT) - response.delete_cookie("access_token") - response.delete_cookie("refresh_token") - response.delete_cookie("is_registered") + response.delete_cookie(ACCESS_TOKEN_COOKIE) + response.delete_cookie(REFRESH_TOKEN_COOKIE) + response.delete_cookie(IS_REGISTERED_COOKIE) return response @@ -181,7 +184,7 @@ class TokenRefreshView(APIView): }, ) def post(self, request): - refresh_token = request.COOKIES.get("refresh_token") + refresh_token = request.COOKIES.get(REFRESH_TOKEN_COOKIE) if not refresh_token: return Response( @@ -226,19 +229,19 @@ def post(self, request): set_secure_cookie( response, - "access_token", + ACCESS_TOKEN_COOKIE, new_access_token, max_age=int(ACCESS_TOKEN_LIFETIME.total_seconds()), ) set_secure_cookie( response, - "refresh_token", + REFRESH_TOKEN_COOKIE, new_refresh_token, max_age=int(REFRESH_TOKEN_LIFETIME.total_seconds()), ) set_secure_cookie( response, - "is_registered", + IS_REGISTERED_COOKIE, str(is_registered).lower(), max_age=int(REFRESH_TOKEN_LIFETIME.total_seconds()), ) @@ -365,19 +368,19 @@ def post(self, request): # 테스트용 access token 쿠키는 토큰 만료 시간과 동일하게 설정 set_secure_cookie( response, - "access_token", + ACCESS_TOKEN_COOKIE, access_token, max_age=int(test_token_lifetime.total_seconds()), ) set_secure_cookie( response, - "refresh_token", + REFRESH_TOKEN_COOKIE, refresh_token, max_age=int(REFRESH_TOKEN_LIFETIME.total_seconds()), ) set_secure_cookie( response, - "is_registered", + IS_REGISTERED_COOKIE, str(is_registered).lower(), max_age=int(REFRESH_TOKEN_LIFETIME.total_seconds()), ) diff --git a/core/user/services.py b/core/user/services.py new file mode 100644 index 0000000..c2fcc55 --- /dev/null +++ b/core/user/services.py @@ -0,0 +1,36 @@ +from core.models import Study + + +class UserService: + """사용자 관련 비즈니스 로직 서비스""" + + class StudyOwnerCannotDeleteError(Exception): + """스터디장인 경우 탈퇴 불가 예외""" + + def __init__(self, study_names: list[str]): + self.study_names = study_names + super().__init__("스터디장인 스터디가 있어 탈퇴할 수 없습니다.") + + def delete_user(self, user): + """ + 사용자를 삭제합니다. + + 스터디장인 경우 먼저 스터디장 권한을 위임해야 합니다. + + Args: + user: 삭제할 사용자 인스턴스 + + Returns: + None + + Raises: + StudyOwnerCannotDeleteError: 스터디장인 경우 + """ + # 스터디 오너인 경우 탈퇴 불가 + owned_studies = Study.objects.filter(owner=user) + if owned_studies.exists(): + study_names = list(owned_studies.values_list("name", flat=True)) + raise self.StudyOwnerCannotDeleteError(study_names) + + # 사용자 삭제 + user.delete() diff --git a/core/user/views.py b/core/user/views.py index fece8fb..824ac6c 100644 --- a/core/user/views.py +++ b/core/user/views.py @@ -5,12 +5,14 @@ from rest_framework_simplejwt.tokens import RefreshToken from drf_spectacular.utils import extend_schema -from core.models import Study from core.utils.cookie import ( set_secure_cookie, delete_auth_cookies, ACCESS_TOKEN_LIFETIME, REFRESH_TOKEN_LIFETIME, + ACCESS_TOKEN_COOKIE, + REFRESH_TOKEN_COOKIE, + IS_REGISTERED_COOKIE, ) from .serializers import ( UserInfoSerializer, @@ -19,6 +21,7 @@ UsernameValidationSerializer, BojUsernameValidationSerializer, ) +from .services import UserService from core.common.serializers import ErrorEnvelopeSerializer @@ -47,23 +50,20 @@ def get(self, request): ) def delete(self, request): user = request.user + user_service = UserService() - # 스터디 오너인 경우 탈퇴 불가 - owned_studies = Study.objects.filter(owner=user) - if owned_studies.exists(): - study_names = list(owned_studies.values_list("name", flat=True)) + try: + user_service.delete_user(user) + except UserService.StudyOwnerCannotDeleteError as e: + study_list = ", ".join(e.study_names) return Response( { "error_code": "STUDY_OWNER_CANNOT_DELETE", - "message": "스터디장인 스터디가 있어 탈퇴할 수 없습니다. 먼저 스터디장을 위임해주세요.", - "studies": study_names, + "message": f"스터디장인 스터디({study_list})가 있어 탈퇴할 수 없습니다. 먼저 스터디장을 위임해주세요.", }, status=status.HTTP_400_BAD_REQUEST, ) - # 사용자 삭제 - user.delete() - # 로그아웃 처리 (쿠키 삭제) response = Response(status=status.HTTP_204_NO_CONTENT) delete_auth_cookies(response) @@ -110,19 +110,19 @@ def patch(self, request): set_secure_cookie( response, - "access_token", + ACCESS_TOKEN_COOKIE, access_token, max_age=ACCESS_TOKEN_LIFETIME, ) set_secure_cookie( response, - "refresh_token", + REFRESH_TOKEN_COOKIE, refresh_token, max_age=REFRESH_TOKEN_LIFETIME, ) set_secure_cookie( response, - "is_registered", + IS_REGISTERED_COOKIE, "true", max_age=REFRESH_TOKEN_LIFETIME, ) diff --git a/core/utils/cookie.py b/core/utils/cookie.py index 36ed7ca..6308b2d 100644 --- a/core/utils/cookie.py +++ b/core/utils/cookie.py @@ -3,6 +3,11 @@ ACCESS_TOKEN_LIFETIME = settings.SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"] REFRESH_TOKEN_LIFETIME = settings.SIMPLE_JWT["REFRESH_TOKEN_LIFETIME"] +# Cookie names +ACCESS_TOKEN_COOKIE = "access_token" +REFRESH_TOKEN_COOKIE = "refresh_token" +IS_REGISTERED_COOKIE = "is_registered" + def _get_secure_cookie_kwargs(max_age): """ @@ -44,6 +49,6 @@ def delete_auth_cookies(response): Args: response: Django Response 객체 """ - response.delete_cookie("access_token") - response.delete_cookie("refresh_token") - response.delete_cookie("is_registered") + response.delete_cookie(ACCESS_TOKEN_COOKIE) + response.delete_cookie(REFRESH_TOKEN_COOKIE) + response.delete_cookie(IS_REGISTERED_COOKIE)