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