diff --git a/users/migrations/0015_customuser_locked_until_customuser_login_failures_and_more.py b/users/migrations/0015_customuser_locked_until_customuser_login_failures_and_more.py new file mode 100644 index 0000000..3856dbf --- /dev/null +++ b/users/migrations/0015_customuser_locked_until_customuser_login_failures_and_more.py @@ -0,0 +1,104 @@ +# Generated by Django 4.2.14 on 2026-01-18 15:44 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0014_sitesettings'), + ] + + operations = [ + migrations.AddField( + model_name='customuser', + name='locked_until', + field=models.DateTimeField(blank=True, null=True, verbose_name='계정 잠금 해제 시간'), + ), + migrations.AddField( + model_name='customuser', + name='login_failures', + field=models.IntegerField(default=0, verbose_name='로그인 실패 횟수'), + ), + migrations.AddField( + model_name='customuser', + name='password_changed_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='비밀번호 변경일시'), + ), + migrations.AddField( + model_name='sitesettings', + name='login_lockout_minutes', + field=models.IntegerField(default=30, verbose_name='계정 잠금 시간(분)'), + ), + migrations.AddField( + model_name='sitesettings', + name='login_max_failures', + field=models.IntegerField(default=5, verbose_name='최대 로그인 실패 횟수 (0=무제한)'), + ), + migrations.AddField( + model_name='sitesettings', + name='password_expiry_days', + field=models.IntegerField(default=90, verbose_name='비밀번호 만료일 (0=무제한)'), + ), + migrations.AddField( + model_name='sitesettings', + name='password_expiry_warning_days', + field=models.IntegerField(default=14, verbose_name='만료 경고일 (만료 전 N일)'), + ), + migrations.AddField( + model_name='sitesettings', + name='password_history_count', + field=models.IntegerField(default=3, verbose_name='이전 비밀번호 재사용 금지 횟수 (0=제한없음)'), + ), + migrations.AddField( + model_name='sitesettings', + name='password_max_length', + field=models.IntegerField(default=128, verbose_name='최대 비밀번호 길이'), + ), + migrations.AddField( + model_name='sitesettings', + name='password_min_length', + field=models.IntegerField(default=8, verbose_name='최소 비밀번호 길이'), + ), + migrations.AddField( + model_name='sitesettings', + name='password_require_digit', + field=models.BooleanField(default=True, verbose_name='숫자 필수'), + ), + migrations.AddField( + model_name='sitesettings', + name='password_require_lowercase', + field=models.BooleanField(default=True, verbose_name='소문자 필수'), + ), + migrations.AddField( + model_name='sitesettings', + name='password_require_special', + field=models.BooleanField(default=True, verbose_name='특수문자 필수'), + ), + migrations.AddField( + model_name='sitesettings', + name='password_require_uppercase', + field=models.BooleanField(default=True, verbose_name='대문자 필수'), + ), + migrations.AddField( + model_name='sitesettings', + name='password_special_chars', + field=models.CharField(default='!@#$%^&*()_+-=[]{}|;:,.<>?', max_length=100, verbose_name='허용 특수문자'), + ), + migrations.CreateModel( + name='PasswordHistory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password_hash', models.CharField(max_length=255, verbose_name='비밀번호 해시')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='password_history', to=settings.AUTH_USER_MODEL, verbose_name='사용자')), + ], + options={ + 'verbose_name': '비밀번호 이력', + 'verbose_name_plural': '비밀번호 이력', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/users/models.py b/users/models.py index 404847d..d10bf52 100644 --- a/users/models.py +++ b/users/models.py @@ -14,14 +14,37 @@ class SiteSettings(models.Model): """ 사이트 전역 설정 (싱글톤 패턴) - Google 로그인 활성화 여부 등 관리 + - 비밀번호 정책 설정 """ # 소셜 로그인 설정 google_login_enabled = models.BooleanField(default=True, verbose_name="Google 로그인 활성화") - # 향후 확장 가능한 설정들 - # kakao_login_enabled = models.BooleanField(default=False, verbose_name="카카오 로그인 활성화") - # naver_login_enabled = models.BooleanField(default=False, verbose_name="네이버 로그인 활성화") - # registration_enabled = models.BooleanField(default=True, verbose_name="회원가입 허용") + # ========== 비밀번호 정책 설정 ========== + # 비밀번호 길이 + 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) @@ -124,6 +147,11 @@ class CustomUser(AbstractBaseUser, PermissionsMixin): 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' @@ -339,3 +367,29 @@ class KVMServer(models.Model): 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')}" diff --git a/users/password_utils.py b/users/password_utils.py new file mode 100644 index 0000000..abc3111 --- /dev/null +++ b/users/password_utils.py @@ -0,0 +1,245 @@ +# users/password_utils.py +""" +비밀번호 정책 유효성 검사 및 관리 유틸리티 +""" +import re +from datetime import timedelta +from django.utils import timezone +from django.contrib.auth.hashers import check_password, make_password + + +def validate_password_policy(password, settings): + """ + 비밀번호 정책에 따른 유효성 검사 + + Args: + password: 검사할 비밀번호 + settings: SiteSettings 인스턴스 + + Returns: + tuple: (is_valid: bool, errors: list) + """ + errors = [] + + # 길이 검사 + if len(password) < settings.password_min_length: + errors.append(f"비밀번호는 최소 {settings.password_min_length}자 이상이어야 합니다.") + + if len(password) > settings.password_max_length: + errors.append(f"비밀번호는 최대 {settings.password_max_length}자 이하여야 합니다.") + + # 대문자 검사 + if settings.password_require_uppercase and not re.search(r'[A-Z]', password): + errors.append("비밀번호에 대문자가 포함되어야 합니다.") + + # 소문자 검사 + if settings.password_require_lowercase and not re.search(r'[a-z]', password): + errors.append("비밀번호에 소문자가 포함되어야 합니다.") + + # 숫자 검사 + if settings.password_require_digit and not re.search(r'\d', password): + errors.append("비밀번호에 숫자가 포함되어야 합니다.") + + # 특수문자 검사 + if settings.password_require_special: + special_chars = settings.password_special_chars + if not any(char in special_chars for char in password): + errors.append(f"비밀번호에 특수문자({special_chars})가 포함되어야 합니다.") + + return len(errors) == 0, errors + + +def check_password_history(user, new_password, history_count): + """ + 비밀번호 이력 검사 (재사용 방지) + + Args: + user: CustomUser 인스턴스 + new_password: 새 비밀번호 + history_count: 검사할 이력 수 (0이면 검사 안 함) + + Returns: + tuple: (is_valid: bool, error_message: str or None) + """ + if history_count <= 0: + return True, None + + # 최근 N개의 비밀번호 이력 가져오기 + recent_passwords = user.password_history.all()[:history_count] + + for history in recent_passwords: + if check_password(new_password, history.password_hash): + return False, f"최근 {history_count}회 이내에 사용한 비밀번호는 재사용할 수 없습니다." + + return True, None + + +def save_password_history(user, password): + """ + 비밀번호 이력 저장 + + Args: + user: CustomUser 인스턴스 + password: 저장할 비밀번호 (평문) + """ + from .models import PasswordHistory + + PasswordHistory.objects.create( + user=user, + password_hash=make_password(password) + ) + + +def check_password_expiry(user, settings): + """ + 비밀번호 만료 상태 확인 + + Args: + user: CustomUser 인스턴스 + settings: SiteSettings 인스턴스 + + Returns: + dict: { + 'is_expired': bool, + 'is_warning': bool, + 'days_until_expiry': int or None, + 'message': str or None + } + """ + result = { + 'is_expired': False, + 'is_warning': False, + 'days_until_expiry': None, + 'message': None + } + + # 만료 정책이 비활성화되어 있으면 (0일) + if settings.password_expiry_days <= 0: + return result + + # 비밀번호 변경일이 없으면 (최초 설정 또는 소셜 로그인) + if not user.password_changed_at: + # 소셜 로그인 사용자는 비밀번호가 없으므로 만료 체크 안 함 + if not user.has_usable_password(): + return result + # 비밀번호는 있지만 변경일이 없으면 즉시 만료 처리 + result['is_expired'] = True + result['message'] = "비밀번호 변경이 필요합니다." + return result + + # 만료일 계산 + expiry_date = user.password_changed_at + timedelta(days=settings.password_expiry_days) + now = timezone.now() + days_until_expiry = (expiry_date - now).days + + result['days_until_expiry'] = days_until_expiry + + if days_until_expiry < 0: + # 만료됨 + result['is_expired'] = True + result['message'] = "비밀번호가 만료되었습니다. 새 비밀번호로 변경해주세요." + elif days_until_expiry <= settings.password_expiry_warning_days: + # 경고 기간 + result['is_warning'] = True + result['message'] = f"비밀번호가 {days_until_expiry}일 후 만료됩니다. 변경을 권장합니다." + + return result + + +def check_account_lockout(user, settings): + """ + 계정 잠금 상태 확인 + + Args: + user: CustomUser 인스턴스 + settings: SiteSettings 인스턴스 + + Returns: + dict: { + 'is_locked': bool, + 'remaining_minutes': int or None, + 'message': str or None + } + """ + result = { + 'is_locked': False, + 'remaining_minutes': None, + 'message': None + } + + # 잠금 정책이 비활성화되어 있으면 + if settings.login_max_failures <= 0: + return result + + # 잠금 시간이 설정되어 있으면 + if user.locked_until: + now = timezone.now() + if user.locked_until > now: + remaining_seconds = (user.locked_until - now).total_seconds() + remaining_minutes = int(remaining_seconds / 60) + 1 + result['is_locked'] = True + result['remaining_minutes'] = remaining_minutes + result['message'] = f"로그인 시도가 너무 많습니다. {remaining_minutes}분 후 다시 시도해주세요." + + return result + + +def record_login_failure(user, settings): + """ + 로그인 실패 기록 및 잠금 처리 + + Args: + user: CustomUser 인스턴스 + settings: SiteSettings 인스턴스 + """ + if settings.login_max_failures <= 0: + return + + user.login_failures += 1 + + if user.login_failures >= settings.login_max_failures: + user.locked_until = timezone.now() + timedelta(minutes=settings.login_lockout_minutes) + + user.save(update_fields=['login_failures', 'locked_until']) + + +def reset_login_failures(user): + """ + 로그인 성공 시 실패 횟수 초기화 + + Args: + user: CustomUser 인스턴스 + """ + if user.login_failures > 0 or user.locked_until: + user.login_failures = 0 + user.locked_until = None + user.save(update_fields=['login_failures', 'locked_until']) + + +def get_password_policy_description(settings): + """ + 비밀번호 정책 설명 텍스트 생성 + + Args: + settings: SiteSettings 인스턴스 + + Returns: + list: 정책 설명 리스트 + """ + descriptions = [] + + descriptions.append(f"최소 {settings.password_min_length}자 이상") + + if settings.password_require_uppercase: + descriptions.append("대문자 포함") + + if settings.password_require_lowercase: + descriptions.append("소문자 포함") + + if settings.password_require_digit: + descriptions.append("숫자 포함") + + if settings.password_require_special: + descriptions.append(f"특수문자 포함 ({settings.password_special_chars})") + + return descriptions diff --git a/users/serializers.py b/users/serializers.py index 335c1b7..805cd1d 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -19,19 +19,52 @@ class RegisterSerializer(serializers.ModelSerializer): return obj.has_usable_password() def validate_email(self, value): - if CustomUser.objects.filter(email=value).exists(): + # 업데이트 시에는 자기 자신 제외 + instance = getattr(self, 'instance', None) + queryset = CustomUser.objects.filter(email=value) + if instance: + queryset = queryset.exclude(pk=instance.pk) + if queryset.exists(): raise ValidationError("이미 사용 중인 이메일입니다.") return value def validate_name(self, value): - if CustomUser.objects.filter(name=value).exists(): + # 업데이트 시에는 자기 자신 제외 + instance = getattr(self, 'instance', None) + queryset = CustomUser.objects.filter(name=value) + if instance: + queryset = queryset.exclude(pk=instance.pk) + if queryset.exists(): raise ValidationError("이미 사용 중인 이름입니다.") return value + def validate_password(self, value): + """비밀번호 정책 검증 (회원가입 시)""" + from .models import SiteSettings + from .password_utils import validate_password_policy + + # 비밀번호가 제공된 경우에만 검증 (업데이트 시 비밀번호 변경 안 할 수 있음) + if value: + site_settings = SiteSettings.get_settings() + is_valid, errors = validate_password_policy(value, site_settings) + if not is_valid: + raise ValidationError(errors) + return value + def create(self, validated_data): - password = validated_data.pop("password") + from .models import SiteSettings + from .password_utils import validate_password_policy + from django.utils import timezone + + password = validated_data.pop("password", None) + + # 회원가입 시 비밀번호 필수 + if not password: + raise ValidationError({"password": "비밀번호는 필수입니다."}) + user = CustomUser(**validated_data) user.set_password(password) + user.password_changed_at = timezone.now() # 비밀번호 변경일 설정 user.save() return user @@ -70,6 +103,12 @@ class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): return token def validate(self, attrs): + from .models import SiteSettings + from .password_utils import ( + check_account_lockout, record_login_failure, + reset_login_failures, check_password_expiry + ) + identifier = attrs.get("email") # 이메일 또는 이름 password = attrs.get("password") @@ -80,11 +119,27 @@ class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): if user is None: raise ValidationError("계정 또는 비밀번호가 올바르지 않습니다.") + + # 사이트 설정 가져오기 + site_settings = SiteSettings.get_settings() + + # 계정 잠금 상태 확인 + lockout_status = check_account_lockout(user, site_settings) + if lockout_status['is_locked']: + raise ValidationError(lockout_status['message']) + if not user.is_active: raise ValidationError("계정이 비활성화되어 있습니다. 관리자에게 문의하세요.") + + # 비밀번호 검증 if not user.check_password(password): + # 로그인 실패 기록 + record_login_failure(user, site_settings) raise ValidationError("계정 또는 비밀번호가 올바르지 않습니다.") + # 로그인 성공 - 실패 횟수 초기화 + reset_login_failures(user) + # 부모 클래스의 validate를 위해 attrs에 실제 email 설정 attrs["email"] = user.email self.user = user # ✅ 수동 설정 필요 @@ -92,6 +147,17 @@ class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): data["email"] = user.email data["grade"] = user.grade + + # 비밀번호 만료 상태 확인 + expiry_status = check_password_expiry(user, site_settings) + if expiry_status['is_expired']: + data["password_expired"] = True + data["password_message"] = expiry_status['message'] + elif expiry_status['is_warning']: + data["password_warning"] = True + data["password_message"] = expiry_status['message'] + data["password_days_until_expiry"] = expiry_status['days_until_expiry'] + return data diff --git a/users/urls.py b/users/urls.py index 7fabe73..b1a7263 100644 --- a/users/urls.py +++ b/users/urls.py @@ -1,6 +1,6 @@ from django.urls import path from .views import ( - RegisterView, MeView, ChangePasswordView, CustomTokenObtainPairView, + RegisterView, MeView, ChangePasswordView, ExtendPasswordExpiryView, CustomTokenObtainPairView, SSHKeyUploadView, SSHKeyInfoView, SSHKeyRetrieveView, UserListView, UserUpdateView, NHNCloudCredentialsView, NHNCloudPasswordView, @@ -26,6 +26,7 @@ urlpatterns = [ path('verify/', TokenVerifyView.as_view(), name='token_verify'), path('me/', MeView.as_view(), name='me'), path('me/password/', ChangePasswordView.as_view(), name='change_password'), + path('me/password/extend/', ExtendPasswordExpiryView.as_view(), name='extend_password_expiry'), path("ssh-key/", SSHKeyUploadView.as_view(), name="ssh_key_upload"), path("ssh-key/info/", SSHKeyInfoView.as_view(), name="ssh_key_info"), path("ssh-key/view/", SSHKeyRetrieveView.as_view(), name="ssh_key_retrieve"), diff --git a/users/views.py b/users/views.py index 46fb366..898c7de 100644 --- a/users/views.py +++ b/users/views.py @@ -86,18 +86,34 @@ class ChangePasswordView(APIView): permission_classes = [IsAuthenticated] def post(self, request): + from .password_utils import ( + validate_password_policy, check_password_history, + save_password_history + ) + from django.utils import timezone + with tracer.start_as_current_span("ChangePasswordView POST") as span: email, ip, ua = get_request_info(request) user = request.user + site_settings = SiteSettings.get_settings() current_password = request.data.get("current_password") new_password = request.data.get("new_password") confirm_password = request.data.get("confirm_password") + # 소셜 로그인 전용 계정인 경우 현재 비밀번호 필수 아님 + is_social_only = not user.has_usable_password() + # 필수 필드 확인 - if not current_password or not new_password or not confirm_password: + if not is_social_only and not current_password: return Response( - {"error": "현재 비밀번호, 새 비밀번호, 비밀번호 확인은 필수입니다."}, + {"error": "현재 비밀번호를 입력해주세요."}, + status=status.HTTP_400_BAD_REQUEST + ) + + if not new_password or not confirm_password: + return Response( + {"error": "새 비밀번호와 비밀번호 확인은 필수입니다."}, status=status.HTTP_400_BAD_REQUEST ) @@ -108,38 +124,92 @@ class ChangePasswordView(APIView): status=status.HTTP_400_BAD_REQUEST ) - # 비밀번호 길이 확인 - if len(new_password) < 8: + # 비밀번호 정책 검증 + is_valid, errors = validate_password_policy(new_password, site_settings) + if not is_valid: return Response( - {"error": "비밀번호는 최소 8자 이상이어야 합니다."}, + {"error": errors[0], "errors": errors}, status=status.HTTP_400_BAD_REQUEST ) - # 소셜 로그인 전용 계정인 경우 (비밀번호 없음) - if not user.has_usable_password(): - # 현재 비밀번호 없이 새 비밀번호 설정 가능 - user.set_password(new_password) - user.save() - logger.info(f"[PASSWORD SET] user={email} | status=success | IP={ip} | UA={ua}") - span.add_event("Password set for social user", attributes={"email": email}) - return Response({"message": "비밀번호가 설정되었습니다."}) + # 현재 비밀번호 확인 (소셜 로그인 전용이 아닌 경우) + if not is_social_only: + if not user.check_password(current_password): + logger.warning(f"[PASSWORD CHANGE] user={email} | status=fail | reason=wrong_password | IP={ip} | UA={ua}") + return Response( + {"error": "현재 비밀번호가 일치하지 않습니다."}, + status=status.HTTP_401_UNAUTHORIZED + ) - # 현재 비밀번호 확인 - if not user.check_password(current_password): - logger.warning(f"[PASSWORD CHANGE] user={email} | status=fail | reason=wrong_password | IP={ip} | UA={ua}") - return Response( - {"error": "현재 비밀번호가 일치하지 않습니다."}, - status=status.HTTP_401_UNAUTHORIZED + # 비밀번호 이력 검사 (재사용 방지) + if site_settings.password_history_count > 0: + history_valid, history_error = check_password_history( + user, new_password, site_settings.password_history_count + ) + if not history_valid: + return Response( + {"error": history_error}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 현재 비밀번호를 이력에 저장 (변경 전) + if user.has_usable_password() and site_settings.password_history_count > 0: + # 현재 비밀번호 해시 저장 + from .models import PasswordHistory + PasswordHistory.objects.create( + user=user, + password_hash=user.password ) # 새 비밀번호 설정 user.set_password(new_password) - user.save() + user.password_changed_at = timezone.now() + user.save(update_fields=['password', 'password_changed_at']) - logger.info(f"[PASSWORD CHANGE] user={email} | status=success | IP={ip} | UA={ua}") - span.add_event("Password changed", attributes={"email": email}) + action = "set" if is_social_only else "changed" + logger.info(f"[PASSWORD {action.upper()}] user={email} | status=success | IP={ip} | UA={ua}") + span.add_event(f"Password {action}", attributes={"email": email}) - return Response({"message": "비밀번호가 변경되었습니다."}) + return Response({ + "message": "비밀번호가 설정되었습니다." if is_social_only else "비밀번호가 변경되었습니다." + }) + + +class ExtendPasswordExpiryView(APIView): + """비밀번호 만료 연장 (90일)""" + permission_classes = [IsAuthenticated] + + def post(self, request): + from django.utils import timezone + from datetime import timedelta + + with tracer.start_as_current_span("ExtendPasswordExpiryView POST") as span: + email, ip, ua = get_request_info(request) + user = request.user + site_settings = SiteSettings.get_settings() + + # 만료 정책이 비활성화된 경우 + if site_settings.password_expiry_days <= 0: + return Response( + {"error": "비밀번호 만료 정책이 비활성화되어 있습니다."}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 비밀번호 변경일을 현재 시간으로 업데이트 (90일 연장) + user.password_changed_at = timezone.now() + user.save(update_fields=['password_changed_at']) + + # 새로운 만료일 계산 + new_expiry_date = user.password_changed_at + timedelta(days=site_settings.password_expiry_days) + + logger.info(f"[PASSWORD EXTEND] user={email} | status=success | new_expiry={new_expiry_date.date()} | IP={ip} | UA={ua}") + span.add_event("Password expiry extended", attributes={"email": email, "new_expiry": str(new_expiry_date.date())}) + + return Response({ + "message": f"비밀번호 유효기간이 {site_settings.password_expiry_days}일 연장되었습니다.", + "new_expiry_date": new_expiry_date.isoformat(), + "days_extended": site_settings.password_expiry_days, + }) class CustomTokenObtainPairView(TokenObtainPairView): @@ -1576,7 +1646,23 @@ class SiteSettingsView(APIView): span.add_event("Site settings retrieved") return Response({ + # 소셜 로그인 설정 "google_login_enabled": settings_obj.google_login_enabled, + # 비밀번호 정책 설정 + "password_min_length": settings_obj.password_min_length, + "password_max_length": settings_obj.password_max_length, + "password_require_uppercase": settings_obj.password_require_uppercase, + "password_require_lowercase": settings_obj.password_require_lowercase, + "password_require_digit": settings_obj.password_require_digit, + "password_require_special": settings_obj.password_require_special, + "password_special_chars": settings_obj.password_special_chars, + "password_expiry_days": settings_obj.password_expiry_days, + "password_expiry_warning_days": settings_obj.password_expiry_warning_days, + "password_history_count": settings_obj.password_history_count, + # 로그인 보안 설정 + "login_max_failures": settings_obj.login_max_failures, + "login_lockout_minutes": settings_obj.login_lockout_minutes, + # 메타 정보 "updated_at": settings_obj.updated_at.isoformat() if settings_obj.updated_at else None, }) @@ -1586,12 +1672,28 @@ class SiteSettingsView(APIView): email, ip, ua = get_request_info(request) settings_obj = SiteSettings.get_settings() - # 업데이트할 필드 처리 - updated_fields = [] + # 업데이트할 필드 목록 + updatable_fields = [ + 'google_login_enabled', + 'password_min_length', + 'password_max_length', + 'password_require_uppercase', + 'password_require_lowercase', + 'password_require_digit', + 'password_require_special', + 'password_special_chars', + 'password_expiry_days', + 'password_expiry_warning_days', + 'password_history_count', + 'login_max_failures', + 'login_lockout_minutes', + ] - if 'google_login_enabled' in request.data: - settings_obj.google_login_enabled = request.data['google_login_enabled'] - updated_fields.append('google_login_enabled') + updated_fields = [] + for field in updatable_fields: + if field in request.data: + setattr(settings_obj, field, request.data[field]) + updated_fields.append(field) if updated_fields: settings_obj.save() @@ -1605,6 +1707,22 @@ class SiteSettingsView(APIView): return Response({ "message": "설정이 저장되었습니다.", + # 소셜 로그인 설정 "google_login_enabled": settings_obj.google_login_enabled, + # 비밀번호 정책 설정 + "password_min_length": settings_obj.password_min_length, + "password_max_length": settings_obj.password_max_length, + "password_require_uppercase": settings_obj.password_require_uppercase, + "password_require_lowercase": settings_obj.password_require_lowercase, + "password_require_digit": settings_obj.password_require_digit, + "password_require_special": settings_obj.password_require_special, + "password_special_chars": settings_obj.password_special_chars, + "password_expiry_days": settings_obj.password_expiry_days, + "password_expiry_warning_days": settings_obj.password_expiry_warning_days, + "password_history_count": settings_obj.password_history_count, + # 로그인 보안 설정 + "login_max_failures": settings_obj.login_max_failures, + "login_lockout_minutes": settings_obj.login_lockout_minutes, + # 메타 정보 "updated_at": settings_obj.updated_at.isoformat() if settings_obj.updated_at else None, })