diff --git a/certs/ca.crt b/certs/ca.crt new file mode 100644 index 0000000..ea6e71f --- /dev/null +++ b/certs/ca.crt @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFnzCCA4egAwIBAgIUAVL34d6iRsXVbUaNbTZ66AM61jUwDQYJKoZIhvcNAQEN +BQAwXzELMAkGA1UEBhMCS1IxDjAMBgNVBAgMBVNlb3VsMQ4wDAYDVQQHDAVTZW91 +bDENMAsGA1UECgwEZGVtbzELMAkGA1UECwwCSVQxFDASBgNVBAMMC2ljdXJmZXIu +Y29tMB4XDTI1MDgwMTA3MzQ0N1oXDTM1MDczMDA3MzQ0N1owXzELMAkGA1UEBhMC +S1IxDjAMBgNVBAgMBVNlb3VsMQ4wDAYDVQQHDAVTZW91bDENMAsGA1UECgwEZGVt +bzELMAkGA1UECwwCSVQxFDASBgNVBAMMC2ljdXJmZXIuY29tMIICIjANBgkqhkiG +9w0BAQEFAAOCAg8AMIICCgKCAgEAr8ZwkvMbwydiFZk0dOODJMcXPkuNPvcTAkGg +4yt8TBgHrRaZVxFGz8ExGAd/pzsjcfGo4DI/Fu7t6cYgxkPrd8U12BK6E90H46hS +xOleWAsyUcrnEP2uD358g3K1kZaDc3IS4Fm26JiDsLYkGva1vyjhd+C7gSw8uEbS +yb/8chp3bLbA5qye+4aCkAErTbdcfZCrTibkgL2Va9qeNchGZkumG6PBL7xhNgLH +b4UOWKi+rYFBtIEhwcsWxt+p9yvrreKS7ezSJGqhhuwl3AFqThpGSl7S76i+5Udg +sCJd7I7D3jsIJV+Yjl3UiK3Wk/6Z5fjPgXAoMZfsSEv+kwu/YNcwKwCfMCBnn1xM +MCvdr09b3n4GnzvAtVLTXHunBz5O4Sif4T3SW38N0e1D1+0tXXUrNgPyCQGT0Oxn +fJgol/L7ngVEvQZSMP17GyH+Z1Waz9vL9fHp24g/T10BZP5zuJuVcM7F7LDDAlp3 +/5J0+iUtZf1x45vYeJbbyg8/44IVmzqhapHFEMSI45R4l50ZnqSc4BGqIitg25Vy +xO4UathfyCaBeG76Jt+yls9sIdOjM0OEVBNCZqacTwSCTJoCd/ElMihXGVmVtI9g +WlOKys41jSNNrDgG+h7N4d5Ev9LvjTgJrxty89xkwPPAqd36NAxdJa9pnEdsE7rc +Tc1uLe0CAwEAAaNTMFEwHQYDVR0OBBYEFDupm0q0frmoFp8gCnCQhrMkqpfGMB8G +A1UdIwQYMBaAFDupm0q0frmoFp8gCnCQhrMkqpfGMA8GA1UdEwEB/wQFMAMBAf8w +DQYJKoZIhvcNAQENBQADggIBAGD84D7/pAJ8RHfxNuaBwum+osHs9UWRUeiRP2Jk +iNvhdYmgl/iLfS92CCn57Vttg46SXGUK8M91W6y4KDqBMeA2DS4/1rHIcLqZ2hpK +SjGRknSqC5CHC9w6fAErmxsEG+uIPmf6/Hl2SzUfyV0L9gdqGVorroQM5FnGvcpZ +VVgIu6dfGNCssZhBlzSznoHqqp7JfjOrg1OsJUeCngYRPitdm4cIkvZ2lR7qC4aN +dtzTypRx2xgq9C/23WC2iPiRAi8m6acu1iWT8KgP4YN2DNx1ISIokyMhuzpWxwmg +v1Hqh87bXqNeJJKe3DIoD4AePw46Mogby8yvVcq1s8Woebdm4bLfrghF3atH+UMG +0eEg2xyEGy4S7MB97+7G+hbb9DA+xu7G1fLI4ZwW0cxhSO+GXNAM90VSKdrkwfve +VR2QyMxY9jj0Iuf2pQjdjStZXdheIP5LVSyBK1i6KBl3kjIT3XKOzwzxu/Ndv87a +wpZqU16Wciyme/Xaq7m0SY4WPWZtc8+mP4XwMoh7Q7OB2sgU0L1j6PEPx8nYdXav +50QGpi43hPtlZVTWnRTNgFKWmOE3TWpwpATNOTP/CzxF6CAQFqW7SKYpiZ9YHdk1 +uitJXaHZCMx12rYEeylZJh+ioKIkZz5jyYWb7HzMEPrgayuPudLhbnVa8QVPGsGH +7aRY +-----END CERTIFICATE----- \ No newline at end of file diff --git a/convert_spans.py b/convert_spans.py new file mode 100644 index 0000000..cc178be --- /dev/null +++ b/convert_spans.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""views.py의 span 패턴을 새로운 방식으로 변환""" +import re + +with open('users/views.py', 'r') as f: + content = f.read() + +# 1. set_span_attributes(span, request, ...) -> enrich_span(request, ...) +content = re.sub( + r'set_span_attributes\(span, request, request\.user\)', + 'enrich_span(request, request.user)', + content +) +content = re.sub( + r'set_span_attributes\(span, request\)', + 'enrich_span(request)', + content +) + +# 2. span.add_event(...) -> span_event(...) +content = re.sub( + r'span\.add_event\(', + 'span_event(', + content +) + +# 3. span.set_attribute(...) -> span_set_attribute(...) +content = re.sub( + r'span\.set_attribute\(', + 'span_set_attribute(', + content +) + +# 4. with tracer.start_as_current_span("...") as span: 패턴 변환 +# 여러 줄에 걸친 패턴도 처리 +def replace_span_block(match): + indent = match.group(1) + span_name = match.group(2) + # operation 이름 추출 (공백, 따옴표 제거) + op_name = span_name.strip().strip('"\'') + # 짧은 이름으로 변환 + op_name = op_name.replace(" POST", ".post").replace(" GET", ".get") + op_name = op_name.replace(" PUT", ".put").replace(" PATCH", ".patch") + op_name = op_name.replace(" DELETE", ".delete") + op_name = op_name.replace("View", "").lower() + return f'{indent}enrich_span(request, operation="{op_name}")' + +# 단일 줄 패턴 +content = re.sub( + r'^(\s*)with tracer\.start_as_current_span\(([^)]+)\) as span:\s*(?:#.*)?$', + replace_span_block, + content, + flags=re.MULTILINE +) + +# 여러 줄에 걸친 패턴 (줄바꿈 포함) +content = re.sub( + r'^(\s*)with tracer\.start_as_current_span\(\s*\n\s*([^)]+)\s*\) as span:\s*(?:#.*)?$', + replace_span_block, + content, + flags=re.MULTILINE +) + +with open('users/views.py', 'w') as f: + f.write(content) + +print("변환 완료") diff --git a/users/views.py.bak b/users/views.py.bak new file mode 100644 index 0000000..fa98db3 --- /dev/null +++ b/users/views.py.bak @@ -0,0 +1,1814 @@ +# 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 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) + + +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 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 + + with tracer.start_as_current_span("auth.password.change") as span: + set_span_attributes(span, request, request.user) + email, ip, ua = get_request_info(request) + user = request.user + 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() + + # 필수 필드 확인 + if not is_social_only and not current_password: + return Response( + {"error": "현재 비밀번호를 입력해주세요."}, + status=status.HTTP_400_BAD_REQUEST + ) + + if 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 + ) + + # 비밀번호 정책 검증 + is_valid, errors = validate_password_policy(new_password, site_settings) + if not is_valid: + return Response( + {"error": errors[0], "errors": errors}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 현재 비밀번호 확인 (소셜 로그인 전용이 아닌 경우) + if not is_social_only: + 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 + ) + + # 비밀번호 이력 검사 (재사용 방지) + if site_settings.password_history_count > 0: + history_valid, history_error = check_password_history( + user, new_password, site_settings.password_history_count + ) + if not history_valid: + return Response( + {"error": history_error}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 현재 비밀번호를 이력에 저장 (변경 전) + if user.has_usable_password() and site_settings.password_history_count > 0: + # 현재 비밀번호 해시 저장 + from .models import PasswordHistory + PasswordHistory.objects.create( + user=user, + password_hash=user.password + ) + + # 새 비밀번호 설정 + 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.add_event(f"Password {action}", attributes={"email": email}) + + 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 + + with tracer.start_as_current_span("ExtendPasswordExpiryView POST") as span: + email, ip, ua = get_request_info(request) + user = request.user + site_settings = SiteSettings.get_settings() + + # 만료 정책이 비활성화된 경우 + if site_settings.password_expiry_days <= 0: + return Response( + {"error": "비밀번호 만료 정책이 비활성화되어 있습니다."}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 비밀번호 변경일을 현재 시간으로 업데이트 (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.add_event("Password expiry extended", attributes={"email": email, "new_expiry": str(new_expiry_date.date())}) + + 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 = get_current_span() + if span and span.is_recording(): + span.set_attribute("auth.email", email) + + logger.info(f"[LOGIN] user={email} | status=attempt | IP={ip} | UA={ua}") + response = super().post(request, *args, **kwargs) + + if response.status_code == 200: + 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): + with tracer.start_as_current_span( + "SSHKeyUploadView POST" + ) as span: # ✅ Span 생성 + email, ip, ua = get_request_info(request) + private_key = request.data.get("private_key") + key_name = request.data.get("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}" + ) + return Response( + {"error": "private_key와 key_name 모두 필요합니다."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + user = request.user + user.save_private_key(private_key) + user.encrypted_private_key_name = key_name + 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.add_event( + "SSH key saved", attributes={"email": email, "key_name": key_name} + ) # ✅ + 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}" + ) + return Response( + {"error": f"암호화 또는 저장 실패: {str(e)}"}, status=500 + ) + + def delete(self, request): + with tracer.start_as_current_span( + "SSHKeyUploadView DELETE" + ) as span: # ✅ Span 생성 + email, ip, ua = get_request_info(request) + 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.add_event( + "SSH key deleted", attributes={"email": email} + ) # ✅ Jaeger 이벤트 등록 + return Response({"message": "SSH key deleted."}, status=200) + + +class SSHKeyInfoView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + with tracer.start_as_current_span("SSHKeyInfoView GET") as span: # ✅ Span 생성 + email, ip, ua = get_request_info(request) + logger.debug(f"[SSH INFO] user={email} | IP={ip} | UA={ua}") + user = request.user + span.add_event( + "SSH key info retrieved", attributes={"email": email} + ) # ✅ Jaeger 이벤트 등록 + return Response( + { + "has_key": bool(user.encrypted_private_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): + with tracer.start_as_current_span( + "SSHKeyRetrieveView GET" + ) as span: # ✅ Span 생성 + email, ip, ua = get_request_info(request) + user = request.user + if not user.encrypted_private_key: + logger.warning( + f"[SSH RETRIEVE] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" + ) + span.add_event( + "SSH key retrieve failed", + attributes={"email": email, "reason": "not_found"}, + ) # ✅ + return Response( + {"error": "SSH 키가 등록되어 있지 않습니다."}, status=404 + ) + + try: + decrypted_key = user.decrypt_private_key() + logger.info( + f"[SSH RETRIEVE] user={email} | status=success | IP={ip} | UA={ua}" + ) + span.add_event("SSH key retrieved", attributes={"email": email}) # ✅ + 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.add_event( + "SSH key retrieve failed", + attributes={"email": email, "reason": str(e)}, + ) # ✅ + 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): + with tracer.start_as_current_span("UserListView GET") as span: + email, ip, ua = get_request_info(request) + logger.info(f"[USER LIST] admin={email} | IP={ip} | UA={ua}") + span.add_event("User list retrieved", attributes={"admin": email}) + return super().list(request, *args, **kwargs) + + +class UserUpdateView(generics.RetrieveUpdateDestroyAPIView): + """사용자 상태 수정/삭제 (관리자 전용)""" + queryset = CustomUser.objects.all() + serializer_class = UserListSerializer + permission_classes = [IsAuthenticated, IsAdminOrManager] + + def partial_update(self, request, *args, **kwargs): + with tracer.start_as_current_span("UserUpdateView PATCH") as span: + admin_email, ip, ua = get_request_info(request) + instance = self.get_object() + target_email = instance.email + + update_fields = [] + actions = [] + + # is_active 수정 + is_active = request.data.get('is_active') + if is_active is not None: + 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: + valid_grades = ['admin', 'manager', 'user'] + if grade not in valid_grades: + return Response( + {"error": f"유효하지 않은 등급입니다. 가능한 값: {valid_grades}"}, + status=status.HTTP_400_BAD_REQUEST + ) + # admin 등급 부여는 admin만 가능 (manager는 불가) + if grade == 'admin' and request.user.grade != 'admin': + return Response( + {"error": "admin 등급 부여는 admin만 가능합니다."}, + status=status.HTTP_403_FORBIDDEN + ) + # 자기 자신의 등급 하향 방지 (실수로 관리자 권한 잃는 것 방지) + if request.user.id == instance.id and instance.grade == 'admin' and grade != 'admin': + 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: + 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( + f"[USER UPDATE] admin={admin_email} | target={target_email} | actions={actions} | IP={ip} | UA={ua}" + ) + span.add_event( + "User updated", + attributes={"admin": admin_email, "target": target_email, "actions": str(actions)} + ) + + serializer = self.get_serializer(instance) + return Response(serializer.data) + + def destroy(self, request, *args, **kwargs): + with tracer.start_as_current_span("UserUpdateView DELETE") as span: + admin_email, ip, ua = get_request_info(request) + instance = self.get_object() + target_email = instance.email + + # 자기 자신은 삭제 불가 + if request.user.id == instance.id: + return Response( + {"error": "자기 자신의 계정은 삭제할 수 없습니다."}, + status=status.HTTP_400_BAD_REQUEST + ) + + logger.info( + f"[USER DELETE] admin={admin_email} | target={target_email} | IP={ip} | UA={ua}" + ) + span.add_event( + "User deleted", + attributes={"admin": admin_email, "target": target_email} + ) + + instance.delete() + 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): + """자격증명 조회 (비밀번호 제외)""" + with tracer.start_as_current_span("NHNCloudCredentialsView GET") as span: + email, ip, ua = get_request_info(request) + user = request.user + logger.debug(f"[NHN CREDENTIALS GET] user={email} | IP={ip} | UA={ua}") + span.add_event("NHN credentials retrieved", attributes={"email": email}) + + return Response({ + "has_credentials": bool(user.nhn_tenant_id and user.encrypted_nhn_api_password), + "tenant_id": user.nhn_tenant_id or "", + "username": user.nhn_username or "", + "storage_account": user.nhn_storage_account or "", + }) + + def post(self, request): + """자격증명 저장""" + with tracer.start_as_current_span("NHNCloudCredentialsView POST") as span: + 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", "") + + 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}" + ) + return Response( + {"error": "tenant_id, username, password는 필수입니다."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + 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.add_event("NHN credentials saved", attributes={"email": email}) + 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}" + ) + return Response({"error": f"저장 실패: {str(e)}"}, status=500) + + def delete(self, request): + """자격증명 삭제""" + with tracer.start_as_current_span("NHNCloudCredentialsView DELETE") as span: + email, ip, ua = get_request_info(request) + user = request.user + + 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.add_event("NHN credentials deleted", attributes={"email": email}) + return Response({"message": "NHN Cloud 자격증명이 삭제되었습니다."}) + + +class NHNCloudPasswordView(APIView): + """NHN Cloud API 비밀번호 조회 (복호화) - msa-django-nhn에서 사용""" + permission_classes = [IsAuthenticated] + + def get(self, request): + """복호화된 비밀번호 조회""" + with tracer.start_as_current_span("NHNCloudPasswordView GET") as span: + email, ip, ua = get_request_info(request) + user = request.user + + if not user.encrypted_nhn_api_password: + logger.warning( + f"[NHN PASSWORD GET] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" + ) + return Response( + {"error": "NHN Cloud 자격증명이 등록되어 있지 않습니다."}, + status=404 + ) + + try: + decrypted_password = user.decrypt_nhn_password() + logger.info(f"[NHN PASSWORD GET] user={email} | status=success | IP={ip} | UA={ua}") + span.add_event("NHN password retrieved", attributes={"email": email}) + 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}" + ) + return Response({"error": f"복호화 실패: {str(e)}"}, status=500) + + +# ============================================ +# NHN Cloud 프로젝트 관리 API (멀티 프로젝트 지원) +# ============================================ + +class NHNCloudProjectListView(APIView): + """NHN Cloud 프로젝트 목록 조회 및 추가""" + permission_classes = [IsAuthenticated] + + def get(self, request): + """프로젝트 목록 조회""" + with tracer.start_as_current_span("NHNCloudProjectListView GET") as span: + email, ip, ua = get_request_info(request) + projects = NHNCloudProject.objects.filter(user=request.user) + + logger.debug(f"[NHN PROJECT LIST] user={email} | count={projects.count()} | IP={ip} | UA={ua}") + span.add_event("NHN projects listed", attributes={"email": email, "count": projects.count()}) + + 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): + """프로젝트 추가""" + with tracer.start_as_current_span("NHNCloudProjectListView POST") as span: + 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", "") + + 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}" + ) + return Response( + {"error": "name, tenant_id, username, password는 필수입니다."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # 중복 체크 + 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}" + ) + return Response( + {"error": "이미 등록된 Tenant ID입니다."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + # 첫 프로젝트면 자동 활성화 + is_first = not NHNCloudProject.objects.filter(user=request.user).exists() + + 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() + + logger.info( + f"[NHN PROJECT CREATE] user={email} | project={name} | is_active={is_first} | IP={ip} | UA={ua}" + ) + span.add_event("NHN project created", attributes={"email": email, "project": name}) + + 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}" + ) + 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): + """프로젝트 상세 조회""" + with tracer.start_as_current_span("NHNCloudProjectDetailView GET") as span: + email, ip, ua = get_request_info(request) + + project = self.get_project(request, project_id) + if not project: + return Response({"error": "프로젝트를 찾을 수 없습니다."}, status=404) + + span.add_event("NHN project retrieved", attributes={"email": email, "project": project.name}) + + 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): + """프로젝트 수정""" + with tracer.start_as_current_span("NHNCloudProjectDetailView PUT") as span: + email, ip, ua = get_request_info(request) + + 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}" + ) + 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") + + # 필수 필드 검증 + if not name or not tenant_id or not username: + return Response( + {"error": "name, tenant_id, username은 필수입니다."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # tenant_id 중복 체크 (자기 자신 제외) + if NHNCloudProject.objects.filter( + user=request.user, tenant_id=tenant_id + ).exclude(id=project_id).exists(): + return Response( + {"error": "이미 등록된 Tenant ID입니다."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + 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.add_event("NHN project updated", attributes={"email": email, "project": name}) + + 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}" + ) + return Response({"error": f"프로젝트 수정 실패: {str(e)}"}, status=500) + + def delete(self, request, project_id): + """프로젝트 삭제""" + with tracer.start_as_current_span("NHNCloudProjectDetailView DELETE") as span: + email, ip, ua = get_request_info(request) + + 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}" + ) + return Response({"error": "프로젝트를 찾을 수 없습니다."}, status=404) + + project_name = project.name + was_active = project.is_active + project.delete() + + # 삭제된 프로젝트가 활성이었으면 다른 프로젝트 활성화 + if was_active: + 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.add_event("NHN project deleted", attributes={"email": email, "project": project_name}) + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class NHNCloudProjectActivateView(APIView): + """NHN Cloud 프로젝트 활성화""" + permission_classes = [IsAuthenticated] + + def patch(self, request, project_id): + """프로젝트 활성화 (기존 활성 해제)""" + with tracer.start_as_current_span("NHNCloudProjectActivateView PATCH") as span: + email, ip, ua = get_request_info(request) + + 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}" + ) + return Response({"error": "프로젝트를 찾을 수 없습니다."}, status=404) + + # 기존 활성 프로젝트 비활성화 + NHNCloudProject.objects.filter(user=request.user, is_active=True).update(is_active=False) + + # 새 프로젝트 활성화 + 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.add_event("NHN project activated", attributes={"email": email, "project": project.name}) + + return Response({ + "message": "프로젝트가 활성화되었습니다.", + "id": project.id, + "name": project.name, + }) + + +class NHNCloudProjectPasswordView(APIView): + """NHN Cloud 프로젝트 비밀번호 조회 (복호화)""" + permission_classes = [IsAuthenticated] + + def get(self, request, project_id): + """복호화된 비밀번호 조회""" + with tracer.start_as_current_span("NHNCloudProjectPasswordView GET") as span: + email, ip, ua = get_request_info(request) + + 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}" + ) + return Response({"error": "프로젝트를 찾을 수 없습니다."}, status=404) + + try: + decrypted_password = project.decrypt_password() + logger.info(f"[NHN PROJECT PASSWORD] user={email} | project={project.name} | IP={ip} | UA={ua}") + span.add_event("NHN project password retrieved", attributes={"email": email, "project": project.name}) + + 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}" + ) + return Response({"error": f"복호화 실패: {str(e)}"}, status=500) + + +# ============================================ +# KVM 서버 관리 API (멀티 서버 지원) +# ============================================ + +class KVMServerListView(APIView): + """KVM 서버 목록 조회 및 추가""" + permission_classes = [IsAuthenticated] + + def get(self, request): + """서버 목록 조회""" + with tracer.start_as_current_span("KVMServerListView GET") as span: + email, ip, ua = get_request_info(request) + servers = KVMServer.objects.filter(user=request.user) + + logger.debug(f"[KVM SERVER LIST] user={email} | count={servers.count()} | IP={ip} | UA={ua}") + span.add_event("KVM servers listed", attributes={"email": email, "count": servers.count()}) + + serializer = KVMServerSerializer(servers, many=True) + return Response(serializer.data) + + def post(self, request): + """서버 추가""" + with tracer.start_as_current_span("KVMServerListView POST") as span: + email, ip, ua = get_request_info(request) + + # 중복 체크 + host = request.data.get("host") + port = request.data.get("port", 22) + 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}" + ) + return Response( + {"error": "이미 등록된 서버입니다 (동일한 호스트:포트)."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = KVMServerSerializer(data=request.data) + if serializer.is_valid(): + server = serializer.save(user=request.user) + logger.info( + f"[KVM SERVER CREATE] user={email} | server={server.name} | host={server.host} | IP={ip} | UA={ua}" + ) + span.add_event("KVM server created", attributes={"email": email, "server": server.name}) + 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}" + ) + 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): + """서버 상세 조회""" + with tracer.start_as_current_span("KVMServerDetailView GET") as span: + email, ip, ua = get_request_info(request) + + server = self.get_server(request, server_id) + if not server: + return Response({"error": "서버를 찾을 수 없습니다."}, status=404) + + span.add_event("KVM server retrieved", attributes={"email": email, "server": server.name}) + return Response(KVMServerSerializer(server).data) + + def put(self, request, server_id): + """서버 수정""" + with tracer.start_as_current_span("KVMServerDetailView PUT") as span: + email, ip, ua = get_request_info(request) + + 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}" + ) + return Response({"error": "서버를 찾을 수 없습니다."}, status=404) + + # 호스트:포트 중복 체크 (자기 자신 제외) + host = request.data.get("host", server.host) + port = request.data.get("port", server.port) + if KVMServer.objects.filter( + user=request.user, host=host, port=port + ).exclude(id=server_id).exists(): + return Response( + {"error": "이미 등록된 서버입니다 (동일한 호스트:포트)."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = KVMServerSerializer(server, data=request.data, partial=True) + if serializer.is_valid(): + server = serializer.save() + logger.info( + f"[KVM SERVER UPDATE] user={email} | server={server.name} | IP={ip} | UA={ua}" + ) + span.add_event("KVM server updated", attributes={"email": email, "server": server.name}) + return Response(KVMServerSerializer(server).data) + + logger.warning( + f"[KVM SERVER UPDATE] user={email} | status=fail | reason=validation | IP={ip} | UA={ua}" + ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, server_id): + """서버 삭제""" + with tracer.start_as_current_span("KVMServerDetailView DELETE") as span: + email, ip, ua = get_request_info(request) + + 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}" + ) + return Response({"error": "서버를 찾을 수 없습니다."}, status=404) + + server_name = server.name + server.delete() + + logger.info( + f"[KVM SERVER DELETE] user={email} | server={server_name} | IP={ip} | UA={ua}" + ) + span.add_event("KVM server deleted", attributes={"email": email, "server": server_name}) + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class KVMServerActivateView(APIView): + """KVM 서버 활성화/비활성화""" + permission_classes = [IsAuthenticated] + + def patch(self, request, server_id): + """서버 활성화 상태 토글""" + with tracer.start_as_current_span("KVMServerActivateView PATCH") as span: + email, ip, ua = get_request_info(request) + + 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}" + ) + return Response({"error": "서버를 찾을 수 없습니다."}, status=404) + + # 요청에서 is_active 값 가져오기 (없으면 토글) + is_active = request.data.get('is_active') + if is_active is None: + server.is_active = not server.is_active + else: + server.is_active = 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.add_event(f"KVM server {action}", attributes={"email": email, "server": server.name}) + + 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 키와 접속 정보 조회""" + with tracer.start_as_current_span("KVMServerSSHKeyView GET") as span: + email, ip, ua = get_request_info(request) + + 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}" + ) + return Response({"error": "서버를 찾을 수 없습니다."}, status=404) + + 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}" + ) + return Response( + {"error": "SSH 키가 등록되어 있지 않습니다."}, + status=404 + ) + + try: + decrypted_key = server.decrypt_private_key() + logger.info( + f"[KVM SERVER SSH KEY] user={email} | server={server.name} | IP={ip} | UA={ua}" + ) + span.add_event("KVM server SSH key retrieved", attributes={"email": email, "server": server.name}) + + 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}" + ) + 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 키 업로드""" + with tracer.start_as_current_span("KVMServerSSHKeyUploadView POST") as span: + email, ip, ua = get_request_info(request) + + server = self.get_server(request, server_id) + if not server: + return Response({"error": "서버를 찾을 수 없습니다."}, status=404) + + private_key = request.data.get("private_key") + key_name = request.data.get("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}" + ) + return Response( + {"error": "private_key는 필수입니다."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + 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.add_event("KVM server SSH key uploaded", attributes={"email": email, "server": server.name}) + 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}" + ) + return Response({"error": f"저장 실패: {str(e)}"}, status=500) + + def delete(self, request, server_id): + """SSH 키 삭제""" + with tracer.start_as_current_span("KVMServerSSHKeyUploadView DELETE") as span: + email, ip, ua = get_request_info(request) + + server = self.get_server(request, server_id) + if not server: + return Response({"error": "서버를 찾을 수 없습니다."}, status=404) + + 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.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=False, # 관리자 승인 필요 + ) + # 비밀번호 없이 사용 불가하게 설정 + user.set_unusable_password() + user.save() + logger.info(f"[GOOGLE LOGIN] user={email} | status=new_user_created_pending | IP={ip} | UA={ua}") + + # 승인 대기 응답 반환 + span.add_event("Google login - pending approval", attributes={"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}") + return Response( + {"error": "비활성화된 계정입니다. 관리자에게 문의하세요."}, + status=status.HTTP_403_FORBIDDEN + ) + + # JWT 토큰 발급 (커스텀 클레임 포함) + refresh = CustomTokenObtainPairSerializer.get_token(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 = CustomTokenObtainPairSerializer.get_token(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) + + +# ============================================ +# 사이트 설정 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): + """사이트 설정 조회 (공개)""" + with tracer.start_as_current_span("SiteSettingsView GET") as span: + settings_obj = SiteSettings.get_settings() + span.add_event("Site settings retrieved") + + 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): + """사이트 설정 수정 (관리자 전용)""" + with tracer.start_as_current_span("SiteSettingsView PATCH") as span: + email, ip, ua = get_request_info(request) + 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: + settings_obj.save() + logger.info( + f"[SITE SETTINGS UPDATE] admin={email} | fields={updated_fields} | IP={ip} | UA={ua}" + ) + span.add_event("Site settings updated", attributes={ + "admin": email, + "fields": str(updated_fields) + }) + + 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, + })