ssh key저장 기능 추가
	
		
			
	
		
	
	
		
	
		
			All checks were successful
		
		
	
	
		
			
				
	
				Build And Test / build-and-push (push) Successful in 1m57s
				
			
		
		
	
	
				
					
				
			
		
			All checks were successful
		
		
	
	Build And Test / build-and-push (push) Successful in 1m57s
				
			This commit is contained in:
		@ -20,6 +20,7 @@ RUN apt-get clean
 | 
			
		||||
# install python dependencies
 | 
			
		||||
RUN pip install --upgrade pip 
 | 
			
		||||
RUN pip install -r requirements.txt
 | 
			
		||||
RUN pip install gunicorn==20.1.0
 | 
			
		||||
 | 
			
		||||
# collect static files
 | 
			
		||||
# RUN python manage.py collectstatic --noinput
 | 
			
		||||
 | 
			
		||||
@ -1,13 +1,16 @@
 | 
			
		||||
asgiref==3.8.1
 | 
			
		||||
certifi==2025.1.31
 | 
			
		||||
cffi==1.17.1
 | 
			
		||||
charset-normalizer==3.4.1
 | 
			
		||||
coreapi==2.3.3
 | 
			
		||||
coreschema==0.0.4
 | 
			
		||||
cryptography==45.0.2
 | 
			
		||||
Django==4.2.14
 | 
			
		||||
django-cors-headers==4.7.0
 | 
			
		||||
djangorestframework==3.16.0
 | 
			
		||||
djangorestframework_simplejwt==5.5.0
 | 
			
		||||
drf-yasg==1.21.10
 | 
			
		||||
gunicorn==20.1.0
 | 
			
		||||
idna==3.10
 | 
			
		||||
inflection==0.5.1
 | 
			
		||||
itypes==1.2.0
 | 
			
		||||
@ -15,6 +18,7 @@ Jinja2==3.1.6
 | 
			
		||||
MarkupSafe==3.0.2
 | 
			
		||||
mysqlclient==2.2.7
 | 
			
		||||
packaging==25.0
 | 
			
		||||
pycparser==2.22
 | 
			
		||||
PyJWT==2.9.0
 | 
			
		||||
python-dotenv==1.0.1
 | 
			
		||||
pytz==2025.2
 | 
			
		||||
@ -24,4 +28,3 @@ sqlparse==0.5.3
 | 
			
		||||
typing_extensions==4.13.2
 | 
			
		||||
uritemplate==4.1.1
 | 
			
		||||
urllib3==2.4.0
 | 
			
		||||
gunicorn==20.1.0
 | 
			
		||||
@ -6,19 +6,37 @@ from rest_framework_simplejwt.tokens import RefreshToken
 | 
			
		||||
 | 
			
		||||
