from django.db import models from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager from django.utils import timezone from django.conf import settings # ✅ 추가 from cryptography.fernet import Fernet import base64, hashlib # ✅ SECRET_KEY 암호화 키 생성용 # ============================================ # 사이트 설정 (싱글톤) # ============================================ class SiteSettings(models.Model): """ 사이트 전역 설정 (싱글톤 패턴) - Google 로그인 활성화 여부 등 관리 - 비밀번호 정책 설정 """ # 소셜 로그인 설정 google_login_enabled = models.BooleanField(default=True, verbose_name="Google 로그인 활성화") # ========== 비밀번호 정책 설정 ========== # 비밀번호 길이 password_min_length = models.IntegerField(default=8, verbose_name="최소 비밀번호 길이") password_max_length = models.IntegerField(default=128, verbose_name="최대 비밀번호 길이") # 비밀번호 복잡성 요구사항 password_require_uppercase = models.BooleanField(default=True, verbose_name="대문자 필수") password_require_lowercase = models.BooleanField(default=True, verbose_name="소문자 필수") password_require_digit = models.BooleanField(default=True, verbose_name="숫자 필수") password_require_special = models.BooleanField(default=True, verbose_name="특수문자 필수") password_special_chars = models.CharField( max_length=100, default="!@#$%^&*()_+-=[]{}|;:,.<>?", verbose_name="허용 특수문자" ) # 비밀번호 만료 정책 password_expiry_days = models.IntegerField(default=90, verbose_name="비밀번호 만료일 (0=무제한)") password_expiry_warning_days = models.IntegerField(default=14, verbose_name="만료 경고일 (만료 전 N일)") # 비밀번호 이력 관리 password_history_count = models.IntegerField(default=3, verbose_name="이전 비밀번호 재사용 금지 횟수 (0=제한없음)") # 계정 잠금 정책 login_max_failures = models.IntegerField(default=5, verbose_name="최대 로그인 실패 횟수 (0=무제한)") login_lockout_minutes = models.IntegerField(default=30, verbose_name="계정 잠금 시간(분)") updated_at = models.DateTimeField(auto_now=True) class Meta: verbose_name = "사이트 설정" verbose_name_plural = "사이트 설정" def save(self, *args, **kwargs): # 싱글톤 패턴: 항상 id=1로 저장 self.pk = 1 super().save(*args, **kwargs) def delete(self, *args, **kwargs): # 삭제 방지 pass @classmethod def get_settings(cls): """설정 인스턴스 가져오기 (없으면 생성)""" obj, created = cls.objects.get_or_create(pk=1) return obj def __str__(self): return "사이트 설정" class CustomUserManager(BaseUserManager): def create_user(self, email, password=None, **extra_fields): if not email: raise ValueError("The Email must be set") email = self.normalize_email(email) user = self.model(email=email, **extra_fields) user.set_password(password) user.save(using=self._db) return user 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") if extra_fields.get("is_staff") is not True: raise ValueError("Superuser must have is_staff=True.") if extra_fields.get("is_superuser") is not True: raise ValueError("Superuser must have is_superuser=True.") return self.create_user(email, password, **extra_fields) class CustomUser(AbstractBaseUser, PermissionsMixin): GRADE_CHOICES = ( ('admin', '관리자'), ('manager', '매니저'), ('user', '일반 사용자'), ) GENDER_CHOICES = ( ('M', '남성'), ('F', '여성'), ('O', '기타'), ) EDUCATION_CHOICES = ( ('high_school', '고등학교 졸업'), ('associate', '전문학사'), ('bachelor', '학사'), ('master', '석사'), ('doctor', '박사'), ('other', '기타'), ) email = models.EmailField(unique=True) name = models.CharField(max_length=255, unique=True) grade = models.CharField(max_length=20, choices=GRADE_CHOICES, default='user') desc = models.TextField(blank=True, null=True, verbose_name="설명") # 추가 회원 정보 (선택) phone = models.CharField(max_length=20, blank=True, null=True, verbose_name="전화번호") address = models.CharField(max_length=500, blank=True, null=True, verbose_name="주소") gender = models.CharField(max_length=1, choices=GENDER_CHOICES, blank=True, null=True, verbose_name="성별") birth_date = models.DateField(blank=True, null=True, verbose_name="생년월일") education = models.CharField(max_length=20, choices=EDUCATION_CHOICES, blank=True, null=True, verbose_name="학력") 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 키 마지막 사용 시각") # 🔗 소셜 로그인 필드 social_provider = models.CharField(max_length=20, blank=True, null=True, verbose_name="소셜 로그인 제공자") # 'google', 'kakao' 등 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") # ☁️ 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") # 🔒 비밀번호 보안 관련 필드 password_changed_at = models.DateTimeField(blank=True, null=True, verbose_name="비밀번호 변경일시") login_failures = models.IntegerField(default=0, verbose_name="로그인 실패 횟수") locked_until = models.DateTimeField(blank=True, null=True, verbose_name="계정 잠금 해제 시간") objects = CustomUserManager() USERNAME_FIELD = 'email' REQUIRED_FIELDS = ['name'] def __str__(self): return self.email # ✅ 2025-05-20 SECRET_KEY 기반 암복호화 메서드들 def get_encryption_key(self) -> bytes: """ SECRET_KEY 기반으로 Fernet 키 생성 (SHA-256 -> base64) """ hashed = hashlib.sha256(settings.SECRET_KEY.encode()).digest() return base64.urlsafe_b64encode(hashed[:32]) 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: """ 암호화된 SSH 키를 복호화하여 문자열로 반환 """ 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): """ 암호화된 SSH 키를 저장 """ 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' ]) # ============================================ # NHN Cloud 프로젝트 (멀티 프로젝트 지원) # ============================================ class NHNCloudProject(models.Model): """ 사용자별 NHN Cloud 프로젝트 관리 (멀티 프로젝트 지원) """ user = models.ForeignKey( CustomUser, on_delete=models.CASCADE, related_name='nhn_projects', verbose_name="사용자" ) name = models.CharField(max_length=100, verbose_name="프로젝트 별칭") tenant_id = models.CharField(max_length=64, verbose_name="NHN Cloud Tenant ID") username = models.EmailField(verbose_name="NHN Cloud Username") encrypted_password = models.BinaryField(verbose_name="NHN Cloud API Password (암호화)") storage_account = models.CharField(max_length=128, blank=True, null=True, verbose_name="Storage Account") dns_appkey = models.CharField(max_length=64, blank=True, null=True, verbose_name="DNS Plus Appkey") is_active = models.BooleanField(default=False, verbose_name="활성 프로젝트") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: verbose_name = "NHN Cloud 프로젝트" verbose_name_plural = "NHN Cloud 프로젝트" unique_together = ['user', 'tenant_id'] # 동일 사용자가 같은 tenant 중복 등록 방지 ordering = ['-is_active', '-created_at'] def __str__(self): return f"{self.name} ({self.tenant_id})" def get_encryption_key(self) -> bytes: """SECRET_KEY 기반 Fernet 키 생성""" hashed = hashlib.sha256(settings.SECRET_KEY.encode()).digest() return base64.urlsafe_b64encode(hashed[:32]) def encrypt_password(self, password: str) -> bytes: """비밀번호 암호화""" cipher = Fernet(self.get_encryption_key()) return cipher.encrypt(password.encode()) def decrypt_password(self) -> str: """비밀번호 복호화""" if self.encrypted_password: cipher = Fernet(self.get_encryption_key()) return cipher.decrypt(self.encrypted_password).decode() return "" def save_credentials(self, password: str): """자격증명 저장 (비밀번호 암호화)""" self.encrypted_password = self.encrypt_password(password) self.save() # ============================================ # KVM 서버 관리 (멀티 서버 지원) # ============================================ class KVMServer(models.Model): """ 사용자별 KVM 서버 관리 (멀티 서버 지원) msa-django-libvirt에서 SSH 접속 정보를 요청할 때 사용 """ user = models.ForeignKey( CustomUser, on_delete=models.CASCADE, related_name='kvm_servers', verbose_name="사용자" ) name = models.CharField(max_length=100, verbose_name="서버 별칭") host = models.CharField(max_length=255, verbose_name="호스트 (IP 또는 도메인)") port = models.IntegerField(default=22, verbose_name="SSH 포트") username = models.CharField(max_length=100, verbose_name="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, verbose_name="SSH 개인키 (암호화)" ) libvirt_uri = models.CharField( max_length=255, blank=True, null=True, verbose_name="Libvirt URI", help_text="예: qemu+ssh://user@host/system" ) description = models.TextField(blank=True, null=True, verbose_name="설명") tags = models.CharField( max_length=500, blank=True, null=True, verbose_name="태그", help_text="쉼표로 구분된 태그 목록" ) is_active = models.BooleanField(default=True, verbose_name="활성화 상태") last_used_at = models.DateTimeField(blank=True, null=True, verbose_name="마지막 사용 시각") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: verbose_name = "KVM 서버" verbose_name_plural = "KVM 서버" unique_together = ['user', 'host', 'port'] # 동일 사용자가 같은 호스트:포트 중복 등록 방지 ordering = ['-is_active', '-created_at'] def __str__(self): return f"{self.name} ({self.host}:{self.port})" def get_encryption_key(self) -> bytes: """SECRET_KEY 기반 Fernet 키 생성""" hashed = hashlib.sha256(settings.SECRET_KEY.encode()).digest() return base64.urlsafe_b64encode(hashed[:32]) def encrypt_private_key(self, private_key: str) -> bytes: """SSH 개인키 암호화""" cipher = Fernet(self.get_encryption_key()) return cipher.encrypt(private_key.encode()) def decrypt_private_key(self) -> str: """SSH 개인키 복호화""" 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_ssh_key(self, private_key: str, key_name: str = None): """SSH 키 저장 (암호화)""" self.encrypted_private_key = self.encrypt_private_key(private_key) if key_name: self.encrypted_private_key_name = key_name self.save() def get_tags_list(self) -> list: """태그 문자열을 리스트로 반환""" if self.tags: return [tag.strip() for tag in self.tags.split(',') if tag.strip()] return [] def set_tags_list(self, tags_list: list): """태그 리스트를 문자열로 저장""" self.tags = ', '.join(tags_list) def get_libvirt_uri(self) -> str: """Libvirt URI 반환 (없으면 기본값 생성)""" if self.libvirt_uri: return self.libvirt_uri return f"qemu+ssh://{self.username}@{self.host}:{self.port}/system" # ============================================ # 비밀번호 이력 관리 # ============================================ class PasswordHistory(models.Model): """ 사용자 비밀번호 변경 이력 (재사용 방지용) """ user = models.ForeignKey( CustomUser, on_delete=models.CASCADE, related_name='password_history', verbose_name="사용자" ) password_hash = models.CharField(max_length=255, verbose_name="비밀번호 해시") created_at = models.DateTimeField(auto_now_add=True) class Meta: verbose_name = "비밀번호 이력" verbose_name_plural = "비밀번호 이력" ordering = ['-created_at'] def __str__(self): return f"{self.user.email} - {self.created_at.strftime('%Y-%m-%d %H:%M')}"