diff --git a/codes/services.py b/codes/services.py index 7b6693c..b345474 100644 --- a/codes/services.py +++ b/codes/services.py @@ -22,6 +22,10 @@ from users.models import User from projects.models import Project, ProjectTeamList + +from datetime import timedelta +from rest_framework_simplejwt.tokens import AccessToken + # from .schemas import send_sms_post_schema # Swagger나 drf-spectacular 등에 사용되는 데코레이터 INVITE_CHOICE_USE_TYPE ={ @@ -29,6 +33,8 @@ INVITE_CHOICE_USE_TYPE ={ 'h': InviteCodeUseType.HACKATHON } +PASSWORD_RESET_TOKEN_TTL_MINUTES = 5 + class CertificateService: @staticmethod def send(code, identifier): @@ -131,3 +137,15 @@ class ProjectInviteService(InviteService): return ProjectTeamList.objects.create(user=invitee, project=work) +class PasswordResetTokenService: + @staticmethod + def issue_temp_access_token(*, user_id: str, identifier: str, use_type: str) -> str: + token = AccessToken() + token.set_exp(lifetime=timedelta(minutes=PASSWORD_RESET_TOKEN_TTL_MINUTES)) + + token["purpose"] = "password_reset" + token["user_id"] = str(user_id) + token["identifier"] = identifier + token["use_type"] = use_type + + return str(token) \ No newline at end of file diff --git a/codes/views.py b/codes/views.py index 7bcf2d5..3f65090 100644 --- a/codes/views.py +++ b/codes/views.py @@ -56,18 +56,43 @@ class CertificationAPIView(APIView): @transaction.atomic def patch(self, request): use_type = request.query_params.get("type") + purpose = request.query_params.get("purpose") + if use_type not in CERTIFICATE_SERVICE_USE_TYPE: return Response({"message": "Not defined use_type"}, status=status.HTTP_400_BAD_REQUEST) + serv = CERTIFICATE_SERVICE_USE_TYPE[use_type] code = request.data.get('code', None) if not code: return Response({"message": "no code"}, status=status.HTTP_400_BAD_REQUEST) + serializer = CertificateCodeSerializer(data=request.data) - if serializer.is_valid(): - if serv.check_code(use_type, code, serializer.validated_data['identifier']): - return Response({"message": "certificated successfully"}, status=status.HTTP_200_OK) - return Response({"message": "wrong code, please retry"}, status=status.HTTP_400_BAD_REQUEST) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + identifier = serializer.validated_data["identifier"] + + if not serv.check_code(use_type, code, identifier): + return Response({"message": "wrong code or already used code, please retry send code"}, status=status.HTTP_400_BAD_REQUEST) + + if purpose == "password_reset": + user = User.objects.filter(phone=identifier).first() + if not user: + return Response({"message": "user not found"}, status=status.HTTP_404_NOT_FOUND) + + temp_access_token = PasswordResetTokenService.issue_temp_access_token( + user_id=user.id, + identifier=identifier, + use_type=use_type, + ) + + return Response( + {"message": "certificated successfully", "temp_access_token": temp_access_token}, + status=status.HTTP_200_OK + ) + + return Response({"message": "certificated successfully"}, status=status.HTTP_200_OK) class InviteByLinkAPIView(APIView): diff --git a/users/permissions.py b/users/permissions.py index 33a2832..9f08b11 100644 --- a/users/permissions.py +++ b/users/permissions.py @@ -8,4 +8,14 @@ class IsGithubLinked(BasePermission): if token_obj := getattr(user, 'github_token', None): if token := getattr(token_obj, 'access_token', None): return True - return False \ No newline at end of file + return False + +class IsPasswordResetToken(BasePermission): + message = "Password reset token required." + + def has_permission(self, request, view): + token = getattr(request, "auth", None) + if not token: + return False + + return token.get("purpose") == "password_reset" \ No newline at end of file diff --git a/users/urls.py b/users/urls.py index 59b7fc2..ced629a 100644 --- a/users/urls.py +++ b/users/urls.py @@ -24,4 +24,5 @@ urlpatterns = [ path('mypage/my-info/', MyPageMemberInfoAPIView.as_view()), path("github/", include(router.urls)), path("portfolio/", UserInfoAPIView.as_view()), + path("reset-password/", PasswordResetAPIView.as_view()), ] \ No newline at end of file diff --git a/users/views.py b/users/views.py index 47e2eea..f055095 100644 --- a/users/views.py +++ b/users/views.py @@ -40,6 +40,7 @@ from github import Github, GithubException CACHE_TIMEOUT = 60 * 60 PAGE_SIZE = 20 +PASSWORD_RESET_USED_JTI_TTL = 60 * 10 class RefreshAPIView(APIView): permission_classes = [AllowAny] @@ -388,6 +389,48 @@ class LoginAPIView(APIView): else: # id, 비번 둘 중 하나가 틀렸을 때 return Response({"message": "아이디 혹은 비밀번호가 맞지 않습니다."}, status=status.HTTP_400_BAD_REQUEST) + +class PasswordResetAPIView(APIView): + permission_classes = [IsAuthenticated, IsPasswordResetToken] + + # 비밀번호 재설정 + @transaction.atomic + def post(self, request): + new_password = request.data.get("new_password") + + if not new_password: + return Response({"message": "no new_password", "is_new_password": None}) + + token = request.auth + jti = token.get("jti") + user_id = token.get("user_id") + + if not jti or not user_id: + return Response({"message": "invalid token"}, status=status.HTTP_400_BAD_REQUEST) + + # 1회성 방지 + used_key = f"pwreset-used:{jti}" + if cache.get(used_key): + return Response({"message": "token already used"}, status=status.HTTP_400_BAD_REQUEST) + cache.set(used_key, True, timeout=PASSWORD_RESET_USED_JTI_TTL) + + user = get_object_or_404(User, id=user_id) + + user.set_password(new_password) + user.save(update_fields=["password"]) + + # 쿠키에 있는 리프레시 토큰 무효화 + try: + refresh = request.COOKIES.get('refresh') + if refresh: + refresh_token = RefreshToken(refresh) + refresh_token.blacklist() + except TokenError as e: + pass + + return Response({"message": "password updated successfully"}, status=status.HTTP_200_OK) + + class CheckUserFieldDuplicateAPIView(APIView): permission_classes = [AllowAny] # 유저 필드 중복 확인