ssh key저장 기능 추가
All checks were successful
Build And Test / build-and-push (push) Successful in 1m57s

This commit is contained in:
2025-05-20 13:25:12 +09:00
parent 9c84584c36
commit 293003cf1c
10 changed files with 149 additions and 15 deletions

View File

@ -20,6 +20,7 @@ RUN apt-get clean
# install python dependencies # install python dependencies
RUN pip install --upgrade pip RUN pip install --upgrade pip
RUN pip install -r requirements.txt RUN pip install -r requirements.txt
RUN pip install gunicorn==20.1.0
# collect static files # collect static files
# RUN python manage.py collectstatic --noinput # RUN python manage.py collectstatic --noinput

View File

@ -1,13 +1,16 @@
asgiref==3.8.1 asgiref==3.8.1
certifi==2025.1.31 certifi==2025.1.31
cffi==1.17.1
charset-normalizer==3.4.1 charset-normalizer==3.4.1
coreapi==2.3.3 coreapi==2.3.3
coreschema==0.0.4 coreschema==0.0.4
cryptography==45.0.2
Django==4.2.14 Django==4.2.14
django-cors-headers==4.7.0 django-cors-headers==4.7.0
djangorestframework==3.16.0 djangorestframework==3.16.0
djangorestframework_simplejwt==5.5.0 djangorestframework_simplejwt==5.5.0
drf-yasg==1.21.10 drf-yasg==1.21.10
gunicorn==20.1.0
idna==3.10 idna==3.10
inflection==0.5.1 inflection==0.5.1
itypes==1.2.0 itypes==1.2.0
@ -15,6 +18,7 @@ Jinja2==3.1.6
MarkupSafe==3.0.2 MarkupSafe==3.0.2
mysqlclient==2.2.7 mysqlclient==2.2.7
packaging==25.0 packaging==25.0
pycparser==2.22
PyJWT==2.9.0 PyJWT==2.9.0
python-dotenv==1.0.1 python-dotenv==1.0.1
pytz==2025.2 pytz==2025.2
@ -24,4 +28,3 @@ sqlparse==0.5.3
typing_extensions==4.13.2 typing_extensions==4.13.2
uritemplate==4.1.1 uritemplate==4.1.1
urllib3==2.4.0 urllib3==2.4.0
gunicorn==20.1.0

View File

@ -6,26 +6,44 @@ from rest_framework_simplejwt.tokens import RefreshToken
class CustomUserAdmin(UserAdmin): class CustomUserAdmin(UserAdmin):
model = CustomUser 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') list_filter = ('grade', 'is_active', 'is_staff')
search_fields = ('email', 'name') search_fields = ('email', 'name')
ordering = ('email',) 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 = ( fieldsets = (
(None, {'fields': ('email', 'password')}), (None, {'fields': ('email', 'password')}),
('Personal Info', {'fields': ('name', 'grade', 'desc')}), ('Personal Info', {'fields': ('name', 'grade', 'desc')}),
('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}), ('Permissions', {'fields': (
('Important dates', {'fields': ('last_login', 'created_at')}), 'is_active', 'is_staff', 'is_superuser',
('JWT Token Preview', {'fields': ('jwt_preview',)}), # ✅ 별도 섹션으로 추가 '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 = ( add_fieldsets = (
(None, { (None, {
'classes': ('wide',), 'classes': ('wide',),
'fields': ( 'fields': (
'email', 'name', 'grade', 'desc', 'email', 'name', 'grade', 'desc',
'password1', 'password2', 'password1', 'password2',
'is_active', 'is_staff', 'is_superuser' 'is_active', 'is_staff', 'is_superuser'
), ),
@ -36,11 +54,16 @@ class CustomUserAdmin(UserAdmin):
try: try:
refresh = RefreshToken.for_user(obj) refresh = RefreshToken.for_user(obj)
access_token = str(refresh.access_token) access_token = str(refresh.access_token)
return access_token[:30] + "..." # ✅ 일부만 보여줌 return access_token[:30] + "..."
except Exception: except Exception:
return "N/A" return "N/A"
jwt_preview.short_description = "JWT Access Preview" 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) admin.site.register(CustomUser, CustomUserAdmin)

View 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),
),
]

View 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 키 마지막 사용 시각'),
),
]

View File

@ -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 키 이름'),
),
]

View File

@ -1,5 +1,8 @@
from django.db import models from django.db import models
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
import base64
from cryptography.fernet import Fernet
from django.utils import timezone
class CustomUserManager(BaseUserManager): class CustomUserManager(BaseUserManager):
@ -15,7 +18,7 @@ class CustomUserManager(BaseUserManager):
def create_superuser(self, email, password=None, **extra_fields): def create_superuser(self, email, password=None, **extra_fields):
extra_fields.setdefault("is_staff", True) extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", 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: if extra_fields.get("is_staff") is not True:
raise ValueError("Superuser must have is_staff=True.") raise ValueError("Superuser must have is_staff=True.")
@ -30,7 +33,6 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
('admin', '관리자'), ('admin', '관리자'),
('manager', '매니저'), ('manager', '매니저'),
('user', '일반 사용자'), ('user', '일반 사용자'),
) )
email = models.EmailField(unique=True) email = models.EmailField(unique=True)
@ -38,10 +40,15 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
grade = models.CharField(max_length=20, choices=GRADE_CHOICES, default='user') grade = models.CharField(max_length=20, choices=GRADE_CHOICES, default='user')
desc = models.TextField(blank=True, null=True, verbose_name="설명") 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) is_staff = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True) 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() objects = CustomUserManager()
USERNAME_FIELD = 'email' USERNAME_FIELD = 'email'
@ -49,3 +56,26 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
def __str__(self): def __str__(self):
return self.email 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

View File

@ -1,5 +1,5 @@
from django.urls import path 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 from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView
urlpatterns = [ urlpatterns = [
@ -9,4 +9,5 @@ urlpatterns = [
path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('verify/', TokenVerifyView.as_view(), name='token_verify'), path('verify/', TokenVerifyView.as_view(), name='token_verify'),
path('me/', MeView.as_view(), name='me'), path('me/', MeView.as_view(), name='me'),
path("ssh-key/", SSHKeyUploadView.as_view(), name="ssh_key_upload"),
] ]

View File

@ -31,4 +31,26 @@ class MeView(APIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class CustomTokenObtainPairView(TokenObtainPairView): class CustomTokenObtainPairView(TokenObtainPairView):
serializer_class = CustomTokenObtainPairSerializer 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)

View File

@ -1 +1 @@
0.0.8-r1 0.0.9