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
|
# 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
|
||||||
|
@ -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
|
|
@ -6,19 +6,37 @@ 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 = (
|
||||||
@ -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)
|
||||||
|
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.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
|
||||||
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
@ -32,3 +32,25 @@ class MeView(APIView):
|
|||||||
|
|
||||||
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)
|
Reference in New Issue
Block a user