From e318855b14e139314cd5161d905e9219d0dbe6b8 Mon Sep 17 00:00:00 2001 From: icurfer Date: Wed, 14 Jan 2026 01:25:43 +0900 Subject: [PATCH] Add NHN Cloud credentials management and bump version to v0.0.19 - Add NHN Cloud credential fields to User model (tenant_id, username, encrypted password, storage_account) - Add API endpoints for credentials CRUD operations - Implement Fernet encryption for password storage Co-Authored-By: Claude Opus 4.5 --- .../0009_add_nhn_cloud_credentials.py | 33 ++++++ users/models.py | 30 +++++ users/urls.py | 6 +- users/views.py | 111 ++++++++++++++++++ version | 2 +- 5 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 users/migrations/0009_add_nhn_cloud_credentials.py diff --git a/users/migrations/0009_add_nhn_cloud_credentials.py b/users/migrations/0009_add_nhn_cloud_credentials.py new file mode 100644 index 0000000..97ce8f9 --- /dev/null +++ b/users/migrations/0009_add_nhn_cloud_credentials.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.14 on 2026-01-13 15:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0008_add_unique_name'), + ] + + operations = [ + migrations.AddField( + model_name='customuser', + name='encrypted_nhn_api_password', + field=models.BinaryField(blank=True, null=True, verbose_name='NHN Cloud API Password (암호화)'), + ), + migrations.AddField( + model_name='customuser', + name='nhn_storage_account', + field=models.CharField(blank=True, max_length=128, null=True, verbose_name='NHN Cloud Storage Account'), + ), + migrations.AddField( + model_name='customuser', + name='nhn_tenant_id', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='NHN Cloud Tenant ID'), + ), + migrations.AddField( + model_name='customuser', + name='nhn_username', + field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='NHN Cloud Username'), + ), + ] diff --git a/users/models.py b/users/models.py index ffd39b4..184057e 100644 --- a/users/models.py +++ b/users/models.py @@ -71,6 +71,12 @@ class CustomUser(AbstractBaseUser, PermissionsMixin): encrypted_private_key = models.BinaryField(blank=True, null=True) last_used_at = models.DateTimeField(blank=True, null=True, verbose_name="SSH 키 마지막 사용 시각") + # ☁️ 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") + encrypted_nhn_api_password = models.BinaryField(blank=True, null=True, verbose_name="NHN Cloud API Password (암호화)") + nhn_storage_account = models.CharField(max_length=128, blank=True, null=True, verbose_name="NHN Cloud Storage Account") + objects = CustomUserManager() USERNAME_FIELD = 'email' @@ -112,3 +118,27 @@ class CustomUser(AbstractBaseUser, PermissionsMixin): """ self.encrypted_private_key = self.encrypt_private_key(private_key) self.save() + + # ☁️ NHN Cloud API Password 암복호화 + def encrypt_nhn_password(self, password: str) -> bytes: + """NHN Cloud API 비밀번호 암호화""" + cipher = Fernet(self.get_encryption_key()) + return cipher.encrypt(password.encode()) + + def decrypt_nhn_password(self) -> str: + """NHN Cloud API 비밀번호 복호화""" + if self.encrypted_nhn_api_password: + cipher = Fernet(self.get_encryption_key()) + return cipher.decrypt(self.encrypted_nhn_api_password).decode() + return "" + + def save_nhn_credentials(self, tenant_id: str, username: str, password: str, storage_account: str = None): + """NHN Cloud 자격증명 저장""" + self.nhn_tenant_id = tenant_id + self.nhn_username = username + self.encrypted_nhn_api_password = self.encrypt_nhn_password(password) + if storage_account: + self.nhn_storage_account = storage_account + self.save(update_fields=[ + 'nhn_tenant_id', 'nhn_username', 'encrypted_nhn_api_password', 'nhn_storage_account' + ]) diff --git a/users/urls.py b/users/urls.py index 8a36636..f4d0b3b 100644 --- a/users/urls.py +++ b/users/urls.py @@ -2,7 +2,8 @@ from django.urls import path from .views import ( RegisterView, MeView, CustomTokenObtainPairView, SSHKeyUploadView, SSHKeyInfoView, SSHKeyRetrieveView, - UserListView, UserUpdateView + UserListView, UserUpdateView, + NHNCloudCredentialsView, NHNCloudPasswordView ) from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView from .views_jwks import jwks_view # django-jwks @@ -21,4 +22,7 @@ urlpatterns = [ # 관리자용 사용자 관리 API path('users/', UserListView.as_view(), name='user_list'), path('users//', UserUpdateView.as_view(), name='user_update'), + # NHN Cloud 자격증명 API + path('nhn-cloud/', NHNCloudCredentialsView.as_view(), name='nhn_cloud_credentials'), + path('nhn-cloud/password/', NHNCloudPasswordView.as_view(), name='nhn_cloud_password'), ] diff --git a/users/views.py b/users/views.py index 227a177..865949b 100644 --- a/users/views.py +++ b/users/views.py @@ -319,3 +319,114 @@ class UserUpdateView(generics.RetrieveUpdateDestroyAPIView): {"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) diff --git a/version b/version index 4f4d7f3..4c36eb1 100644 --- a/version +++ b/version @@ -1 +1 @@ -v0.0.18 \ No newline at end of file +v0.0.19 \ No newline at end of file