- RegisterSerializer에 비밀번호 정책 검증 추가 - ExtendPasswordExpiryView: 비밀번호 유효기간 연장 API - CustomTokenObtainPairSerializer: 로그인 시 만료/잠금 검증 - password_utils.py: 정책 검증, 계정 잠금, 만료 체크 유틸리티 - SiteSettings 모델에 비밀번호 정책 필드 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -14,14 +14,37 @@ class SiteSettings(models.Model):
|
|||||||
"""
|
"""
|
||||||
사이트 전역 설정 (싱글톤 패턴)
|
사이트 전역 설정 (싱글톤 패턴)
|
||||||
- Google 로그인 활성화 여부 등 관리
|
- Google 로그인 활성화 여부 등 관리
|
||||||
|
- 비밀번호 정책 설정
|
||||||
"""
|
"""
|
||||||
# 소셜 로그인 설정
|
# 소셜 로그인 설정
|
||||||
google_login_enabled = models.BooleanField(default=True, verbose_name="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="네이버 로그인 활성화")
|
password_min_length = models.IntegerField(default=8, verbose_name="최소 비밀번호 길이")
|
||||||
# registration_enabled = models.BooleanField(default=True, 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)
|
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 (암호화)")
|
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")
|
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()
|
objects = CustomUserManager()
|
||||||
|
|
||||||
USERNAME_FIELD = 'email'
|
USERNAME_FIELD = 'email'
|
||||||
@ -339,3 +367,29 @@ class KVMServer(models.Model):
|
|||||||
if self.libvirt_uri:
|
if self.libvirt_uri:
|
||||||
return self.libvirt_uri
|
return self.libvirt_uri
|
||||||
return f"qemu+ssh://{self.username}@{self.host}:{self.port}/system"
|
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')}"
|
||||||
|
|||||||
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
|
||||||
@ -19,19 +19,52 @@ class RegisterSerializer(serializers.ModelSerializer):
|
|||||||
return obj.has_usable_password()
|
return obj.has_usable_password()
|
||||||
|
|
||||||
def validate_email(self, value):
|
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("이미 사용 중인 이메일입니다.")
|
raise ValidationError("이미 사용 중인 이메일입니다.")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def validate_name(self, 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("이미 사용 중인 이름입니다.")
|
raise ValidationError("이미 사용 중인 이름입니다.")
|
||||||
return value
|
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):
|
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 = CustomUser(**validated_data)
|
||||||
user.set_password(password)
|
user.set_password(password)
|
||||||
|
user.password_changed_at = timezone.now() # 비밀번호 변경일 설정
|
||||||
user.save()
|
user.save()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@ -70,6 +103,12 @@ class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
|
|||||||
return token
|
return token
|
||||||
|
|
||||||
def validate(self, attrs):
|
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") # 이메일 또는 이름
|
identifier = attrs.get("email") # 이메일 또는 이름
|
||||||
password = attrs.get("password")
|
password = attrs.get("password")
|
||||||
|
|
||||||
@ -80,11 +119,27 @@ class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
|
|||||||
|
|
||||||
if user is None:
|
if user is None:
|
||||||
raise ValidationError("계정 또는 비밀번호가 올바르지 않습니다.")
|
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:
|
if not user.is_active:
|
||||||
raise ValidationError("계정이 비활성화되어 있습니다. 관리자에게 문의하세요.")
|
raise ValidationError("계정이 비활성화되어 있습니다. 관리자에게 문의하세요.")
|
||||||
|
|
||||||
|
# 비밀번호 검증
|
||||||
if not user.check_password(password):
|
if not user.check_password(password):
|
||||||
|
# 로그인 실패 기록
|
||||||
|
record_login_failure(user, site_settings)
|
||||||
raise ValidationError("계정 또는 비밀번호가 올바르지 않습니다.")
|
raise ValidationError("계정 또는 비밀번호가 올바르지 않습니다.")
|
||||||
|
|
||||||
|
# 로그인 성공 - 실패 횟수 초기화
|
||||||
|
reset_login_failures(user)
|
||||||
|
|
||||||
# 부모 클래스의 validate를 위해 attrs에 실제 email 설정
|
# 부모 클래스의 validate를 위해 attrs에 실제 email 설정
|
||||||
attrs["email"] = user.email
|
attrs["email"] = user.email
|
||||||
self.user = user # ✅ 수동 설정 필요
|
self.user = user # ✅ 수동 설정 필요
|
||||||
@ -92,6 +147,17 @@ class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
|
|||||||
|
|
||||||
data["email"] = user.email
|
data["email"] = user.email
|
||||||
data["grade"] = user.grade
|
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
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from .views import (
|
from .views import (
|
||||||
RegisterView, MeView, ChangePasswordView, CustomTokenObtainPairView,
|
RegisterView, MeView, ChangePasswordView, ExtendPasswordExpiryView, CustomTokenObtainPairView,
|
||||||
SSHKeyUploadView, SSHKeyInfoView, SSHKeyRetrieveView,
|
SSHKeyUploadView, SSHKeyInfoView, SSHKeyRetrieveView,
|
||||||
UserListView, UserUpdateView,
|
UserListView, UserUpdateView,
|
||||||
NHNCloudCredentialsView, NHNCloudPasswordView,
|
NHNCloudCredentialsView, NHNCloudPasswordView,
|
||||||
@ -26,6 +26,7 @@ urlpatterns = [
|
|||||||
path('verify/', TokenVerifyView.as_view(), name='token_verify'),
|
path('verify/', TokenVerifyView.as_view(), name='token_verify'),
|
||||||
path('me/', MeView.as_view(), name='me'),
|
path('me/', MeView.as_view(), name='me'),
|
||||||
path('me/password/', ChangePasswordView.as_view(), name='change_password'),
|
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/", SSHKeyUploadView.as_view(), name="ssh_key_upload"),
|
||||||
path("ssh-key/info/", SSHKeyInfoView.as_view(), name="ssh_key_info"),
|
path("ssh-key/info/", SSHKeyInfoView.as_view(), name="ssh_key_info"),
|
||||||
path("ssh-key/view/", SSHKeyRetrieveView.as_view(), name="ssh_key_retrieve"),
|
path("ssh-key/view/", SSHKeyRetrieveView.as_view(), name="ssh_key_retrieve"),
|
||||||
|
|||||||
166
users/views.py
166
users/views.py
@ -86,18 +86,34 @@ class ChangePasswordView(APIView):
|
|||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def post(self, request):
|
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:
|
with tracer.start_as_current_span("ChangePasswordView POST") as span:
|
||||||
email, ip, ua = get_request_info(request)
|
email, ip, ua = get_request_info(request)
|
||||||
user = request.user
|
user = request.user
|
||||||
|
site_settings = SiteSettings.get_settings()
|
||||||
|
|
||||||
current_password = request.data.get("current_password")
|
current_password = request.data.get("current_password")
|
||||||
new_password = request.data.get("new_password")
|
new_password = request.data.get("new_password")
|
||||||
confirm_password = request.data.get("confirm_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(
|
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
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -108,23 +124,16 @@ class ChangePasswordView(APIView):
|
|||||||
status=status.HTTP_400_BAD_REQUEST
|
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(
|
return Response(
|
||||||
{"error": "비밀번호는 최소 8자 이상이어야 합니다."},
|
{"error": errors[0], "errors": errors},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# 소셜 로그인 전용 계정인 경우 (비밀번호 없음)
|
# 현재 비밀번호 확인 (소셜 로그인 전용이 아닌 경우)
|
||||||
if not user.has_usable_password():
|
if not is_social_only:
|
||||||
# 현재 비밀번호 없이 새 비밀번호 설정 가능
|
|
||||||
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 user.check_password(current_password):
|
if not user.check_password(current_password):
|
||||||
logger.warning(f"[PASSWORD CHANGE] user={email} | status=fail | reason=wrong_password | IP={ip} | UA={ua}")
|
logger.warning(f"[PASSWORD CHANGE] user={email} | status=fail | reason=wrong_password | IP={ip} | UA={ua}")
|
||||||
return Response(
|
return Response(
|
||||||
@ -132,14 +141,75 @@ class ChangePasswordView(APIView):
|
|||||||
status=status.HTTP_401_UNAUTHORIZED
|
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.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}")
|
action = "set" if is_social_only else "changed"
|
||||||
span.add_event("Password changed", attributes={"email": email})
|
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):
|
class CustomTokenObtainPairView(TokenObtainPairView):
|
||||||
@ -1576,7 +1646,23 @@ class SiteSettingsView(APIView):
|
|||||||
span.add_event("Site settings retrieved")
|
span.add_event("Site settings retrieved")
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
|
# 소셜 로그인 설정
|
||||||
"google_login_enabled": settings_obj.google_login_enabled,
|
"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,
|
"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)
|
email, ip, ua = get_request_info(request)
|
||||||
settings_obj = SiteSettings.get_settings()
|
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:
|
updated_fields = []
|
||||||
settings_obj.google_login_enabled = request.data['google_login_enabled']
|
for field in updatable_fields:
|
||||||
updated_fields.append('google_login_enabled')
|
if field in request.data:
|
||||||
|
setattr(settings_obj, field, request.data[field])
|
||||||
|
updated_fields.append(field)
|
||||||
|
|
||||||
if updated_fields:
|
if updated_fields:
|
||||||
settings_obj.save()
|
settings_obj.save()
|
||||||
@ -1605,6 +1707,22 @@ class SiteSettingsView(APIView):
|
|||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
"message": "설정이 저장되었습니다.",
|
"message": "설정이 저장되었습니다.",
|
||||||
|
# 소셜 로그인 설정
|
||||||
"google_login_enabled": settings_obj.google_login_enabled,
|
"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,
|
"updated_at": settings_obj.updated_at.isoformat() if settings_obj.updated_at else None,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user