# 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