From 27d7101f0f9965352f21541d6943389760480bf0 Mon Sep 17 00:00:00 2001 From: icurfer Date: Sun, 18 Jan 2026 19:53:43 +0900 Subject: [PATCH] =?UTF-8?q?v0.0.27=20|=20Google=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Google ID Token 검증 및 로그인/회원가입 기능 - 기존 계정 연동 기능 (비밀번호 확인 후 연동) - 프로필에서 Google 연동/해제 기능 - CustomUser 모델에 social_provider, social_id, profile_image 필드 추가 Co-Authored-By: Claude Opus 4.5 --- auth_prj/settings.py | 3 + requirements.txt | 1 + .../0013_add_social_login_fields.py | 28 ++ users/models.py | 5 + users/serializers.py | 6 +- users/urls.py | 7 + users/views.py | 398 ++++++++++++++++++ version | 2 +- 8 files changed, 447 insertions(+), 3 deletions(-) create mode 100644 users/migrations/0013_add_social_login_fields.py diff --git a/auth_prj/settings.py b/auth_prj/settings.py index 2e4c47b..8e47666 100644 --- a/auth_prj/settings.py +++ b/auth_prj/settings.py @@ -38,6 +38,9 @@ SERVICE_PLATFORM = os.getenv("SERVICE_PLATFORM", "none") TRACE_SERVICE_NAME = os.getenv("TRACE_SERVICE_NAME", "msa-django-auth") TRACE_ENDPOINT = os.getenv("TRACE_ENDPOINT", "none") +# Google 소셜 로그인 설정 +GOOGLE_CLIENT_ID = os.environ.get('GOOGLE_CLIENT_ID', '') + # SECURITY WARNING: don't run with debug turned on in production! DEBUG = int(os.environ.get('DEBUG', 1)) diff --git a/requirements.txt b/requirements.txt index e0bd88c..c9e8c28 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,3 +46,4 @@ uritemplate==4.1.1 urllib3==2.4.0 wrapt==1.17.2 zipp==3.23.0 +google-auth==2.38.0 diff --git a/users/migrations/0013_add_social_login_fields.py b/users/migrations/0013_add_social_login_fields.py new file mode 100644 index 0000000..a4d0c68 --- /dev/null +++ b/users/migrations/0013_add_social_login_fields.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.14 on 2026-01-18 07:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0012_add_kvm_server'), + ] + + operations = [ + migrations.AddField( + model_name='customuser', + name='profile_image', + field=models.URLField(blank=True, max_length=500, null=True, verbose_name='프로필 이미지 URL'), + ), + migrations.AddField( + model_name='customuser', + name='social_id', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='소셜 고유 ID'), + ), + migrations.AddField( + model_name='customuser', + name='social_provider', + field=models.CharField(blank=True, max_length=20, null=True, verbose_name='소셜 로그인 제공자'), + ), + ] diff --git a/users/models.py b/users/models.py index 9d97e2a..da3f008 100644 --- a/users/models.py +++ b/users/models.py @@ -71,6 +71,11 @@ class CustomUser(AbstractBaseUser, PermissionsMixin): encrypted_private_key = models.BinaryField(blank=True, null=True) last_used_at = models.DateTimeField(blank=True, null=True, verbose_name="SSH 키 마지막 사용 시각") + # 🔗 소셜 로그인 필드 + social_provider = models.CharField(max_length=20, blank=True, null=True, verbose_name="소셜 로그인 제공자") # 'google', 'kakao' 등 + social_id = models.CharField(max_length=255, blank=True, null=True, verbose_name="소셜 고유 ID") + profile_image = models.URLField(max_length=500, blank=True, null=True, verbose_name="프로필 이미지 URL") + # ☁️ NHN Cloud 자격증명 필드 nhn_tenant_id = models.CharField(max_length=64, blank=True, null=True, verbose_name="NHN Cloud Tenant ID") nhn_username = models.EmailField(blank=True, null=True, verbose_name="NHN Cloud Username") diff --git a/users/serializers.py b/users/serializers.py index 24f7a38..0fb3b4c 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -4,12 +4,14 @@ from rest_framework_simplejwt.serializers import TokenObtainPairSerializer from rest_framework.exceptions import ValidationError class RegisterSerializer(serializers.ModelSerializer): - password = serializers.CharField(write_only=True) + password = serializers.CharField(write_only=True, required=False) class Meta: model = CustomUser fields = ("email", "name", "password", "grade", "desc", - "phone", "address", "gender", "birth_date", "education") + "phone", "address", "gender", "birth_date", "education", + "social_provider", "profile_image") + read_only_fields = ("social_provider", "profile_image") def validate_email(self, value): if CustomUser.objects.filter(email=value).exists(): diff --git a/users/urls.py b/users/urls.py index 2052256..837a6f5 100644 --- a/users/urls.py +++ b/users/urls.py @@ -10,6 +10,8 @@ from .views import ( # KVM 서버 관리 KVMServerListView, KVMServerDetailView, KVMServerActivateView, KVMServerSSHKeyView, KVMServerSSHKeyUploadView, + # 소셜 로그인 + GoogleLoginView, GoogleLinkWithPasswordView, GoogleLinkView, GoogleUnlinkView, ) from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView from .views_jwks import jwks_view # django-jwks @@ -42,4 +44,9 @@ urlpatterns = [ path('kvm-servers//activate/', KVMServerActivateView.as_view(), name='kvm_server_activate'), path('kvm-servers//ssh-key/', KVMServerSSHKeyView.as_view(), name='kvm_server_ssh_key'), path('kvm-servers//ssh-key/upload/', KVMServerSSHKeyUploadView.as_view(), name='kvm_server_ssh_key_upload'), + # 소셜 로그인 + path('auth/google/', GoogleLoginView.as_view(), name='google_login'), + path('auth/google/link-with-password/', GoogleLinkWithPasswordView.as_view(), name='google_link_with_password'), + path('auth/google/link/', GoogleLinkView.as_view(), name='google_link'), + path('auth/google/unlink/', GoogleUnlinkView.as_view(), name='google_unlink'), ] diff --git a/users/views.py b/users/views.py index ba5692e..fb9cb95 100644 --- a/users/views.py +++ b/users/views.py @@ -1,12 +1,17 @@ # views.py import logging +import secrets from opentelemetry import trace # ✅ OpenTelemetry 트레이서 from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status from rest_framework.permissions import IsAuthenticated, BasePermission from rest_framework_simplejwt.views import TokenObtainPairView +from rest_framework_simplejwt.tokens import RefreshToken from rest_framework import generics +from django.conf import settings +from google.oauth2 import id_token +from google.auth.transport import requests as google_requests from .serializers import RegisterSerializer, CustomTokenObtainPairSerializer, UserListSerializer, KVMServerSerializer from .models import CustomUser, NHNCloudProject, KVMServer @@ -1029,3 +1034,396 @@ class KVMServerSSHKeyUploadView(APIView): ) span.add_event("KVM server SSH key deleted", attributes={"email": email, "server": server.name}) return Response({"message": "SSH 키가 삭제되었습니다."}) + + +# ============================================ +# Google 소셜 로그인 API +# ============================================ + +class GoogleLoginView(APIView): + """ + Google 소셜 로그인 + - Google ID Token 검증 + - 기존 사용자 조회 또는 자동 회원가입 + - 동일 이메일 계정 연동 + - JWT 토큰 발급 + """ + + def post(self, request): + with tracer.start_as_current_span("GoogleLoginView POST") as span: + ip = request.META.get("REMOTE_ADDR", "unknown") + ua = request.META.get("HTTP_USER_AGENT", "unknown") + + credential = request.data.get("credential") + if not credential: + logger.warning(f"[GOOGLE LOGIN] status=fail | reason=missing_credential | IP={ip} | UA={ua}") + return Response( + {"error": "Google credential이 필요합니다."}, + status=status.HTTP_400_BAD_REQUEST + ) + + # GOOGLE_CLIENT_ID 확인 + if not settings.GOOGLE_CLIENT_ID: + logger.error(f"[GOOGLE LOGIN] status=fail | reason=missing_client_id | IP={ip} | UA={ua}") + return Response( + {"error": "서버에 Google Client ID가 설정되지 않았습니다."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + try: + # Google ID Token 검증 + idinfo = id_token.verify_oauth2_token( + credential, + google_requests.Request(), + settings.GOOGLE_CLIENT_ID + ) + + # 토큰 정보 추출 + google_id = idinfo.get("sub") # Google 고유 ID + email = idinfo.get("email") + name = idinfo.get("name", "") + picture = idinfo.get("picture", "") + + if not email: + logger.warning(f"[GOOGLE LOGIN] status=fail | reason=no_email | IP={ip} | UA={ua}") + return Response( + {"error": "Google 계정에서 이메일을 가져올 수 없습니다."}, + status=status.HTTP_400_BAD_REQUEST + ) + + logger.info(f"[GOOGLE LOGIN] email={email} | google_id={google_id} | status=token_verified | IP={ip} | UA={ua}") + + # 1. social_id로 기존 사용자 조회 (Google로 가입한 사용자) + user = CustomUser.objects.filter(social_provider="google", social_id=google_id).first() + + if user: + # 기존 Google 계정 사용자 로그인 + logger.info(f"[GOOGLE LOGIN] user={email} | status=existing_social_user | IP={ip} | UA={ua}") + # 프로필 이미지 업데이트 + if picture and user.profile_image != picture: + user.profile_image = picture + user.save(update_fields=["profile_image"]) + else: + # 2. 이메일로 기존 사용자 조회 + existing_user = CustomUser.objects.filter(email=email).first() + + if existing_user: + # 기존 계정이 일반 가입인 경우 (social_provider가 없음) + if not existing_user.social_provider: + logger.info(f"[GOOGLE LOGIN] user={email} | status=need_password | reason=email_exists_need_link | IP={ip} | UA={ua}") + return Response( + { + "error": "이미 해당 이메일로 가입된 계정이 있습니다.", + "code": "EMAIL_EXISTS_NEED_LINK", + "message": "기존 계정 비밀번호를 입력하면 Google 계정을 연동할 수 있습니다.", + "email": email, + "google_id": google_id, + "google_name": name, + "google_picture": picture, + }, + status=status.HTTP_409_CONFLICT + ) + # 다른 소셜 로그인으로 가입한 경우 (예: kakao) + elif existing_user.social_provider != "google": + logger.warning(f"[GOOGLE LOGIN] user={email} | status=fail | reason=other_social_provider | IP={ip} | UA={ua}") + return Response( + { + "error": f"해당 이메일은 {existing_user.social_provider} 계정으로 가입되어 있습니다.", + "code": "OTHER_SOCIAL_PROVIDER", + "message": f"{existing_user.social_provider} 로그인을 이용해주세요." + }, + status=status.HTTP_409_CONFLICT + ) + # Google social_id가 다른 경우 (동일 이메일, 다른 Google 계정) + else: + logger.warning(f"[GOOGLE LOGIN] user={email} | status=fail | reason=different_google_account | IP={ip} | UA={ua}") + return Response( + { + "error": "다른 Google 계정으로 이미 가입되어 있습니다.", + "code": "DIFFERENT_GOOGLE_ACCOUNT" + }, + status=status.HTTP_409_CONFLICT + ) + else: + # 3. 신규 사용자 자동 회원가입 (기존 계정이 없는 경우) + # 고유한 name 생성 (이메일 앞부분 + 랜덤 문자열) + base_name = name or email.split("@")[0] + unique_name = base_name + counter = 1 + while CustomUser.objects.filter(name=unique_name).exists(): + unique_name = f"{base_name}_{secrets.token_hex(3)}" + counter += 1 + if counter > 10: + unique_name = f"{base_name}_{secrets.token_hex(6)}" + break + + user = CustomUser.objects.create( + email=email, + name=unique_name, + social_provider="google", + social_id=google_id, + profile_image=picture, + is_active=True, # 소셜 로그인은 이메일 인증 불필요 + ) + # 비밀번호 없이 사용 불가하게 설정 + user.set_unusable_password() + user.save() + logger.info(f"[GOOGLE LOGIN] user={email} | status=new_user_created | IP={ip} | UA={ua}") + + # 사용자 활성 상태 확인 + if not user.is_active: + logger.warning(f"[GOOGLE LOGIN] user={email} | status=fail | reason=inactive_user | IP={ip} | UA={ua}") + return Response( + {"error": "비활성화된 계정입니다. 관리자에게 문의하세요."}, + status=status.HTTP_403_FORBIDDEN + ) + + # JWT 토큰 발급 + refresh = RefreshToken.for_user(user) + access = refresh.access_token + + # 기존 로그인 응답과 동일한 형식 + span.add_event("Google login success", attributes={"email": email}) + logger.info(f"[GOOGLE LOGIN] user={email} | status=success | IP={ip} | UA={ua}") + + return Response({ + "access": str(access), + "refresh": str(refresh), + "user": { + "id": user.id, + "email": user.email, + "name": user.name, + "grade": user.grade, + "profile_image": user.profile_image, + "social_provider": user.social_provider, + } + }, status=status.HTTP_200_OK) + + except ValueError as e: + # 토큰 검증 실패 + logger.warning(f"[GOOGLE LOGIN] status=fail | reason=invalid_token | error={str(e)} | IP={ip} | UA={ua}") + span.add_event("Google login failed", attributes={"reason": "invalid_token", "error": str(e)}) + return Response( + {"error": "유효하지 않은 Google 토큰입니다."}, + status=status.HTTP_401_UNAUTHORIZED + ) + except Exception as e: + logger.exception(f"[GOOGLE LOGIN] status=fail | reason=exception | IP={ip} | UA={ua}") + span.add_event("Google login failed", attributes={"reason": "exception", "error": str(e)}) + return Response( + {"error": f"로그인 처리 중 오류가 발생했습니다: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +class GoogleLinkWithPasswordView(APIView): + """ + 비밀번호 확인 후 Google 계정 연동 + - 일반 가입 사용자가 Google 로그인 시도 시 비밀번호 확인 후 연동 + """ + + def post(self, request): + with tracer.start_as_current_span("GoogleLinkWithPasswordView POST") as span: + ip = request.META.get("REMOTE_ADDR", "unknown") + ua = request.META.get("HTTP_USER_AGENT", "unknown") + + email = request.data.get("email") + password = request.data.get("password") + google_id = request.data.get("google_id") + google_picture = request.data.get("google_picture", "") + + if not email or not password or not google_id: + return Response( + {"error": "email, password, google_id는 필수입니다."}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + user = CustomUser.objects.get(email=email) + except CustomUser.DoesNotExist: + logger.warning(f"[GOOGLE LINK] email={email} | status=fail | reason=user_not_found | IP={ip} | UA={ua}") + return Response( + {"error": "사용자를 찾을 수 없습니다."}, + status=status.HTTP_404_NOT_FOUND + ) + + # 비밀번호 확인 + if not user.check_password(password): + logger.warning(f"[GOOGLE LINK] user={email} | status=fail | reason=wrong_password | IP={ip} | UA={ua}") + return Response( + {"error": "비밀번호가 일치하지 않습니다."}, + status=status.HTTP_401_UNAUTHORIZED + ) + + # 이미 다른 소셜 계정으로 연동된 경우 + if user.social_provider and user.social_provider != "google": + return Response( + {"error": f"이미 {user.social_provider} 계정으로 연동되어 있습니다."}, + status=status.HTTP_409_CONFLICT + ) + + # Google 연동 + user.social_provider = "google" + user.social_id = google_id + if google_picture: + user.profile_image = google_picture + user.save(update_fields=["social_provider", "social_id", "profile_image"]) + + logger.info(f"[GOOGLE LINK] user={email} | status=success | IP={ip} | UA={ua}") + span.add_event("Google account linked", attributes={"email": email}) + + # JWT 토큰 발급 + refresh = RefreshToken.for_user(user) + access = refresh.access_token + + return Response({ + "message": "Google 계정이 연동되었습니다.", + "access": str(access), + "refresh": str(refresh), + "user": { + "id": user.id, + "email": user.email, + "name": user.name, + "grade": user.grade, + "profile_image": user.profile_image, + "social_provider": user.social_provider, + } + }, status=status.HTTP_200_OK) + + +class GoogleLinkView(APIView): + """ + 로그인된 상태에서 Google 계정 연동 + - 마이페이지에서 Google 연동 버튼 클릭 시 + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + with tracer.start_as_current_span("GoogleLinkView POST") as span: + email, ip, ua = get_request_info(request) + user = request.user + + credential = request.data.get("credential") + if not credential: + return Response( + {"error": "Google credential이 필요합니다."}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 이미 Google 연동된 경우 + if user.social_provider == "google": + return Response( + {"error": "이미 Google 계정이 연동되어 있습니다."}, + status=status.HTTP_409_CONFLICT + ) + + # 다른 소셜 계정으로 연동된 경우 + if user.social_provider: + return Response( + {"error": f"이미 {user.social_provider} 계정으로 연동되어 있습니다."}, + status=status.HTTP_409_CONFLICT + ) + + if not settings.GOOGLE_CLIENT_ID: + return Response( + {"error": "서버에 Google Client ID가 설정되지 않았습니다."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + try: + # Google ID Token 검증 + idinfo = id_token.verify_oauth2_token( + credential, + google_requests.Request(), + settings.GOOGLE_CLIENT_ID + ) + + google_id = idinfo.get("sub") + google_email = idinfo.get("email") + picture = idinfo.get("picture", "") + + # 이메일 일치 확인 + if google_email != user.email: + logger.warning(f"[GOOGLE LINK] user={email} | google_email={google_email} | status=fail | reason=email_mismatch | IP={ip} | UA={ua}") + return Response( + {"error": "로그인된 계정의 이메일과 Google 이메일이 일치하지 않습니다."}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 해당 Google 계정이 다른 사용자에게 이미 연동되어 있는지 확인 + existing_google_user = CustomUser.objects.filter(social_provider="google", social_id=google_id).exclude(id=user.id).first() + if existing_google_user: + return Response( + {"error": "해당 Google 계정은 이미 다른 사용자에게 연동되어 있습니다."}, + status=status.HTTP_409_CONFLICT + ) + + # Google 연동 + user.social_provider = "google" + user.social_id = google_id + if picture: + user.profile_image = picture + user.save(update_fields=["social_provider", "social_id", "profile_image"]) + + logger.info(f"[GOOGLE LINK] user={email} | status=success | IP={ip} | UA={ua}") + span.add_event("Google account linked", attributes={"email": email}) + + return Response({ + "message": "Google 계정이 연동되었습니다.", + "social_provider": user.social_provider, + "profile_image": user.profile_image, + }, status=status.HTTP_200_OK) + + except ValueError as e: + logger.warning(f"[GOOGLE LINK] user={email} | status=fail | reason=invalid_token | error={str(e)} | IP={ip} | UA={ua}") + return Response( + {"error": "유효하지 않은 Google 토큰입니다."}, + status=status.HTTP_401_UNAUTHORIZED + ) + except Exception as e: + logger.exception(f"[GOOGLE LINK] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}") + return Response( + {"error": f"연동 처리 중 오류가 발생했습니다: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +class GoogleUnlinkView(APIView): + """ + Google 계정 연동 해제 + - 비밀번호가 설정되어 있어야 연동 해제 가능 (로그인 수단 확보) + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + with tracer.start_as_current_span("GoogleUnlinkView POST") as span: + email, ip, ua = get_request_info(request) + user = request.user + + # Google 연동 확인 + if user.social_provider != "google": + return Response( + {"error": "Google 계정이 연동되어 있지 않습니다."}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 비밀번호 설정 여부 확인 (소셜 전용 계정인 경우 연동 해제 불가) + if not user.has_usable_password(): + return Response( + {"error": "비밀번호가 설정되어 있지 않아 연동 해제할 수 없습니다. 먼저 비밀번호를 설정해주세요."}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 연동 해제 + user.social_provider = None + user.social_id = None + # profile_image는 유지 (사용자가 원하면 별도로 삭제) + user.save(update_fields=["social_provider", "social_id"]) + + logger.info(f"[GOOGLE UNLINK] user={email} | status=success | IP={ip} | UA={ua}") + span.add_event("Google account unlinked", attributes={"email": email}) + + return Response({ + "message": "Google 계정 연동이 해제되었습니다.", + "social_provider": None, + }, status=status.HTTP_200_OK) diff --git a/version b/version index 9ff51a9..00cba39 100644 --- a/version +++ b/version @@ -1 +1 @@ -v0.0.26 \ No newline at end of file +v0.0.27 \ No newline at end of file