All checks were successful
Build And Test / build-and-push (push) Successful in 2m21s
- LogoutView 추가: refresh token 블랙리스트 처리 - OpenTelemetry instrumentation 확장: requests, logging, dbapi - TRACE_CA_CERT TLS 지원 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2078 lines
88 KiB
Python
2078 lines
88 KiB
Python
# 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,
|
|
})
|