# views.py import logging import secrets from contextlib import contextmanager from opentelemetry import trace # ✅ OpenTelemetry 트레이서 from opentelemetry.trace import Status, StatusCode # ✅ Span 상태 설정용 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_simplejwt.exceptions import TokenError 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, SiteSettings logger = logging.getLogger(__name__) tracer = trace.get_tracer(__name__) def get_request_info(request): ip = request.META.get("REMOTE_ADDR", "unknown") ua = request.META.get("HTTP_USER_AGENT", "unknown") email = getattr(request.user, "email", "anonymous") return email, ip, ua def get_current_span(): """현재 활성화된 span 가져오기""" return trace.get_current_span() @contextmanager def child_span(name, **attributes): """하위 span 생성 (비즈니스 로직 추적용)""" with tracer.start_as_current_span(name) as span: for key, value in attributes.items(): if value is not None: span.set_attribute(key, str(value) if not isinstance(value, (int, float, bool)) else value) try: yield span except Exception as e: span.set_status(Status(StatusCode.ERROR, str(e)[:200])) span.set_attribute("error", True) span.set_attribute("error.type", type(e).__name__) span.set_attribute("error.message", str(e)[:500]) raise def enrich_span(request, user=None, operation=None): """현재 span에 상세 속성 추가""" span = get_current_span() if not span or not span.is_recording(): return span email, ip, ua = get_request_info(request) if operation: span.set_attribute("operation", operation) span.update_name(operation) span.set_attribute("client.ip", ip) span.set_attribute("client.user_agent", ua[:200] if ua else "unknown") if user and hasattr(user, 'id') and user.id: span.set_attribute("user.id", user.id) span.set_attribute("user.email", getattr(user, 'email', 'unknown')) span.set_attribute("user.grade", getattr(user, 'grade', 'unknown')) elif email != "anonymous": span.set_attribute("user.email", email) return span def span_set_attribute(key, value): """현재 span에 속성 추가""" span = get_current_span() if span and span.is_recording() and value is not None: span.set_attribute(key, str(value) if not isinstance(value, (int, float, bool)) else value) def span_error(error_msg, status_code=400): """현재 span에 에러 상태 설정""" span = get_current_span() if span and span.is_recording(): span.set_status(Status(StatusCode.ERROR, str(error_msg)[:200])) span.set_attribute("error", True) span.set_attribute("error.message", str(error_msg)[:500]) span.set_attribute("http.response.status_code", status_code) def span_success(status_code=200): """현재 span에 성공 상태 설정""" span = get_current_span() if span and span.is_recording(): span.set_status(Status(StatusCode.OK)) span.set_attribute("http.response.status_code", status_code) def span_event(name, **attributes): """현재 span에 이벤트 추가""" span = get_current_span() if span and span.is_recording(): clean_attrs = {k: str(v)[:200] if isinstance(v, str) else v for k, v in attributes.items() if v is not None} span.add_event(name, attributes=clean_attrs) # 기존 코드 호환성을 위한 별칭 def set_span_attributes(span, request, user=None): """기존 코드 호환용 - enrich_span 사용 권장""" return enrich_span(request, user) class RegisterView(APIView): def post(self, request): enrich_span(request, operation="auth.register") email, ip, ua = get_request_info(request) with child_span("validate_input", input_email=request.data.get("email")): serializer = RegisterSerializer(data=request.data) is_valid = serializer.is_valid() if is_valid: with child_span("create_user") as span: user = serializer.save() span.set_attribute("user.id", user.id) span.set_attribute("user.email", user.email) logger.info(f"[REGISTER] user={user.email} | status=success | IP={ip} | UA={ua}") span_event("user_registered", user_id=user.id, user_email=user.email) span_success(201) return Response({"message": "User registered successfully."}, status=status.HTTP_201_CREATED) logger.warning(f"[REGISTER] user={email} | status=fail | IP={ip} | UA={ua} | detail={serializer.errors}") span_error(serializer.errors, 400) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class LogoutView(APIView): permission_classes = [IsAuthenticated] def post(self, request): enrich_span(request, request.user, operation="auth.logout") email, ip, ua = get_request_info(request) refresh_token = request.data.get("refresh") if not refresh_token: logger.warning(f"[LOGOUT] user={email} | status=fail | reason=no_refresh_token | IP={ip}") span_error("refresh token required", 400) return Response({"error": "refresh token이 필요합니다."}, status=status.HTTP_400_BAD_REQUEST) with child_span("blacklist_token", user_email=email): try: token = RefreshToken(refresh_token) token.blacklist() except TokenError as e: logger.warning(f"[LOGOUT] user={email} | status=fail | reason=invalid_token | IP={ip} | error={e}") span_error(str(e), 400) return Response({"error": "유효하지 않은 토큰입니다."}, status=status.HTTP_400_BAD_REQUEST) logger.info(f"[LOGOUT] user={email} | status=success | IP={ip} | UA={ua}") span_event("user_logged_out", user_email=email) span_success(200) return Response({"message": "로그아웃 되었습니다."}, status=status.HTTP_200_OK) class MeView(APIView): permission_classes = [IsAuthenticated] def get(self, request): enrich_span(request, request.user, operation="auth.me.get") email, ip, ua = get_request_info(request) logger.debug(f"[ME GET] user={email} | IP={ip} | UA={ua}") with child_span("serialize_user", user_id=request.user.id): serializer = RegisterSerializer(request.user) data = serializer.data span_event("profile_retrieved", user_email=email) span_success(200) return Response(data) def put(self, request): enrich_span(request, request.user, operation="auth.me.update") email, ip, ua = get_request_info(request) with child_span("validate_input"): serializer = RegisterSerializer(request.user, data=request.data, partial=True) is_valid = serializer.is_valid() if is_valid: with child_span("update_user", user_id=request.user.id): serializer.save() logger.info(f"[ME UPDATE] user={email} | status=success | IP={ip} | UA={ua}") span_event("profile_updated", user_email=email) span_success(200) return Response(serializer.data) logger.warning(f"[ME UPDATE] user={email} | status=fail | IP={ip} | UA={ua} | detail={serializer.errors}") span_error(serializer.errors, 400) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class ChangePasswordView(APIView): """사용자 본인 비밀번호 변경""" permission_classes = [IsAuthenticated] def post(self, request): from .password_utils import ( validate_password_policy, check_password_history, save_password_history ) from django.utils import timezone enrich_span(request, request.user, operation="auth.password.change") email, ip, ua = get_request_info(request) user = request.user with child_span("load_settings"): site_settings = SiteSettings.get_settings() current_password = request.data.get("current_password") new_password = request.data.get("new_password") confirm_password = request.data.get("confirm_password") # 소셜 로그인 전용 계정인 경우 현재 비밀번호 필수 아님 is_social_only = not user.has_usable_password() span_set_attribute("user.is_social_only", is_social_only) with child_span("validate_input"): # 필수 필드 확인 if not is_social_only and not current_password: span_error("missing_current_password", 400) return Response( {"error": "현재 비밀번호를 입력해주세요."}, status=status.HTTP_400_BAD_REQUEST ) if not new_password or not confirm_password: span_error("missing_new_password", 400) return Response( {"error": "새 비밀번호와 비밀번호 확인은 필수입니다."}, status=status.HTTP_400_BAD_REQUEST ) # 새 비밀번호 확인 if new_password != confirm_password: span_error("password_mismatch", 400) return Response( {"error": "새 비밀번호가 일치하지 않습니다."}, status=status.HTTP_400_BAD_REQUEST ) with child_span("validate_policy"): # 비밀번호 정책 검증 is_valid, errors = validate_password_policy(new_password, site_settings) if not is_valid: span_error(errors[0], 400) return Response( {"error": errors[0], "errors": errors}, status=status.HTTP_400_BAD_REQUEST ) # 현재 비밀번호 확인 (소셜 로그인 전용이 아닌 경우) if not is_social_only: with child_span("verify_current_password"): if not user.check_password(current_password): logger.warning(f"[PASSWORD CHANGE] user={email} | status=fail | reason=wrong_password | IP={ip} | UA={ua}") span_error("wrong_password", 401) return Response( {"error": "현재 비밀번호가 일치하지 않습니다."}, status=status.HTTP_401_UNAUTHORIZED ) # 비밀번호 이력 검사 (재사용 방지) if site_settings.password_history_count > 0: with child_span("check_password_history", history_count=site_settings.password_history_count): history_valid, history_error = check_password_history( user, new_password, site_settings.password_history_count ) if not history_valid: span_error(history_error, 400) return Response( {"error": history_error}, status=status.HTTP_400_BAD_REQUEST ) # 현재 비밀번호를 이력에 저장 (변경 전) if user.has_usable_password() and site_settings.password_history_count > 0: with child_span("save_password_history"): from .models import PasswordHistory PasswordHistory.objects.create( user=user, password_hash=user.password ) with child_span("update_password", user_id=user.id): # 새 비밀번호 설정 user.set_password(new_password) user.password_changed_at = timezone.now() user.save(update_fields=['password', 'password_changed_at']) action = "set" if is_social_only else "changed" logger.info(f"[PASSWORD {action.upper()}] user={email} | status=success | IP={ip} | UA={ua}") span_event(f"password_{action}", email=email) span_success(200) return Response({ "message": "비밀번호가 설정되었습니다." if is_social_only else "비밀번호가 변경되었습니다." }) class ExtendPasswordExpiryView(APIView): """비밀번호 만료 연장 (90일)""" permission_classes = [IsAuthenticated] def post(self, request): from django.utils import timezone from datetime import timedelta enrich_span(request, request.user, operation="auth.password.extend") email, ip, ua = get_request_info(request) user = request.user with child_span("load_settings"): site_settings = SiteSettings.get_settings() # 만료 정책이 비활성화된 경우 if site_settings.password_expiry_days <= 0: span_error("policy_disabled", 400) return Response( {"error": "비밀번호 만료 정책이 비활성화되어 있습니다."}, status=status.HTTP_400_BAD_REQUEST ) with child_span("update_expiry", user_id=user.id, expiry_days=site_settings.password_expiry_days): # 비밀번호 변경일을 현재 시간으로 업데이트 (90일 연장) user.password_changed_at = timezone.now() user.save(update_fields=['password_changed_at']) # 새로운 만료일 계산 new_expiry_date = user.password_changed_at + timedelta(days=site_settings.password_expiry_days) logger.info(f"[PASSWORD EXTEND] user={email} | status=success | new_expiry={new_expiry_date.date()} | IP={ip} | UA={ua}") span_event("password_expiry_extended", email=email, new_expiry=str(new_expiry_date.date())) span_success(200) return Response({ "message": f"비밀번호 유효기간이 {site_settings.password_expiry_days}일 연장되었습니다.", "new_expiry_date": new_expiry_date.isoformat(), "days_extended": site_settings.password_expiry_days, }) class CustomTokenObtainPairView(TokenObtainPairView): serializer_class = CustomTokenObtainPairSerializer def post(self, request, *args, **kwargs): ip = request.META.get("REMOTE_ADDR", "unknown") ua = request.META.get("HTTP_USER_AGENT", "unknown") email = request.data.get("email", "unknown") enrich_span(request, operation="auth.login") span_set_attribute("auth.email", email) span_event("login_attempt", email=email, client_ip=ip) logger.info(f"[LOGIN] user={email} | status=attempt | IP={ip} | UA={ua}") with child_span("validate_credentials", email=email): response = super().post(request, *args, **kwargs) if response.status_code == 200: with child_span("generate_token", email=email) as span: # 토큰 정보 추가 span.set_attribute("token.has_access", "access" in response.data) span.set_attribute("token.has_refresh", "refresh" in response.data) logger.info(f"[LOGIN] user={email} | status=success | IP={ip} | UA={ua}") span_event("login_success", email=email) span_success(200) else: logger.warning(f"[LOGIN] user={email} | status=fail | IP={ip} | UA={ua} | detail={response.data}") span_event("login_failed", email=email, reason=str(response.data)[:200]) span_error(response.data, response.status_code) return response class SSHKeyUploadView(APIView): permission_classes = [IsAuthenticated] def post(self, request): enrich_span(request, request.user, operation="ssh.key.upload") email, ip, ua = get_request_info(request) private_key = request.data.get("private_key") key_name = request.data.get("key_name") with child_span("validate_input", key_name=key_name): if not private_key or not key_name: logger.warning(f"[SSH UPLOAD] user={email} | status=fail | reason=missing_key_or_name | IP={ip} | UA={ua}") span_error("missing_key_or_name", 400) return Response({"error": "private_key와 key_name 모두 필요합니다."}, status=status.HTTP_400_BAD_REQUEST) try: with child_span("encrypt_key", key_name=key_name): user = request.user user.save_private_key(private_key) user.encrypted_private_key_name = key_name with child_span("save_to_db", user_id=user.id): user.save(update_fields=["encrypted_private_key", "encrypted_private_key_name"]) logger.info(f"[SSH UPLOAD] user={email} | status=success | key_name={key_name} | IP={ip} | UA={ua}") span_event("ssh_key_saved", email=email, key_name=key_name) span_success(201) return Response({"message": "SSH key 저장 완료."}, status=201) except Exception as e: logger.exception(f"[SSH UPLOAD] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}") span_error(str(e), 500) return Response({"error": f"암호화 또는 저장 실패: {str(e)}"}, status=500) def delete(self, request): enrich_span(request, request.user, operation="ssh.key.delete") email, ip, ua = get_request_info(request) with child_span("delete_key", user_id=request.user.id): user = request.user user.encrypted_private_key = None user.encrypted_private_key_name = None user.last_used_at = None user.save(update_fields=["encrypted_private_key", "encrypted_private_key_name", "last_used_at"]) logger.info(f"[SSH DELETE] user={email} | status=success | IP={ip} | UA={ua}") span_event("ssh_key_deleted", email=email) span_success(200) return Response({"message": "SSH key deleted."}, status=200) class SSHKeyInfoView(APIView): permission_classes = [IsAuthenticated] def get(self, request): enrich_span(request, request.user, operation="ssh.key.info") email, ip, ua = get_request_info(request) logger.debug(f"[SSH INFO] user={email} | IP={ip} | UA={ua}") user = request.user with child_span("get_key_info", user_id=user.id): has_key = bool(user.encrypted_private_key) span_set_attribute("ssh.has_key", has_key) span_event("ssh_key_info_retrieved", email=email, has_key=has_key) span_success(200) return Response({ "has_key": has_key, "encrypted_private_key_name": user.encrypted_private_key_name, "last_used_at": user.last_used_at, }) class SSHKeyRetrieveView(APIView): permission_classes = [IsAuthenticated] def get(self, request): enrich_span(request, request.user, operation="ssh.key.retrieve") email, ip, ua = get_request_info(request) user = request.user with child_span("check_key_exists", user_id=user.id): if not user.encrypted_private_key: logger.warning( f"[SSH RETRIEVE] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" ) span_event("ssh_key_retrieve_failed", email=email, reason="not_found") span_error("not_found", 404) return Response( {"error": "SSH 키가 등록되어 있지 않습니다."}, status=404 ) try: with child_span("decrypt_key", user_id=user.id): decrypted_key = user.decrypt_private_key() logger.info( f"[SSH RETRIEVE] user={email} | status=success | IP={ip} | UA={ua}" ) span_event("ssh_key_retrieved", email=email) span_success(200) return Response({"ssh_key": decrypted_key}) except Exception as e: logger.exception( f"[SSH RETRIEVE] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" ) span_event("ssh_key_retrieve_failed", email=email, reason=str(e)) span_error(str(e), 500) return Response({"error": f"복호화 실패: {str(e)}"}, status=500) # ============================================ # 관리자용 사용자 관리 API # ============================================ class IsAdminOrManager(BasePermission): """admin 또는 manager 등급만 접근 가능""" def has_permission(self, request, view): if not request.user or not request.user.is_authenticated: return False return request.user.grade in ['admin', 'manager'] class UserListView(generics.ListAPIView): """사용자 목록 조회 (관리자 전용)""" queryset = CustomUser.objects.all().order_by('-created_at') serializer_class = UserListSerializer permission_classes = [IsAuthenticated, IsAdminOrManager] def list(self, request, *args, **kwargs): enrich_span(request, request.user, operation="admin.user.list") email, ip, ua = get_request_info(request) logger.info(f"[USER LIST] admin={email} | IP={ip} | UA={ua}") with child_span("query_users") as span: response = super().list(request, *args, **kwargs) span.set_attribute("user.count", len(response.data)) span_event("user_list_retrieved", admin=email, count=len(response.data)) span_success(200) return response class UserUpdateView(generics.RetrieveUpdateDestroyAPIView): """사용자 상태 수정/삭제 (관리자 전용)""" queryset = CustomUser.objects.all() serializer_class = UserListSerializer permission_classes = [IsAuthenticated, IsAdminOrManager] def partial_update(self, request, *args, **kwargs): enrich_span(request, request.user, operation="admin.user.update") admin_email, ip, ua = get_request_info(request) with child_span("get_target_user"): instance = self.get_object() target_email = instance.email span_set_attribute("target.user_id", instance.id) span_set_attribute("target.email", target_email) update_fields = [] actions = [] # is_active 수정 is_active = request.data.get('is_active') if is_active is not None: with child_span("update_active_status", is_active=is_active): instance.is_active = is_active update_fields.append('is_active') actions.append("activated" if is_active else "deactivated") # grade 수정 (관리자만 admin 등급 부여 가능) grade = request.data.get('grade') if grade is not None: with child_span("update_grade", new_grade=grade): valid_grades = ['admin', 'manager', 'user'] if grade not in valid_grades: span_error("invalid_grade", 400) return Response( {"error": f"유효하지 않은 등급입니다. 가능한 값: {valid_grades}"}, status=status.HTTP_400_BAD_REQUEST ) # admin 등급 부여는 admin만 가능 (manager는 불가) if grade == 'admin' and request.user.grade != 'admin': span_error("permission_denied", 403) return Response( {"error": "admin 등급 부여는 admin만 가능합니다."}, status=status.HTTP_403_FORBIDDEN ) # 자기 자신의 등급 하향 방지 (실수로 관리자 권한 잃는 것 방지) if request.user.id == instance.id and instance.grade == 'admin' and grade != 'admin': span_error("self_demote_blocked", 400) return Response( {"error": "자신의 admin 등급을 변경할 수 없습니다."}, status=status.HTTP_400_BAD_REQUEST ) instance.grade = grade update_fields.append('grade') actions.append(f"grade_changed_to_{grade}") # 비밀번호 초기화 (관리자 전용) new_password = request.data.get('new_password') if new_password is not None: with child_span("reset_password", target_email=target_email): if len(new_password) < 8: span_error("password_too_short", 400) 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: with child_span("save_changes", fields=str(update_fields)): instance.save(update_fields=update_fields) logger.info( f"[USER UPDATE] admin={admin_email} | target={target_email} | actions={actions} | IP={ip} | UA={ua}" ) span_event("user_updated", admin=admin_email, target=target_email, actions=str(actions)) span_success(200) serializer = self.get_serializer(instance) return Response(serializer.data) def destroy(self, request, *args, **kwargs): enrich_span(request, request.user, operation="admin.user.delete") admin_email, ip, ua = get_request_info(request) with child_span("get_target_user"): instance = self.get_object() target_email = instance.email span_set_attribute("target.user_id", instance.id) span_set_attribute("target.email", target_email) # 자기 자신은 삭제 불가 if request.user.id == instance.id: span_error("self_delete_blocked", 400) return Response( {"error": "자기 자신의 계정은 삭제할 수 없습니다."}, status=status.HTTP_400_BAD_REQUEST ) with child_span("delete_user", target_email=target_email): instance.delete() logger.info( f"[USER DELETE] admin={admin_email} | target={target_email} | IP={ip} | UA={ua}" ) span_event("user_deleted", admin=admin_email, target=target_email) span_success(200) return Response( {"message": f"사용자 {target_email}이(가) 삭제되었습니다."}, status=status.HTTP_200_OK ) # ============================================ # NHN Cloud 자격증명 관리 API # ============================================ class NHNCloudCredentialsView(APIView): """NHN Cloud 자격증명 저장/조회/삭제""" permission_classes = [IsAuthenticated] def get(self, request): """자격증명 조회 (비밀번호 제외)""" enrich_span(request, request.user, operation="nhn.credentials.get") email, ip, ua = get_request_info(request) user = request.user logger.debug(f"[NHN CREDENTIALS GET] user={email} | IP={ip} | UA={ua}") with child_span("get_credentials", user_id=user.id): has_credentials = bool(user.nhn_tenant_id and user.encrypted_nhn_api_password) span_set_attribute("nhn.has_credentials", has_credentials) span_event("nhn_credentials_retrieved", email=email, has_credentials=has_credentials) span_success(200) return Response({ "has_credentials": has_credentials, "tenant_id": user.nhn_tenant_id or "", "username": user.nhn_username or "", "storage_account": user.nhn_storage_account or "", }) def post(self, request): """자격증명 저장""" enrich_span(request, request.user, operation="nhn.credentials.save") email, ip, ua = get_request_info(request) user = request.user tenant_id = request.data.get("tenant_id") username = request.data.get("username") password = request.data.get("password") storage_account = request.data.get("storage_account", "") with child_span("validate_input"): if not tenant_id or not username or not password: logger.warning( f"[NHN CREDENTIALS SAVE] user={email} | status=fail | reason=missing_fields | IP={ip} | UA={ua}" ) span_error("missing_fields", 400) return Response( {"error": "tenant_id, username, password는 필수입니다."}, status=status.HTTP_400_BAD_REQUEST, ) try: with child_span("encrypt_and_save", tenant_id=tenant_id): user.save_nhn_credentials(tenant_id, username, password, storage_account) logger.info( f"[NHN CREDENTIALS SAVE] user={email} | status=success | IP={ip} | UA={ua}" ) span_event("nhn_credentials_saved", email=email, tenant_id=tenant_id) span_success(201) return Response({"message": "NHN Cloud 자격증명이 저장되었습니다."}, status=201) except Exception as e: logger.exception( f"[NHN CREDENTIALS SAVE] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" ) span_error(str(e), 500) return Response({"error": f"저장 실패: {str(e)}"}, status=500) def delete(self, request): """자격증명 삭제""" enrich_span(request, request.user, operation="nhn.credentials.delete") email, ip, ua = get_request_info(request) user = request.user with child_span("delete_credentials", user_id=user.id): user.nhn_tenant_id = None user.nhn_username = None user.encrypted_nhn_api_password = None user.nhn_storage_account = None user.save(update_fields=[ 'nhn_tenant_id', 'nhn_username', 'encrypted_nhn_api_password', 'nhn_storage_account' ]) logger.info(f"[NHN CREDENTIALS DELETE] user={email} | status=success | IP={ip} | UA={ua}") span_event("nhn_credentials_deleted", email=email) span_success(200) return Response({"message": "NHN Cloud 자격증명이 삭제되었습니다."}) class NHNCloudPasswordView(APIView): """NHN Cloud API 비밀번호 조회 (복호화) - msa-django-nhn에서 사용""" permission_classes = [IsAuthenticated] def get(self, request): """복호화된 비밀번호 조회""" enrich_span(request, request.user, operation="nhn.password.get") email, ip, ua = get_request_info(request) user = request.user with child_span("check_credentials", user_id=user.id): if not user.encrypted_nhn_api_password: logger.warning( f"[NHN PASSWORD GET] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" ) span_error("not_found", 404) return Response( {"error": "NHN Cloud 자격증명이 등록되어 있지 않습니다."}, status=404 ) try: with child_span("decrypt_password"): decrypted_password = user.decrypt_nhn_password() logger.info(f"[NHN PASSWORD GET] user={email} | status=success | IP={ip} | UA={ua}") span_event("nhn_password_retrieved", email=email) span_success(200) return Response({ "tenant_id": user.nhn_tenant_id, "username": user.nhn_username, "password": decrypted_password, "storage_account": user.nhn_storage_account or "", }) except Exception as e: logger.exception( f"[NHN PASSWORD GET] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" ) span_error(str(e), 500) return Response({"error": f"복호화 실패: {str(e)}"}, status=500) # ============================================ # NHN Cloud 프로젝트 관리 API (멀티 프로젝트 지원) # ============================================ class NHNCloudProjectListView(APIView): """NHN Cloud 프로젝트 목록 조회 및 추가""" permission_classes = [IsAuthenticated] def get(self, request): """프로젝트 목록 조회""" enrich_span(request, request.user, operation="nhn.project.list") email, ip, ua = get_request_info(request) with child_span("query_projects", user_id=request.user.id) as span: projects = NHNCloudProject.objects.filter(user=request.user) count = projects.count() span.set_attribute("project.count", count) logger.debug(f"[NHN PROJECT LIST] user={email} | count={count} | IP={ip} | UA={ua}") span_event("nhn_projects_listed", email=email, count=count) span_success(200) data = [{ "id": p.id, "name": p.name, "tenant_id": p.tenant_id, "username": p.username, "storage_account": p.storage_account or "", "dns_appkey": p.dns_appkey or "", "is_active": p.is_active, "created_at": p.created_at.isoformat(), } for p in projects] return Response(data) def post(self, request): """프로젝트 추가""" enrich_span(request, request.user, operation="nhn.project.create") email, ip, ua = get_request_info(request) name = request.data.get("name") tenant_id = request.data.get("tenant_id") username = request.data.get("username") password = request.data.get("password") storage_account = request.data.get("storage_account", "") dns_appkey = request.data.get("dns_appkey", "") with child_span("validate_input", project_name=name): if not name or not tenant_id or not username or not password: logger.warning( f"[NHN PROJECT CREATE] user={email} | status=fail | reason=missing_fields | IP={ip} | UA={ua}" ) span_error("missing_fields", 400) return Response( {"error": "name, tenant_id, username, password는 필수입니다."}, status=status.HTTP_400_BAD_REQUEST, ) with child_span("check_duplicate", tenant_id=tenant_id): # 중복 체크 if NHNCloudProject.objects.filter(user=request.user, tenant_id=tenant_id).exists(): logger.warning( f"[NHN PROJECT CREATE] user={email} | status=fail | reason=duplicate | IP={ip} | UA={ua}" ) span_error("duplicate_tenant_id", 400) return Response( {"error": "이미 등록된 Tenant ID입니다."}, status=status.HTTP_400_BAD_REQUEST, ) try: with child_span("create_project", project_name=name) as span: # 첫 프로젝트면 자동 활성화 is_first = not NHNCloudProject.objects.filter(user=request.user).exists() span.set_attribute("project.is_first", is_first) project = NHNCloudProject( user=request.user, name=name, tenant_id=tenant_id, username=username, storage_account=storage_account, dns_appkey=dns_appkey, is_active=is_first, ) # 암호화된 비밀번호 설정 후 저장 project.encrypted_password = project.encrypt_password(password) project.save() span.set_attribute("project.id", project.id) logger.info( f"[NHN PROJECT CREATE] user={email} | project={name} | is_active={is_first} | IP={ip} | UA={ua}" ) span_event("nhn_project_created", email=email, project=name) span_success(201) return Response({ "id": project.id, "name": project.name, "tenant_id": project.tenant_id, "username": project.username, "storage_account": project.storage_account or "", "dns_appkey": project.dns_appkey or "", "is_active": project.is_active, "created_at": project.created_at.isoformat(), }, status=status.HTTP_201_CREATED) except Exception as e: logger.exception( f"[NHN PROJECT CREATE] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" ) span_error(str(e), 500) return Response({"error": f"프로젝트 생성 실패: {str(e)}"}, status=500) class NHNCloudProjectDetailView(APIView): """NHN Cloud 프로젝트 상세 (조회/수정/삭제)""" permission_classes = [IsAuthenticated] def get_project(self, request, project_id): """프로젝트 조회 (본인 것만)""" try: return NHNCloudProject.objects.get(id=project_id, user=request.user) except NHNCloudProject.DoesNotExist: return None def get(self, request, project_id): """프로젝트 상세 조회""" enrich_span(request, request.user, operation="nhn.project.get") email, ip, ua = get_request_info(request) span_set_attribute("project.id", project_id) with child_span("get_project", project_id=project_id): project = self.get_project(request, project_id) if not project: span_error("not_found", 404) return Response({"error": "프로젝트를 찾을 수 없습니다."}, status=404) span_event("nhn_project_retrieved", email=email, project=project.name) span_success(200) return Response({ "id": project.id, "name": project.name, "tenant_id": project.tenant_id, "username": project.username, "storage_account": project.storage_account or "", "dns_appkey": project.dns_appkey or "", "is_active": project.is_active, "created_at": project.created_at.isoformat(), }) def put(self, request, project_id): """프로젝트 수정""" enrich_span(request, request.user, operation="nhn.project.update") email, ip, ua = get_request_info(request) span_set_attribute("project.id", project_id) with child_span("get_project", project_id=project_id): project = self.get_project(request, project_id) if not project: logger.warning( f"[NHN PROJECT UPDATE] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" ) span_error("not_found", 404) return Response({"error": "프로젝트를 찾을 수 없습니다."}, status=404) # 수정 가능한 필드 name = request.data.get("name") tenant_id = request.data.get("tenant_id") username = request.data.get("username") password = request.data.get("password") # 비어있으면 변경 안함 storage_account = request.data.get("storage_account") dns_appkey = request.data.get("dns_appkey") with child_span("validate_input", project_name=name): # 필수 필드 검증 if not name or not tenant_id or not username: span_error("missing_fields", 400) return Response( {"error": "name, tenant_id, username은 필수입니다."}, status=status.HTTP_400_BAD_REQUEST, ) with child_span("check_duplicate", tenant_id=tenant_id): # tenant_id 중복 체크 (자기 자신 제외) if NHNCloudProject.objects.filter( user=request.user, tenant_id=tenant_id ).exclude(id=project_id).exists(): span_error("duplicate_tenant_id", 400) return Response( {"error": "이미 등록된 Tenant ID입니다."}, status=status.HTTP_400_BAD_REQUEST, ) try: with child_span("update_project", project_name=name): project.name = name project.tenant_id = tenant_id project.username = username if storage_account is not None: project.storage_account = storage_account if dns_appkey is not None: project.dns_appkey = dns_appkey # 비밀번호가 제공된 경우에만 변경 if password: project.encrypted_password = project.encrypt_password(password) project.save() logger.info( f"[NHN PROJECT UPDATE] user={email} | project={name} | IP={ip} | UA={ua}" ) span_event("nhn_project_updated", email=email, project=name) span_success(200) return Response({ "id": project.id, "name": project.name, "tenant_id": project.tenant_id, "username": project.username, "storage_account": project.storage_account or "", "dns_appkey": project.dns_appkey or "", "is_active": project.is_active, "created_at": project.created_at.isoformat(), }) except Exception as e: logger.exception( f"[NHN PROJECT UPDATE] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" ) span_error(str(e), 500) return Response({"error": f"프로젝트 수정 실패: {str(e)}"}, status=500) def delete(self, request, project_id): """프로젝트 삭제""" enrich_span(request, request.user, operation="nhn.project.delete") email, ip, ua = get_request_info(request) span_set_attribute("project.id", project_id) with child_span("get_project", project_id=project_id): project = self.get_project(request, project_id) if not project: logger.warning( f"[NHN PROJECT DELETE] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" ) span_error("not_found", 404) return Response({"error": "프로젝트를 찾을 수 없습니다."}, status=404) project_name = project.name was_active = project.is_active with child_span("delete_project", project_name=project_name): project.delete() # 삭제된 프로젝트가 활성이었으면 다른 프로젝트 활성화 if was_active: with child_span("activate_another_project"): other_project = NHNCloudProject.objects.filter(user=request.user).first() if other_project: other_project.is_active = True other_project.save(update_fields=['is_active']) logger.info( f"[NHN PROJECT DELETE] user={email} | project={project_name} | IP={ip} | UA={ua}" ) span_event("nhn_project_deleted", email=email, project=project_name) span_success(204) return Response(status=status.HTTP_204_NO_CONTENT) class NHNCloudProjectActivateView(APIView): """NHN Cloud 프로젝트 활성화""" permission_classes = [IsAuthenticated] def patch(self, request, project_id): """프로젝트 활성화 (기존 활성 해제)""" enrich_span(request, request.user, operation="nhn.project.activate") email, ip, ua = get_request_info(request) span_set_attribute("project.id", project_id) with child_span("get_project", project_id=project_id): try: project = NHNCloudProject.objects.get(id=project_id, user=request.user) except NHNCloudProject.DoesNotExist: logger.warning( f"[NHN PROJECT ACTIVATE] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" ) span_error("not_found", 404) return Response({"error": "프로젝트를 찾을 수 없습니다."}, status=404) with child_span("deactivate_others"): # 기존 활성 프로젝트 비활성화 NHNCloudProject.objects.filter(user=request.user, is_active=True).update(is_active=False) with child_span("activate_project", project_name=project.name): # 새 프로젝트 활성화 project.is_active = True project.save(update_fields=['is_active']) logger.info( f"[NHN PROJECT ACTIVATE] user={email} | project={project.name} | IP={ip} | UA={ua}" ) span_event("nhn_project_activated", email=email, project=project.name) span_success(200) return Response({ "message": "프로젝트가 활성화되었습니다.", "id": project.id, "name": project.name, }) class NHNCloudProjectPasswordView(APIView): """NHN Cloud 프로젝트 비밀번호 조회 (복호화)""" permission_classes = [IsAuthenticated] def get(self, request, project_id): """복호화된 비밀번호 조회""" enrich_span(request, request.user, operation="nhn.project.password") email, ip, ua = get_request_info(request) span_set_attribute("project.id", project_id) with child_span("get_project", project_id=project_id): try: project = NHNCloudProject.objects.get(id=project_id, user=request.user) except NHNCloudProject.DoesNotExist: logger.warning( f"[NHN PROJECT PASSWORD] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" ) span_error("not_found", 404) return Response({"error": "프로젝트를 찾을 수 없습니다."}, status=404) try: with child_span("decrypt_password", project_name=project.name): decrypted_password = project.decrypt_password() logger.info(f"[NHN PROJECT PASSWORD] user={email} | project={project.name} | IP={ip} | UA={ua}") span_event("nhn_project_password_retrieved", email=email, project=project.name) span_success(200) return Response({ "tenant_id": project.tenant_id, "username": project.username, "password": decrypted_password, "storage_account": project.storage_account or "", "dns_appkey": project.dns_appkey or "", }) except Exception as e: logger.exception( f"[NHN PROJECT PASSWORD] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" ) span_error(str(e), 500) return Response({"error": f"복호화 실패: {str(e)}"}, status=500) # ============================================ # KVM 서버 관리 API (멀티 서버 지원) # ============================================ class KVMServerListView(APIView): """KVM 서버 목록 조회 및 추가""" permission_classes = [IsAuthenticated] def get(self, request): """서버 목록 조회""" enrich_span(request, request.user, operation="kvm.server.list") email, ip, ua = get_request_info(request) with child_span("query_servers", user_id=request.user.id) as span: servers = KVMServer.objects.filter(user=request.user) count = servers.count() span.set_attribute("server.count", count) logger.debug(f"[KVM SERVER LIST] user={email} | count={count} | IP={ip} | UA={ua}") span_event("kvm_servers_listed", email=email, count=count) span_success(200) serializer = KVMServerSerializer(servers, many=True) return Response(serializer.data) def post(self, request): """서버 추가""" enrich_span(request, request.user, operation="kvm.server.create") email, ip, ua = get_request_info(request) # 중복 체크 host = request.data.get("host") port = request.data.get("port", 22) with child_span("check_duplicate", host=host, port=port): if KVMServer.objects.filter(user=request.user, host=host, port=port).exists(): logger.warning( f"[KVM SERVER CREATE] user={email} | status=fail | reason=duplicate | IP={ip} | UA={ua}" ) span_error("duplicate_server", 400) return Response( {"error": "이미 등록된 서버입니다 (동일한 호스트:포트)."}, status=status.HTTP_400_BAD_REQUEST, ) with child_span("validate_input"): serializer = KVMServerSerializer(data=request.data) is_valid = serializer.is_valid() if is_valid: with child_span("create_server", host=host) as span: server = serializer.save(user=request.user) span.set_attribute("server.id", server.id) span.set_attribute("server.name", server.name) logger.info( f"[KVM SERVER CREATE] user={email} | server={server.name} | host={server.host} | IP={ip} | UA={ua}" ) span_event("kvm_server_created", email=email, server=server.name) span_success(201) return Response(KVMServerSerializer(server).data, status=status.HTTP_201_CREATED) logger.warning( f"[KVM SERVER CREATE] user={email} | status=fail | reason=validation | IP={ip} | UA={ua}" ) span_error(str(serializer.errors), 400) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class KVMServerDetailView(APIView): """KVM 서버 상세 (조회/수정/삭제)""" permission_classes = [IsAuthenticated] def get_server(self, request, server_id): """서버 조회 (본인 것만)""" try: return KVMServer.objects.get(id=server_id, user=request.user) except KVMServer.DoesNotExist: return None def get(self, request, server_id): """서버 상세 조회""" enrich_span(request, request.user, operation="kvm.server.get") email, ip, ua = get_request_info(request) span_set_attribute("server.id", server_id) with child_span("get_server", server_id=server_id): server = self.get_server(request, server_id) if not server: span_error("not_found", 404) return Response({"error": "서버를 찾을 수 없습니다."}, status=404) span_event("kvm_server_retrieved", email=email, server=server.name) span_success(200) return Response(KVMServerSerializer(server).data) def put(self, request, server_id): """서버 수정""" enrich_span(request, request.user, operation="kvm.server.update") email, ip, ua = get_request_info(request) span_set_attribute("server.id", server_id) with child_span("get_server", server_id=server_id): server = self.get_server(request, server_id) if not server: logger.warning( f"[KVM SERVER UPDATE] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" ) span_error("not_found", 404) return Response({"error": "서버를 찾을 수 없습니다."}, status=404) # 호스트:포트 중복 체크 (자기 자신 제외) host = request.data.get("host", server.host) port = request.data.get("port", server.port) with child_span("check_duplicate", host=host, port=port): if KVMServer.objects.filter( user=request.user, host=host, port=port ).exclude(id=server_id).exists(): span_error("duplicate_server", 400) return Response( {"error": "이미 등록된 서버입니다 (동일한 호스트:포트)."}, status=status.HTTP_400_BAD_REQUEST, ) with child_span("validate_input"): serializer = KVMServerSerializer(server, data=request.data, partial=True) is_valid = serializer.is_valid() if is_valid: with child_span("update_server", server_name=server.name): server = serializer.save() logger.info( f"[KVM SERVER UPDATE] user={email} | server={server.name} | IP={ip} | UA={ua}" ) span_event("kvm_server_updated", email=email, server=server.name) span_success(200) return Response(KVMServerSerializer(server).data) logger.warning( f"[KVM SERVER UPDATE] user={email} | status=fail | reason=validation | IP={ip} | UA={ua}" ) span_error(str(serializer.errors), 400) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def delete(self, request, server_id): """서버 삭제""" enrich_span(request, request.user, operation="kvm.server.delete") email, ip, ua = get_request_info(request) span_set_attribute("server.id", server_id) with child_span("get_server", server_id=server_id): server = self.get_server(request, server_id) if not server: logger.warning( f"[KVM SERVER DELETE] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" ) span_error("not_found", 404) return Response({"error": "서버를 찾을 수 없습니다."}, status=404) server_name = server.name with child_span("delete_server", server_name=server_name): server.delete() logger.info( f"[KVM SERVER DELETE] user={email} | server={server_name} | IP={ip} | UA={ua}" ) span_event("kvm_server_deleted", email=email, server=server_name) span_success(204) return Response(status=status.HTTP_204_NO_CONTENT) class KVMServerActivateView(APIView): """KVM 서버 활성화/비활성화""" permission_classes = [IsAuthenticated] def patch(self, request, server_id): """서버 활성화 상태 토글""" enrich_span(request, request.user, operation="kvm.server.activate") email, ip, ua = get_request_info(request) span_set_attribute("server.id", server_id) with child_span("get_server", server_id=server_id): try: server = KVMServer.objects.get(id=server_id, user=request.user) except KVMServer.DoesNotExist: logger.warning( f"[KVM SERVER ACTIVATE] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" ) span_error("not_found", 404) return Response({"error": "서버를 찾을 수 없습니다."}, status=404) # 요청에서 is_active 값 가져오기 (없으면 토글) is_active = request.data.get('is_active') with child_span("toggle_active", server_name=server.name) as span: if is_active is None: server.is_active = not server.is_active else: server.is_active = is_active span.set_attribute("server.is_active", server.is_active) server.save(update_fields=['is_active']) action = "activated" if server.is_active else "deactivated" logger.info( f"[KVM SERVER ACTIVATE] user={email} | server={server.name} | action={action} | IP={ip} | UA={ua}" ) span_event(f"kvm_server_{action}", email=email, server=server.name) span_success(200) return Response({ "message": f"서버가 {'활성화' if server.is_active else '비활성화'}되었습니다.", "id": server.id, "name": server.name, "is_active": server.is_active, }) class KVMServerSSHKeyView(APIView): """KVM 서버 SSH 키 조회 (복호화) - msa-django-libvirt에서 사용""" permission_classes = [IsAuthenticated] def get(self, request, server_id): """복호화된 SSH 키와 접속 정보 조회""" enrich_span(request, request.user, operation="kvm.server.ssh_key") email, ip, ua = get_request_info(request) span_set_attribute("server.id", server_id) with child_span("get_server", server_id=server_id): try: server = KVMServer.objects.get(id=server_id, user=request.user) except KVMServer.DoesNotExist: logger.warning( f"[KVM SERVER SSH KEY] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" ) span_error("not_found", 404) return Response({"error": "서버를 찾을 수 없습니다."}, status=404) with child_span("check_key_exists", server_name=server.name): if not server.encrypted_private_key: logger.warning( f"[KVM SERVER SSH KEY] user={email} | server={server.name} | status=fail | reason=no_key | IP={ip} | UA={ua}" ) span_error("no_key", 404) return Response( {"error": "SSH 키가 등록되어 있지 않습니다."}, status=404 ) try: with child_span("decrypt_key", server_name=server.name): decrypted_key = server.decrypt_private_key() logger.info( f"[KVM SERVER SSH KEY] user={email} | server={server.name} | IP={ip} | UA={ua}" ) span_event("kvm_server_ssh_key_retrieved", email=email, server=server.name) span_success(200) return Response({ "id": server.id, "name": server.name, "host": server.host, "port": server.port, "username": server.username, "private_key": decrypted_key, "libvirt_uri": server.get_libvirt_uri(), }) except Exception as e: logger.exception( f"[KVM SERVER SSH KEY] user={email} | server={server.name} | status=fail | reason=exception | IP={ip} | UA={ua}" ) span_error(str(e), 500) return Response({"error": f"복호화 실패: {str(e)}"}, status=500) class KVMServerSSHKeyUploadView(APIView): """KVM 서버 SSH 키 업로드/삭제""" permission_classes = [IsAuthenticated] def get_server(self, request, server_id): try: return KVMServer.objects.get(id=server_id, user=request.user) except KVMServer.DoesNotExist: return None def post(self, request, server_id): """SSH 키 업로드""" enrich_span(request, request.user, operation="kvm.server.ssh_key.upload") email, ip, ua = get_request_info(request) span_set_attribute("server.id", server_id) with child_span("get_server", server_id=server_id): server = self.get_server(request, server_id) if not server: span_error("not_found", 404) return Response({"error": "서버를 찾을 수 없습니다."}, status=404) private_key = request.data.get("private_key") key_name = request.data.get("key_name") with child_span("validate_input", key_name=key_name): if not private_key: logger.warning( f"[KVM SERVER SSH UPLOAD] user={email} | server={server.name} | status=fail | reason=missing_key | IP={ip} | UA={ua}" ) span_error("missing_key", 400) return Response( {"error": "private_key는 필수입니다."}, status=status.HTTP_400_BAD_REQUEST, ) try: with child_span("encrypt_and_save", server_name=server.name): server.save_ssh_key(private_key, key_name) logger.info( f"[KVM SERVER SSH UPLOAD] user={email} | server={server.name} | IP={ip} | UA={ua}" ) span_event("kvm_server_ssh_key_uploaded", email=email, server=server.name) span_success(201) return Response({"message": "SSH 키가 저장되었습니다."}, status=201) except Exception as e: logger.exception( f"[KVM SERVER SSH UPLOAD] user={email} | server={server.name} | status=fail | reason=exception | IP={ip} | UA={ua}" ) span_error(str(e), 500) return Response({"error": f"저장 실패: {str(e)}"}, status=500) def delete(self, request, server_id): """SSH 키 삭제""" enrich_span(request, request.user, operation="kvm.server.ssh_key.delete") email, ip, ua = get_request_info(request) span_set_attribute("server.id", server_id) with child_span("get_server", server_id=server_id): server = self.get_server(request, server_id) if not server: span_error("not_found", 404) return Response({"error": "서버를 찾을 수 없습니다."}, status=404) with child_span("delete_key", server_name=server.name): server.encrypted_private_key = None server.encrypted_private_key_name = None server.last_used_at = None server.save(update_fields=['encrypted_private_key', 'encrypted_private_key_name', 'last_used_at']) logger.info( f"[KVM SERVER SSH DELETE] user={email} | server={server.name} | IP={ip} | UA={ua}" ) span_event("kvm_server_ssh_key_deleted", email=email, server=server.name) span_success(200) return Response({"message": "SSH 키가 삭제되었습니다."}) # ============================================ # Google 소셜 로그인 API # ============================================ class GoogleLoginView(APIView): """ Google 소셜 로그인 - Google ID Token 검증 - 기존 사용자 조회 또는 자동 회원가입 - 동일 이메일 계정 연동 - JWT 토큰 발급 """ def post(self, request): enrich_span(request, operation="auth.google.login") ip = request.META.get("REMOTE_ADDR", "unknown") ua = request.META.get("HTTP_USER_AGENT", "unknown") credential = request.data.get("credential") with child_span("validate_input"): if not credential: logger.warning(f"[GOOGLE LOGIN] status=fail | reason=missing_credential | IP={ip} | UA={ua}") span_error("missing_credential", 400) 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}") span_error("missing_client_id", 500) return Response( {"error": "서버에 Google Client ID가 설정되지 않았습니다."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) try: with child_span("verify_google_token"): # 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", "") span_set_attribute("google.email", email) span_set_attribute("google.id", google_id) if not email: logger.warning(f"[GOOGLE LOGIN] status=fail | reason=no_email | IP={ip} | UA={ua}") span_error("no_email", 400) 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}") with child_span("find_user", email=email, google_id=google_id): # 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}") span_set_attribute("login.type", "existing_social_user") # 프로필 이미지 업데이트 if picture and user.profile_image != picture: user.profile_image = picture user.save(update_fields=["profile_image"]) else: with child_span("check_existing_email", email=email): # 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}") span_event("email_exists_need_link", email=email) 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}") span_error("other_social_provider", 409) 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}") span_error("different_google_account", 409) return Response( { "error": "다른 Google 계정으로 이미 가입되어 있습니다.", "code": "DIFFERENT_GOOGLE_ACCOUNT" }, status=status.HTTP_409_CONFLICT ) else: # 3. 신규 사용자 자동 회원가입 (기존 계정이 없는 경우) with child_span("create_new_user", email=email) as span: # 고유한 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=False, # 관리자 승인 필요 ) # 비밀번호 없이 사용 불가하게 설정 user.set_unusable_password() user.save() span.set_attribute("user.id", user.id) logger.info(f"[GOOGLE LOGIN] user={email} | status=new_user_created_pending | IP={ip} | UA={ua}") # 승인 대기 응답 반환 span_event("google_login_pending_approval", email=email) return Response( { "error": "회원가입이 완료되었습니다. 관리자 승인 후 로그인할 수 있습니다.", "code": "PENDING_APPROVAL", "message": "관리자 승인 대기 중입니다.", }, status=status.HTTP_403_FORBIDDEN ) # 사용자 활성 상태 확인 if not user.is_active: logger.warning(f"[GOOGLE LOGIN] user={email} | status=fail | reason=inactive_user | IP={ip} | UA={ua}") span_error("inactive_user", 403) return Response( {"error": "비활성화된 계정입니다. 관리자에게 문의하세요."}, status=status.HTTP_403_FORBIDDEN ) with child_span("generate_token", user_id=user.id): # JWT 토큰 발급 (커스텀 클레임 포함) refresh = CustomTokenObtainPairSerializer.get_token(user) access = refresh.access_token # 기존 로그인 응답과 동일한 형식 span_event("google_login_success", email=email) span_success(200) 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_event("google_login_failed", reason="invalid_token", error=str(e)) span_error("invalid_token", 401) 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_event("google_login_failed", reason="exception", error=str(e)) span_error(str(e), 500) return Response( {"error": f"로그인 처리 중 오류가 발생했습니다: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) class GoogleLinkWithPasswordView(APIView): """ 비밀번호 확인 후 Google 계정 연동 - 일반 가입 사용자가 Google 로그인 시도 시 비밀번호 확인 후 연동 """ def post(self, request): enrich_span(request, operation="auth.google.link_with_password") 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", "") with child_span("validate_input", email=email): if not email or not password or not google_id: span_error("missing_fields", 400) return Response( {"error": "email, password, google_id는 필수입니다."}, status=status.HTTP_400_BAD_REQUEST ) with child_span("find_user", email=email): 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}") span_error("user_not_found", 404) return Response( {"error": "사용자를 찾을 수 없습니다."}, status=status.HTTP_404_NOT_FOUND ) with child_span("verify_password", email=email): # 비밀번호 확인 if not user.check_password(password): logger.warning(f"[GOOGLE LINK] user={email} | status=fail | reason=wrong_password | IP={ip} | UA={ua}") span_error("wrong_password", 401) return Response( {"error": "비밀번호가 일치하지 않습니다."}, status=status.HTTP_401_UNAUTHORIZED ) # 이미 다른 소셜 계정으로 연동된 경우 if user.social_provider and user.social_provider != "google": span_error("already_linked_other", 409) return Response( {"error": f"이미 {user.social_provider} 계정으로 연동되어 있습니다."}, status=status.HTTP_409_CONFLICT ) with child_span("link_google_account", user_id=user.id, google_id=google_id): # 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_event("google_account_linked", email=email) with child_span("generate_token", user_id=user.id): # JWT 토큰 발급 (커스텀 클레임 포함) refresh = CustomTokenObtainPairSerializer.get_token(user) access = refresh.access_token span_success(200) 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): enrich_span(request, request.user, operation="auth.google.link") email, ip, ua = get_request_info(request) user = request.user credential = request.data.get("credential") with child_span("validate_input"): if not credential: span_error("missing_credential", 400) return Response( {"error": "Google credential이 필요합니다."}, status=status.HTTP_400_BAD_REQUEST ) # 이미 Google 연동된 경우 if user.social_provider == "google": span_error("already_linked_google", 409) return Response( {"error": "이미 Google 계정이 연동되어 있습니다."}, status=status.HTTP_409_CONFLICT ) # 다른 소셜 계정으로 연동된 경우 if user.social_provider: span_error("already_linked_other", 409) return Response( {"error": f"이미 {user.social_provider} 계정으로 연동되어 있습니다."}, status=status.HTTP_409_CONFLICT ) if not settings.GOOGLE_CLIENT_ID: span_error("missing_client_id", 500) return Response( {"error": "서버에 Google Client ID가 설정되지 않았습니다."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) try: with child_span("verify_google_token"): # 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", "") span_set_attribute("google.email", google_email) span_set_attribute("google.id", google_id) # 이메일 일치 확인 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}") span_error("email_mismatch", 400) return Response( {"error": "로그인된 계정의 이메일과 Google 이메일이 일치하지 않습니다."}, status=status.HTTP_400_BAD_REQUEST ) with child_span("check_google_already_linked", google_id=google_id): # 해당 Google 계정이 다른 사용자에게 이미 연동되어 있는지 확인 existing_google_user = CustomUser.objects.filter(social_provider="google", social_id=google_id).exclude(id=user.id).first() if existing_google_user: span_error("google_already_linked_other_user", 409) return Response( {"error": "해당 Google 계정은 이미 다른 사용자에게 연동되어 있습니다."}, status=status.HTTP_409_CONFLICT ) with child_span("link_google_account", user_id=user.id): # 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_event("google_account_linked", email=email) span_success(200) 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}") span_error("invalid_token", 401) 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}") span_error(str(e), 500) return Response( {"error": f"연동 처리 중 오류가 발생했습니다: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) class GoogleUnlinkView(APIView): """ Google 계정 연동 해제 - 비밀번호가 설정되어 있어야 연동 해제 가능 (로그인 수단 확보) """ permission_classes = [IsAuthenticated] def post(self, request): enrich_span(request, request.user, operation="auth.google.unlink") email, ip, ua = get_request_info(request) user = request.user with child_span("validate_unlink", user_id=user.id): # Google 연동 확인 if user.social_provider != "google": span_error("not_linked_google", 400) return Response( {"error": "Google 계정이 연동되어 있지 않습니다."}, status=status.HTTP_400_BAD_REQUEST ) # 비밀번호 설정 여부 확인 (소셜 전용 계정인 경우 연동 해제 불가) if not user.has_usable_password(): span_error("no_password_set", 400) return Response( {"error": "비밀번호가 설정되어 있지 않아 연동 해제할 수 없습니다. 먼저 비밀번호를 설정해주세요."}, status=status.HTTP_400_BAD_REQUEST ) with child_span("unlink_google_account", user_id=user.id): # 연동 해제 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_event("google_account_unlinked", email=email) span_success(200) return Response({ "message": "Google 계정 연동이 해제되었습니다.", "social_provider": None, }, status=status.HTTP_200_OK) # ============================================ # 사이트 설정 API # ============================================ class IsAdmin(BasePermission): """admin 등급만 접근 가능""" def has_permission(self, request, view): if not request.user or not request.user.is_authenticated: return False return request.user.grade == 'admin' class SiteSettingsView(APIView): """ 사이트 설정 조회/수정 (관리자 전용) """ def get_permissions(self): # GET은 인증된 사용자면 누구나 가능 (설정 조회) # PUT/PATCH는 관리자만 가능 (설정 변경) if self.request.method in ['PUT', 'PATCH']: return [IsAuthenticated(), IsAdmin()] return [] # GET은 누구나 가능 (비로그인 포함) def get(self, request): """사이트 설정 조회 (공개)""" enrich_span(request, operation="site.settings.get") with child_span("get_settings"): settings_obj = SiteSettings.get_settings() span_event("site_settings_retrieved") span_success(200) return Response({ # 소셜 로그인 설정 "google_login_enabled": settings_obj.google_login_enabled, # 비밀번호 정책 설정 "password_min_length": settings_obj.password_min_length, "password_max_length": settings_obj.password_max_length, "password_require_uppercase": settings_obj.password_require_uppercase, "password_require_lowercase": settings_obj.password_require_lowercase, "password_require_digit": settings_obj.password_require_digit, "password_require_special": settings_obj.password_require_special, "password_special_chars": settings_obj.password_special_chars, "password_expiry_days": settings_obj.password_expiry_days, "password_expiry_warning_days": settings_obj.password_expiry_warning_days, "password_history_count": settings_obj.password_history_count, # 로그인 보안 설정 "login_max_failures": settings_obj.login_max_failures, "login_lockout_minutes": settings_obj.login_lockout_minutes, # 메타 정보 "updated_at": settings_obj.updated_at.isoformat() if settings_obj.updated_at else None, }) def patch(self, request): """사이트 설정 수정 (관리자 전용)""" enrich_span(request, request.user, operation="site.settings.update") email, ip, ua = get_request_info(request) with child_span("get_settings"): settings_obj = SiteSettings.get_settings() # 업데이트할 필드 목록 updatable_fields = [ 'google_login_enabled', 'password_min_length', 'password_max_length', 'password_require_uppercase', 'password_require_lowercase', 'password_require_digit', 'password_require_special', 'password_special_chars', 'password_expiry_days', 'password_expiry_warning_days', 'password_history_count', 'login_max_failures', 'login_lockout_minutes', ] updated_fields = [] for field in updatable_fields: if field in request.data: setattr(settings_obj, field, request.data[field]) updated_fields.append(field) if updated_fields: with child_span("save_settings", fields=str(updated_fields)): settings_obj.save() logger.info( f"[SITE SETTINGS UPDATE] admin={email} | fields={updated_fields} | IP={ip} | UA={ua}" ) span_event("site_settings_updated", admin=email, fields=str(updated_fields)) span_success(200) return Response({ "message": "설정이 저장되었습니다.", # 소셜 로그인 설정 "google_login_enabled": settings_obj.google_login_enabled, # 비밀번호 정책 설정 "password_min_length": settings_obj.password_min_length, "password_max_length": settings_obj.password_max_length, "password_require_uppercase": settings_obj.password_require_uppercase, "password_require_lowercase": settings_obj.password_require_lowercase, "password_require_digit": settings_obj.password_require_digit, "password_require_special": settings_obj.password_require_special, "password_special_chars": settings_obj.password_special_chars, "password_expiry_days": settings_obj.password_expiry_days, "password_expiry_warning_days": settings_obj.password_expiry_warning_days, "password_history_count": settings_obj.password_history_count, # 로그인 보안 설정 "login_max_failures": settings_obj.login_max_failures, "login_lockout_minutes": settings_obj.login_lockout_minutes, # 메타 정보 "updated_at": settings_obj.updated_at.isoformat() if settings_obj.updated_at else None, })