- RegisterSerializer에 비밀번호 정책 검증 추가 - ExtendPasswordExpiryView: 비밀번호 유효기간 연장 API - CustomTokenObtainPairSerializer: 로그인 시 만료/잠금 검증 - password_utils.py: 정책 검증, 계정 잠금, 만료 체크 유틸리티 - SiteSettings 모델에 비밀번호 정책 필드 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
174
users/views.py
174
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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user