diff --git a/users/migrations/0016_customuser_encrypted_upbit_access_key_and_more.py b/users/migrations/0016_customuser_encrypted_upbit_access_key_and_more.py new file mode 100644 index 0000000..9ce4b5a --- /dev/null +++ b/users/migrations/0016_customuser_encrypted_upbit_access_key_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.11 on 2026-02-16 01:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0015_customuser_locked_until_customuser_login_failures_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customuser', + name='encrypted_upbit_access_key', + field=models.BinaryField(blank=True, null=True, verbose_name='Upbit Access Key (암호화)'), + ), + migrations.AddField( + model_name='customuser', + name='encrypted_upbit_secret_key', + field=models.BinaryField(blank=True, null=True, verbose_name='Upbit Secret Key (암호화)'), + ), + ] diff --git a/users/models.py b/users/models.py index d10bf52..df55796 100644 --- a/users/models.py +++ b/users/models.py @@ -141,6 +141,10 @@ class CustomUser(AbstractBaseUser, PermissionsMixin): social_id = models.CharField(max_length=255, blank=True, null=True, verbose_name="소셜 고유 ID") profile_image = models.URLField(max_length=500, blank=True, null=True, verbose_name="프로필 이미지 URL") + # 📈 Upbit API 자격증명 필드 + encrypted_upbit_access_key = models.BinaryField(blank=True, null=True, verbose_name="Upbit Access Key (암호화)") + encrypted_upbit_secret_key = models.BinaryField(blank=True, null=True, verbose_name="Upbit Secret Key (암호화)") + # ☁️ NHN Cloud 자격증명 필드 nhn_tenant_id = models.CharField(max_length=64, blank=True, null=True, verbose_name="NHN Cloud Tenant ID") nhn_username = models.EmailField(blank=True, null=True, verbose_name="NHN Cloud Username") @@ -218,6 +222,38 @@ class CustomUser(AbstractBaseUser, PermissionsMixin): 'nhn_tenant_id', 'nhn_username', 'encrypted_nhn_api_password', 'nhn_storage_account' ]) + # 📈 Upbit API 키 암복호화 + def encrypt_upbit_key(self, key: str) -> bytes: + """Upbit API 키 암호화""" + cipher = Fernet(self.get_encryption_key()) + return cipher.encrypt(key.encode()) + + def decrypt_upbit_access_key(self) -> str: + """Upbit Access Key 복호화""" + if self.encrypted_upbit_access_key: + cipher = Fernet(self.get_encryption_key()) + return cipher.decrypt(self.encrypted_upbit_access_key).decode() + return "" + + def decrypt_upbit_secret_key(self) -> str: + """Upbit Secret Key 복호화""" + if self.encrypted_upbit_secret_key: + cipher = Fernet(self.get_encryption_key()) + return cipher.decrypt(self.encrypted_upbit_secret_key).decode() + return "" + + def save_upbit_credentials(self, access_key: str, secret_key: str): + """Upbit API 자격증명 저장""" + self.encrypted_upbit_access_key = self.encrypt_upbit_key(access_key) + self.encrypted_upbit_secret_key = self.encrypt_upbit_key(secret_key) + self.save(update_fields=['encrypted_upbit_access_key', 'encrypted_upbit_secret_key']) + + def clear_upbit_credentials(self): + """Upbit API 자격증명 삭제""" + self.encrypted_upbit_access_key = None + self.encrypted_upbit_secret_key = None + self.save(update_fields=['encrypted_upbit_access_key', 'encrypted_upbit_secret_key']) + # ============================================ # NHN Cloud 프로젝트 (멀티 프로젝트 지원) diff --git a/users/urls.py b/users/urls.py index be8e0fc..6346713 100644 --- a/users/urls.py +++ b/users/urls.py @@ -14,6 +14,8 @@ from .views import ( GoogleLoginView, GoogleLinkWithPasswordView, GoogleLinkView, GoogleUnlinkView, # 사이트 설정 SiteSettingsView, + # Upbit API 자격증명 + UpbitCredentialsView, UpbitSecretKeyView, ) from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView from .views_jwks import jwks_view # django-jwks @@ -56,4 +58,7 @@ urlpatterns = [ path('auth/google/unlink/', GoogleUnlinkView.as_view(), name='google_unlink'), # 사이트 설정 path('settings/', SiteSettingsView.as_view(), name='site_settings'), + # Upbit API 자격증명 + path('upbit/', UpbitCredentialsView.as_view(), name='upbit_credentials'), + path('upbit/keys/', UpbitSecretKeyView.as_view(), name='upbit_keys'), ] diff --git a/users/views.py b/users/views.py index bf6858f..1b177e1 100644 --- a/users/views.py +++ b/users/views.py @@ -2075,3 +2075,109 @@ class SiteSettingsView(APIView): # 메타 정보 "updated_at": settings_obj.updated_at.isoformat() if settings_obj.updated_at else None, }) + + +# ============================================ +# 📈 Upbit API 자격증명 관리 +# ============================================ + +class UpbitCredentialsView(APIView): + """Upbit API 자격증명 저장/조회/삭제""" + permission_classes = [IsAuthenticated] + + def get(self, request): + """자격증명 조회 (키 마스킹)""" + enrich_span(request, request.user, operation="upbit.credentials.get") + email, ip, ua = get_request_info(request) + user = request.user + + with child_span("get_credentials", user_id=user.id): + has_credentials = bool(user.encrypted_upbit_access_key and user.encrypted_upbit_secret_key) + access_key_masked = "" + if has_credentials: + try: + ak = user.decrypt_upbit_access_key() + access_key_masked = ak[:4] + "****" + ak[-4:] if len(ak) > 8 else "****" + except Exception: + access_key_masked = "****" + + span_success(200) + return Response({ + "has_credentials": has_credentials, + "access_key": access_key_masked, + }) + + def post(self, request): + """자격증명 저장""" + enrich_span(request, request.user, operation="upbit.credentials.save") + email, ip, ua = get_request_info(request) + user = request.user + + access_key = request.data.get("access_key") + secret_key = request.data.get("secret_key") + + if not access_key or not secret_key: + span_error("missing_fields", 400) + return Response( + {"error": "access_key, secret_key는 필수입니다."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + with child_span("encrypt_and_save"): + user.save_upbit_credentials(access_key, secret_key) + + logger.info(f"[UPBIT CREDENTIALS SAVE] user={email} | status=success | IP={ip}") + span_success(201) + return Response({"message": "Upbit API 키가 저장되었습니다."}, status=201) + except Exception as e: + logger.exception(f"[UPBIT CREDENTIALS SAVE] user={email} | status=fail | IP={ip}") + span_error(str(e), 500) + return Response({"error": f"저장 실패: {str(e)}"}, status=500) + + def delete(self, request): + """자격증명 삭제""" + enrich_span(request, request.user, operation="upbit.credentials.delete") + email, ip, ua = get_request_info(request) + user = request.user + + with child_span("delete_credentials", user_id=user.id): + user.clear_upbit_credentials() + + logger.info(f"[UPBIT CREDENTIALS DELETE] user={email} | status=success | IP={ip}") + span_success(200) + return Response({"message": "Upbit API 키가 삭제되었습니다."}) + + +class UpbitSecretKeyView(APIView): + """Upbit API 키 조회 (복호화) - msa-django-upbit에서 호출용""" + permission_classes = [IsAuthenticated] + + def get(self, request): + """복호화된 API 키 조회""" + enrich_span(request, request.user, operation="upbit.keys.get") + email, ip, ua = get_request_info(request) + user = request.user + + if not user.encrypted_upbit_access_key or not user.encrypted_upbit_secret_key: + span_error("not_found", 404) + return Response( + {"error": "Upbit API 키가 등록되어 있지 않습니다."}, + status=404 + ) + + try: + with child_span("decrypt_keys"): + access_key = user.decrypt_upbit_access_key() + secret_key = user.decrypt_upbit_secret_key() + + logger.info(f"[UPBIT KEYS GET] user={email} | status=success | IP={ip}") + span_success(200) + return Response({ + "access_key": access_key, + "secret_key": secret_key, + }) + except Exception as e: + logger.exception(f"[UPBIT KEYS GET] user={email} | status=fail | IP={ip}") + span_error(str(e), 500) + return Response({"error": f"키 복호화 실패: {str(e)}"}, status=500) diff --git a/version b/version index a9914f8..a8934c4 100644 --- a/version +++ b/version @@ -1 +1 @@ -v0.0.35 \ No newline at end of file +v0.0.36 \ No newline at end of file