비밀번호 정책 기능 구현
Some checks failed
Build And Test / build-and-push (push) Failing after 2m49s

- RegisterSerializer에 비밀번호 정책 검증 추가
- ExtendPasswordExpiryView: 비밀번호 유효기간 연장 API
- CustomTokenObtainPairSerializer: 로그인 시 만료/잠금 검증
- password_utils.py: 정책 검증, 계정 잠금, 만료 체크 유틸리티
- SiteSettings 모델에 비밀번호 정책 필드 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 21:35:46 +09:00
parent 1c7f241b37
commit 6b4d38ad5f
6 changed files with 624 additions and 36 deletions

View File

@ -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,
})