from __future__ import annotations from typing import Optional from flask_sqlalchemy import SQLAlchemy from flask_login import UserMixin from sqlalchemy import String, Boolean from sqlalchemy.orm import Mapped, mapped_column from werkzeug.security import check_password_hash as wz_check_password_hash import logging # passlib: Argon2id 기본, scrypt/pbkdf2는 검증만 (점진 마이그레이션) from passlib.context import CryptContext db = SQLAlchemy() # Argon2id를 기본으로 사용하고, scrypt/pbkdf2_sha256은 검증만 허용(=deprecated) # 로그인에 성공하면 자동으로 Argon2id로 재해시 저장합니다. pwd_ctx = CryptContext( schemes=["argon2", "scrypt", "pbkdf2_sha256"], default="argon2", deprecated=["scrypt", "pbkdf2_sha256"], # Argon2id 파라미터 (기본도 충분하지만 서비스 보안수준에 맞춰 조정 가능) # time_cost: 2~4, memory_cost: 64~256 MiB 권장 범위 argon2__type="ID", argon2__time_cost=3, argon2__memory_cost=102400, # 100 MiB argon2__parallelism=8, # PBKDF2 라운드 상향 (레거시 검증용) pbkdf2_sha256__rounds=300_000, ) class User(db.Model, UserMixin): __tablename__ = "user" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) username: Mapped[str] = mapped_column(String(80), unique=True, nullable=False) email: Mapped[str] = mapped_column(String(120), unique=True, nullable=False, index=True) password: Mapped[str] = mapped_column(String(255), nullable=False) is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # 가입 승인 관련 필드 is_approved: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) approval_token: Mapped[Optional[str]] = mapped_column(String(100), unique=True, nullable=True) # ── 유틸 메서드 def __repr__(self) -> str: # pragma: no cover return f"" # 신규 저장/변경 시 항상 Argon2id로 해시 def set_password(self, password: str) -> None: self.password = pwd_ctx.hash(password) # 혼합 검증: scrypt/pbkdf2/argon2 모두 검증 가능 # 검증 성공 + 레거시 스킴이면 Argon2id로 즉시 재해시 & 커밋 def check_password(self, password: str) -> bool: """ - 우선 저장된 해시의 '형식'을 보고 검증기를 선택한다. * 'scrypt:' 또는 'pbkdf2:'로 시작하면 → Werkzeug 검증 * 그 외 → passlib(CryptContext) 검증 - 검증에 성공했고 현재 해시가 Argon2가 아니거나(passlib가 needs_update True), Werk­zeug 형식(scrypt/pbkdf2)이라면 즉시 Argon2id로 재해시 저장한다. """ raw = self.password or "" ok = False used_werkzeug = False try: # 1) Werkzeug 포맷 감지 (예: 'scrypt:32768:8:1$...', 'pbkdf2:sha256:260000$...') if raw.startswith("scrypt:") or raw.startswith("pbkdf2:"): used_werkzeug = True ok = wz_check_password_hash(raw, password) # hashlib 기반 → 형식 그대로 검증 else: # 2) passlib 포맷(argon2/$scrypt$/pbkdf2_sha256) 시도 ok = pwd_ctx.verify(password, raw) except Exception as e: logging.warning("password verify failed: %s", e) ok = False if not ok: return False # ── 여기까지 왔으면 검증 성공. 필요 시 Argon2id로 즉시 업그레이드. try: need_upgrade = False if used_werkzeug: # Werkzeug 형식(scrypt/pbkdf2)은 우리 정책상 모두 Argon2로 마이그레이션 need_upgrade = True else: # passlib가 판단하는 업그레이드 필요 여부(파라미터/알고리즘 기준) need_upgrade = pwd_ctx.needs_update(raw) if need_upgrade: self.password = pwd_ctx.hash(password) # Argon2id 기본 db.session.add(self) db.session.commit() except Exception as e: # 업그레이드 실패해도 로그인 자체는 성공시킨다(다음 로그인 때 재시도) logging.warning("password rehash (argon2) failed: %s", e) db.session.rollback() return True # Flask-Login 호환 (UserMixin 기본 get_id 사용 가능하지만 명시) def get_id(self) -> str: # pragma: no cover return str(self.id) # ── 조회 헬퍼 @staticmethod def find_by_email(email: Optional[str]) -> Optional["User"]: q = (email or "").strip().lower() if not q: return None return User.query.filter_by(email=q).first() @staticmethod def find_by_username(username: Optional[str]) -> Optional["User"]: q = (username or "").strip() if not q: return None return User.query.filter_by(username=q).first() # Flask-Login user_loader (SQLAlchemy 2.0 방식) def load_user(user_id: str) -> Optional[User]: # pragma: no cover try: return db.session.get(User, int(user_id)) except Exception: return None