class CustomUserAdmin(UserAdmin):
 | 
			
		||||
    model = CustomUser
 | 
			
		||||
    list_display = ('email', 'name', 'grade', 'desc', 'is_active', 'is_staff', 'jwt_preview')
 | 
			
		||||
    list_display = (
 | 
			
		||||
        'email', 'name', 'grade', 'desc',
 | 
			
		||||
        'is_active', 'is_staff', 'jwt_preview',
 | 
			
		||||
        'encrypted_private_key_name'  # ✅ 키 이름 컬럼 추가
 | 
			
		||||
    )
 | 
			
		||||
    list_filter = ('grade', 'is_active', 'is_staff')
 | 
			
		||||
    search_fields = ('email', 'name')
 | 
			
		||||
    ordering = ('email',)
 | 
			
		||||
 | 
			
		||||
    readonly_fields = ('created_at', 'last_login', 'jwt_preview')  # ✅ jwt_preview 추가
 | 
			
		||||
    readonly_fields = (
 | 
			
		||||
        'created_at', 'last_login', 'jwt_preview',
 | 
			
		||||
        'encrypted_private_key_name',  # ✅ 키 이름 읽기 전용
 | 
			
		||||
        'encrypted_private_key_preview'
 | 
			
		||||
        
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    fieldsets = (
 | 
			
		||||
        (None, {'fields': ('email', 'password')}),
 | 
			
		||||
        ('Personal Info', {'fields': ('name', 'grade', 'desc')}),
 | 
			
		||||
        ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
 | 
			
		||||
        ('Important dates', {'fields': ('last_login', 'created_at')}),
 | 
			
		||||
        ('JWT Token Preview', {'fields': ('jwt_preview',)}),  # ✅ 별도 섹션으로 추가
 | 
			
		||||
        ('Permissions', {'fields': (
 | 
			
		||||
            'is_active', 'is_staff', 'is_superuser',
 | 
			
		||||
            'groups', 'user_permissions'
 | 
			
		||||
        )}),
 | 
			
		||||
        ('Important dates', {'fields': ('last_login',)}),
 | 
			
		||||
        ('JWT Token Preview', {'fields': ('jwt_preview',)}),
 | 
			
		||||
        ('SSH Key Info', {  # ✅ 키 정보 그룹
 | 
			
		||||
            'fields': (
 | 
			
		||||
                'encrypted_private_key_name',
 | 
			
		||||
                'encrypted_private_key_preview'
 | 
			
		||||
            )
 | 
			
		||||
        }),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    add_fieldsets = (
 | 
			
		||||
@ -36,11 +54,16 @@ class CustomUserAdmin(UserAdmin):
 | 
			
		||||
        try:
 | 
			
		||||
            refresh = RefreshToken.for_user(obj)
 | 
			
		||||
            access_token = str(refresh.access_token)
 | 
			
		||||
            return access_token[:30] + "..."  # ✅ 일부만 보여줌
 | 
			
		||||
            return access_token[:30] + "..."
 | 
			
		||||
        except Exception:
 | 
			
		||||
            return "N/A"
 | 
			
		||||
 | 
			
		||||
    jwt_preview.short_description = "JWT Access Preview"
 | 
			
		||||
 | 
			
		||||
    def encrypted_private_key_preview(self, obj):
 | 
			
		||||
        if obj.encrypted_private_key:
 | 
			
		||||
            return str(obj.encrypted_private_key)[:30] + "..."
 | 
			
		||||
        return "없음"
 | 
			
		||||
    encrypted_private_key_preview.short_description = "SSH Key (암호화)"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
admin.site.register(CustomUser, CustomUserAdmin)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										18
									
								
								users/migrations/0004_customuser_encrypted_private_key.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								users/migrations/0004_customuser_encrypted_private_key.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,18 @@
 | 
			
		||||
# Generated by Django 4.2.14 on 2025-05-20 03:25
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('users', '0003_alter_customuser_is_active'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='customuser',
 | 
			
		||||
            name='encrypted_private_key',
 | 
			
		||||
            field=models.BinaryField(blank=True, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										18
									
								
								users/migrations/0005_customuser_last_used_at.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								users/migrations/0005_customuser_last_used_at.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,18 @@
 | 
			
		||||
# Generated by Django 4.2.14 on 2025-05-20 03:26
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('users', '0004_customuser_encrypted_private_key'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='customuser',
 | 
			
		||||
            name='last_used_at',
 | 
			
		||||
            field=models.DateTimeField(blank=True, null=True, verbose_name='SSH 키 마지막 사용 시각'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -0,0 +1,18 @@
 | 
			
		||||
# Generated by Django 4.2.14 on 2025-05-20 04:18
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('users', '0005_customuser_last_used_at'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='customuser',
 | 
			
		||||
            name='encrypted_private_key_name',
 | 
			
		||||
            field=models.CharField(blank=True, max_length=100, null=True, verbose_name='SSH 키 이름'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -1,5 +1,8 @@
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
 | 
			
		||||
import base64
 | 
			
		||||
from cryptography.fernet import Fernet
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CustomUserManager(BaseUserManager):
 | 
			
		||||
@ -15,7 +18,7 @@ class CustomUserManager(BaseUserManager):
 | 
			
		||||
    def create_superuser(self, email, password=None, **extra_fields):
 | 
			
		||||
        extra_fields.setdefault("is_staff", True)
 | 
			
		||||
        extra_fields.setdefault("is_superuser", True)
 | 
			
		||||
        extra_fields.setdefault("grade", "admin")  # 슈퍼유저는 기본 admin
 | 
			
		||||
        extra_fields.setdefault("grade", "admin")
 | 
			
		||||
 | 
			
		||||
        if extra_fields.get("is_staff") is not True:
 | 
			
		||||
            raise ValueError("Superuser must have is_staff=True.")
 | 
			
		||||
@ -30,7 +33,6 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
 | 
			
		||||
        ('admin', '관리자'),
 | 
			
		||||
        ('manager', '매니저'),
 | 
			
		||||
        ('user', '일반 사용자'),
 | 
			
		||||
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    email = models.EmailField(unique=True)
 | 
			
		||||
@ -38,10 +40,15 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
 | 
			
		||||
    grade = models.CharField(max_length=20, choices=GRADE_CHOICES, default='user')
 | 
			
		||||
    desc = models.TextField(blank=True, null=True, verbose_name="설명") 
 | 
			
		||||
    
 | 
			
		||||
    is_active = models.BooleanField(default=False) # 최초 가입 비활성
 | 
			
		||||
    is_active = models.BooleanField(default=False)
 | 
			
		||||
    is_staff = models.BooleanField(default=False)
 | 
			
		||||
    created_at = models.DateTimeField(auto_now_add=True)
 | 
			
		||||
 | 
			
		||||
    # 🔐 SSH 키 관련 필드
 | 
			
		||||
    encrypted_private_key_name = models.CharField(max_length=100, blank=True, null=True, verbose_name="SSH 키 이름")
 | 
			
		||||
    encrypted_private_key = models.BinaryField(blank=True, null=True)
 | 
			
		||||
    last_used_at = models.DateTimeField(blank=True, null=True, verbose_name="SSH 키 마지막 사용 시각")
 | 
			
		||||
 | 
			
		||||
    objects = CustomUserManager()
 | 
			
		||||
 | 
			
		||||
    USERNAME_FIELD = 'email'
 | 
			
		||||
@ -49,3 +56,26 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.email
 | 
			
		||||
 | 
			
		||||
    # 🔐 SSH Private Key 암복호화 관련 메서드
 | 
			
		||||
    def encrypt_private_key(self, private_key: str) -> bytes:
 | 
			
		||||
        cipher = Fernet(self.get_encryption_key())
 | 
			
		||||
        return cipher.encrypt(private_key.encode())
 | 
			
		||||
 | 
			
		||||
    def decrypt_private_key(self) -> str:
 | 
			
		||||
        if self.encrypted_private_key:
 | 
			
		||||
            cipher = Fernet(self.get_encryption_key())
 | 
			
		||||
            decrypted = cipher.decrypt(self.encrypted_private_key).decode()
 | 
			
		||||
            self.last_used_at = timezone.now()
 | 
			
		||||
            self.save(update_fields=['last_used_at'])  # 📌 사용 시각 업데이트
 | 
			
		||||
            return decrypted
 | 
			
		||||
        return ""
 | 
			
		||||
 | 
			
		||||
    def save_private_key(self, private_key: str):
 | 
			
		||||
        self.encrypted_private_key = self.encrypt_private_key(private_key)
 | 
			
		||||
        self.save()
 | 
			
		||||
 | 
			
		||||
    def get_encryption_key(self) -> bytes:
 | 
			
		||||
        email_encoded = self.email.encode()
 | 
			
		||||
        base64_key = base64.urlsafe_b64encode(email_encoded.ljust(32)[:32])
 | 
			
		||||
        return base64_key
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
from django.urls import path
 | 
			
		||||
from .views import RegisterView, MeView, CustomTokenObtainPairView
 | 
			
		||||
from .views import RegisterView, MeView, CustomTokenObtainPairView, SSHKeyUploadView
 | 
			
		||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView
 | 
			
		||||
 | 
			
		||||
urlpatterns = [
 | 
			
		||||
@ -9,4 +9,5 @@ urlpatterns = [
 | 
			
		||||
    path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
 | 
			
		||||
    path('verify/', TokenVerifyView.as_view(), name='token_verify'),
 | 
			
		||||
    path('me/', MeView.as_view(), name='me'),
 | 
			
		||||
    path("ssh-key/", SSHKeyUploadView.as_view(), name="ssh_key_upload"),
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
@ -32,3 +32,25 @@ class MeView(APIView):
 | 
			
		||||
    
 | 
			
		||||
class CustomTokenObtainPairView(TokenObtainPairView):
 | 
			
		||||
    serializer_class = CustomTokenObtainPairSerializer
 | 
			
		||||
    
 | 
			
		||||
class SSHKeyUploadView(APIView):
 | 
			
		||||
    permission_classes = [IsAuthenticated]
 | 
			
		||||
 | 
			
		||||
    def post(self, request):
 | 
			
		||||
        private_key = request.data.get("private_key")
 | 
			
		||||
        key_name = request.data.get("key_name")  # 여전히 key_name으로 받음
 | 
			
		||||
 | 
			
		||||
        if not private_key or not key_name:
 | 
			
		||||
            return Response(
 | 
			
		||||
                {"error": "private_key와 key_name 모두 필요합니다."},
 | 
			
		||||
                status=status.HTTP_400_BAD_REQUEST
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        user = request.user
 | 
			
		||||
        try:
 | 
			
		||||
            user.save_private_key(private_key)
 | 
			
		||||
            user.encrypted_private_key_name = key_name
 | 
			
		||||
            user.save(update_fields=["encrypted_private_key", "encrypted_private_key_name"])
 | 
			
		||||
            return Response({"message": "SSH key 저장 완료."})
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            return Response({"error": str(e)}, status=500)
 | 
			
		||||
		Reference in New Issue
	
	Block a user