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 7dc4433..824ac6c 100644 --- a/core/user/views.py +++ b/core/user/views.py @@ -7,8 +7,12 @@ 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, @@ -17,12 +21,13 @@ UsernameValidationSerializer, BojUsernameValidationSerializer, ) +from .services import UserService from core.common.serializers import ErrorEnvelopeSerializer @extend_schema(tags=["user"]) class UserMeView(APIView): - """현재 로그인한 사용자 정보 조회 API""" + """현재 로그인한 사용자 정보 조회 및 탈퇴 API""" permission_classes = [IsAuthenticated] @@ -35,6 +40,36 @@ 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 + user_service = UserService() + + 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": f"스터디장인 스터디({study_list})가 있어 탈퇴할 수 없습니다. 먼저 스터디장을 위임해주세요.", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # 로그아웃 처리 (쿠키 삭제) + response = Response(status=status.HTTP_204_NO_CONTENT) + delete_auth_cookies(response) + + return response + @extend_schema(tags=["user"]) class UserProfileView(APIView): @@ -75,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 cbd74e0..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): """ @@ -35,3 +40,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_COOKIE) + response.delete_cookie(REFRESH_TOKEN_COOKIE) + response.delete_cookie(IS_REGISTERED_COOKIE)