diff --git a/users/serializers.py b/users/serializers.py index 0fb3b4c..335c1b7 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -5,13 +5,18 @@ from rest_framework.exceptions import ValidationError class RegisterSerializer(serializers.ModelSerializer): password = serializers.CharField(write_only=True, required=False) + has_password = serializers.SerializerMethodField() class Meta: model = CustomUser fields = ("email", "name", "password", "grade", "desc", "phone", "address", "gender", "birth_date", "education", - "social_provider", "profile_image") - read_only_fields = ("social_provider", "profile_image") + "social_provider", "profile_image", "has_password") + read_only_fields = ("social_provider", "profile_image", "has_password") + + def get_has_password(self, obj): + """사용자가 비밀번호를 가지고 있는지 여부""" + return obj.has_usable_password() def validate_email(self, value): if CustomUser.objects.filter(email=value).exists(): diff --git a/users/urls.py b/users/urls.py index 152f737..7fabe73 100644 --- a/users/urls.py +++ b/users/urls.py @@ -1,6 +1,6 @@ from django.urls import path from .views import ( - RegisterView, MeView, CustomTokenObtainPairView, + RegisterView, MeView, ChangePasswordView, CustomTokenObtainPairView, SSHKeyUploadView, SSHKeyInfoView, SSHKeyRetrieveView, UserListView, UserUpdateView, NHNCloudCredentialsView, NHNCloudPasswordView, @@ -25,6 +25,7 @@ urlpatterns = [ path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('verify/', TokenVerifyView.as_view(), name='token_verify'), path('me/', MeView.as_view(), name='me'), + path('me/password/', ChangePasswordView.as_view(), name='change_password'), path("ssh-key/", SSHKeyUploadView.as_view(), name="ssh_key_upload"), path("ssh-key/info/", SSHKeyInfoView.as_view(), name="ssh_key_info"), path("ssh-key/view/", SSHKeyRetrieveView.as_view(), name="ssh_key_retrieve"), diff --git a/users/views.py b/users/views.py index d8b64a6..46fb366 100644 --- a/users/views.py +++ b/users/views.py @@ -81,6 +81,67 @@ class MeView(APIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) +class ChangePasswordView(APIView): + """사용자 본인 비밀번호 변경""" + permission_classes = [IsAuthenticated] + + def post(self, request): + with tracer.start_as_current_span("ChangePasswordView POST") as span: + email, ip, ua = get_request_info(request) + user = request.user + + current_password = request.data.get("current_password") + new_password = request.data.get("new_password") + confirm_password = request.data.get("confirm_password") + + # 필수 필드 확인 + if not current_password or not new_password or not confirm_password: + return Response( + {"error": "현재 비밀번호, 새 비밀번호, 비밀번호 확인은 필수입니다."}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 새 비밀번호 확인 + if new_password != confirm_password: + return Response( + {"error": "새 비밀번호가 일치하지 않습니다."}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 비밀번호 길이 확인 + if len(new_password) < 8: + return Response( + {"error": "비밀번호는 최소 8자 이상이어야 합니다."}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 소셜 로그인 전용 계정인 경우 (비밀번호 없음) + if not user.has_usable_password(): + # 현재 비밀번호 없이 새 비밀번호 설정 가능 + user.set_password(new_password) + user.save() + logger.info(f"[PASSWORD SET] user={email} | status=success | IP={ip} | UA={ua}") + span.add_event("Password set for social user", attributes={"email": email}) + return Response({"message": "비밀번호가 설정되었습니다."}) + + # 현재 비밀번호 확인 + if not user.check_password(current_password): + logger.warning(f"[PASSWORD CHANGE] user={email} | status=fail | reason=wrong_password | IP={ip} | UA={ua}") + return Response( + {"error": "현재 비밀번호가 일치하지 않습니다."}, + status=status.HTTP_401_UNAUTHORIZED + ) + + # 새 비밀번호 설정 + user.set_password(new_password) + user.save() + + logger.info(f"[PASSWORD CHANGE] user={email} | status=success | IP={ip} | UA={ua}") + span.add_event("Password changed", attributes={"email": email}) + + return Response({"message": "비밀번호가 변경되었습니다."}) + + class CustomTokenObtainPairView(TokenObtainPairView): serializer_class = CustomTokenObtainPairSerializer @@ -314,6 +375,21 @@ class UserUpdateView(generics.RetrieveUpdateDestroyAPIView): update_fields.append('grade') actions.append(f"grade_changed_to_{grade}") + # 비밀번호 초기화 (관리자 전용) + new_password = request.data.get('new_password') + if new_password is not None: + if len(new_password) < 8: + return Response( + {"error": "비밀번호는 최소 8자 이상이어야 합니다."}, + status=status.HTTP_400_BAD_REQUEST + ) + instance.set_password(new_password) + instance.save() # set_password 후 별도 save 필요 + actions.append("password_reset") + logger.info( + f"[USER PASSWORD RESET] admin={admin_email} | target={target_email} | IP={ip} | UA={ua}" + ) + if update_fields: instance.save(update_fields=update_fields) logger.info( diff --git a/version b/version index 5d60b2e..62b34e3 100644 --- a/version +++ b/version @@ -1 +1 @@ -v0.0.30 +v0.0.31