"""대시보드 계정 인증 엔드포인트 (DB users 테이블 기반 회원가입/로그인). POST /api/v1/auth/register {username, password} → 계정 생성 (201) POST /api/v1/auth/login {username, password} → {token, expires_at, username, is_admin} GET /api/v1/auth/me (X-API-Key: 세션 토큰 또는 API 키) → 현재 인증 주체 GET /api/v1/auth/users (관리자) 계정 목록 POST /api/v1/auth/users/{id}/approve (관리자) 가입 승인 POST /api/v1/auth/users/{id}/revoke (관리자) 비활성화 정책 - 첫 가입자는 자동으로 관리자 + 활성. 이후 가입자는 SIGNUP_AUTO_APPROVE=False 면 비활성 상태로 생성되어 관리자 승인 전까지 로그인 불가(403). - 비밀번호는 bcrypt 해시로만 저장. 발급 토큰은 기존 X-API-Key 헤더로 전송되며 security.require_api_key 가 세션 토큰도 수용한다. API 키(SECRET_KEY)는 관리자로 간주. - 무차별 대입 완화: IP당 연속 실패 LOGIN_MAX_FAILURES 회 초과 시 LOGIN_LOCKOUT_SECONDS 잠금. DB 접근 - 회원가입/로그인은 "중복 확인 → INSERT/UPDATE → 응답" 이 한 요청 안에서 원자적으로 끝나야 하므로 sync_portfolio 와 같은 예외로 엔드포인트에서 직접 세션을 사용한다 (db_worker 큐는 결과를 응답에 되돌릴 수 없음). 실시간 계층과는 무관한 경로. """ import asyncio import hmac import logging import re import time from datetime import datetime import bcrypt from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel, Field, field_validator from sqlalchemy import func, select, text from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.core.database import get_db from app.core.security import ( _get_api_key, create_session_token, require_api_key, verify_session_token, ) from app.models.models import User logger = logging.getLogger(__name__) router = APIRouter() _USERNAME_RE = re.compile(r"^[a-zA-Z0-9_]{3,32}$") # IP별 로그인 실패 기록: {ip: (연속 실패 횟수, 마지막 실패 시각 monotonic)} _failures: dict[str, tuple[int, float]] = {} # ── 스키마 ──────────────────────────────────────────────────────────────── class Credentials(BaseModel): username: str = Field(..., min_length=3, max_length=32) password: str = Field(..., min_length=8, max_length=128) @field_validator("username") @classmethod def _username_format(cls, v: str) -> str: v = v.strip() if not _USERNAME_RE.match(v): raise ValueError("아이디는 영문/숫자/밑줄 3~32자여야 합니다.") return v class LoginRequest(BaseModel): """로그인 요청 — 길이 정책은 가입 시에만 적용(부트스트랩 시드 계정 등 짧은 비밀번호 허용).""" username: str = Field(..., min_length=1, max_length=32) password: str = Field(..., min_length=1, max_length=128) class LoginResponse(BaseModel): token: str expires_at: datetime username: str is_admin: bool class RegisterResponse(BaseModel): username: str is_active: bool is_admin: bool message: str class UserOut(BaseModel): id: int username: str is_active: bool is_admin: bool created_at: datetime last_login_at: datetime | None class TenantMigrateOut(BaseModel): legacy_owner: str | None credentials: str # created | exists | skipped_no_env | skipped_no_encryption backfilled: dict[str, int] class TenantStatusOut(BaseModel): legacy_owner: str | None multi_tenant_enabled: bool credentials_registered: int null_user_id_counts: dict[str, int] # ── 헬퍼 ───────────────────────────────────────────────────────────────── def _hash_password(password: str) -> str: # passlib 1.7.4 는 bcrypt>=4.1 과 호환되지 않으므로 bcrypt 를 직접 사용 (72바이트 제한) return bcrypt.hashpw(password.encode()[:72], bcrypt.gensalt()).decode() _DUMMY_HASH = _hash_password("not-a-real-password") # 미존재 계정 타이밍 균등화용 def _check_password(password: str, password_hash: str) -> bool: try: return bcrypt.checkpw(password.encode()[:72], password_hash.encode()) except ValueError: return False # bcrypt 는 의도적으로 느린(≈100~300ms) CPU 연산 → 이벤트 루프를 막지 않도록 스레드로 위임 async def _hash_password_async(password: str) -> str: return await asyncio.to_thread(_hash_password, password) async def _check_password_async(password: str, password_hash: str) -> bool: return await asyncio.to_thread(_check_password, password, password_hash) def _client_ip(request: Request) -> str: fwd = request.headers.get("x-forwarded-for") if fwd: return fwd.split(",")[0].strip() return request.client.host if request.client else "unknown" def _lockout_remaining(ip: str) -> int: rec = _failures.get(ip) if not rec: return 0 count, last = rec if count < settings.LOGIN_MAX_FAILURES: return 0 remaining = int(settings.LOGIN_LOCKOUT_SECONDS - (time.monotonic() - last)) if remaining <= 0: _failures.pop(ip, None) return 0 return remaining def _record_failure(ip: str) -> None: count, _ = _failures.get(ip, (0, 0.0)) _failures[ip] = (count + 1, time.monotonic()) if len(_failures) > 1000: # 오래된 항목 정리 cutoff = time.monotonic() - settings.LOGIN_LOCKOUT_SECONDS for k in [k for k, (_, t) in _failures.items() if t < cutoff]: _failures.pop(k, None) async def _resolve_user(credential: str, db: AsyncSession) -> User | None: """세션 토큰 → User. API 키면 None (호출 측에서 관리자 취급).""" username = verify_session_token(credential) if not username: return None return (await db.execute(select(User).where(User.username == username))).scalar_one_or_none() async def require_admin( credential: str = Depends(require_api_key), db: AsyncSession = Depends(get_db) ) -> str: """관리자 전용 의존성. API 키(SECRET_KEY) 또는 is_admin 세션 토큰만 통과.""" if hmac.compare_digest(credential.encode(), _get_api_key().encode()): return "api-key" user = await _resolve_user(credential, db) if user is None or not user.is_active or not user.is_admin: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="관리자 권한이 필요합니다.") return user.username # ── 엔드포인트 ──────────────────────────────────────────────────────────── @router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED) async def register(body: Credentials, request: Request, db: AsyncSession = Depends(get_db)): if not settings.SIGNUP_ENABLED: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="회원가입이 비활성화되어 있습니다.") total = (await db.execute(select(func.count()).select_from(User))).scalar() or 0 is_first = total == 0 is_active = is_first or settings.SIGNUP_AUTO_APPROVE user = User( username=body.username, password_hash=await _hash_password_async(body.password), is_active=is_active, is_admin=is_first, ) db.add(user) try: await db.commit() except IntegrityError: await db.rollback() raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="이미 사용 중인 아이디입니다.") logger.info( f"[Auth] 회원가입 user={body.username} admin={is_first} active={is_active} ip={_client_ip(request)}" ) if is_first: message = "첫 계정이 관리자로 생성되었습니다. 바로 로그인할 수 있습니다." elif is_active: message = "가입이 완료되었습니다. 바로 로그인할 수 있습니다." else: message = "가입 신청이 접수되었습니다. 관리자 승인 후 로그인할 수 있습니다." return RegisterResponse(username=user.username, is_active=is_active, is_admin=is_first, message=message) @router.post("/login", response_model=LoginResponse) async def login(body: LoginRequest, request: Request, db: AsyncSession = Depends(get_db)): ip = _client_ip(request) remaining = _lockout_remaining(ip) if remaining > 0: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=f"로그인 시도가 너무 많습니다. {remaining}초 후 다시 시도하세요.", ) user = (await db.execute(select(User).where(User.username == body.username.strip()))).scalar_one_or_none() # 존재하지 않는 계정도 해시 검증을 수행해 응답 시간 차이로 계정 유무가 드러나지 않게 함 pw_ok = await _check_password_async(body.password, user.password_hash if user else _DUMMY_HASH) if user is None or not pw_ok: _record_failure(ip) logger.warning(f"[Auth] 로그인 실패 ip={ip} user={body.username!r}") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="아이디 또는 비밀번호가 올바르지 않습니다.", ) if not user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="관리자 승인 대기 중인 계정입니다.", ) _failures.pop(ip, None) user.last_login_at = datetime.utcnow() await db.commit() token, expires_at = create_session_token(user.username) logger.info(f"[Auth] 로그인 성공 user={user.username} ip={ip}") return LoginResponse(token=token, expires_at=expires_at, username=user.username, is_admin=user.is_admin) @router.get("/me") async def me(credential: str = Depends(require_api_key), db: AsyncSession = Depends(get_db)): """현재 자격 증명 확인 — 세션 토큰이면 계정 정보, API 키면 'api-key'(관리자).""" if hmac.compare_digest(credential.encode(), _get_api_key().encode()): return {"username": "api-key", "auth_type": "api_key", "is_admin": True} user = await _resolve_user(credential, db) if user is None or not user.is_active: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="세션이 유효하지 않습니다.") return {"username": user.username, "auth_type": "session", "is_admin": user.is_admin} @router.get("/users", response_model=list[UserOut], dependencies=[Depends(require_admin)]) async def list_users(db: AsyncSession = Depends(get_db)): rows = (await db.execute(select(User).order_by(User.id))).scalars().all() return [UserOut.model_validate(u, from_attributes=True) for u in rows] @router.post("/users/{user_id}/approve", response_model=UserOut) async def approve_user(user_id: int, admin: str = Depends(require_admin), db: AsyncSession = Depends(get_db)): user = await db.get(User, user_id) if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="계정을 찾을 수 없습니다.") user.is_active = True await db.commit() logger.info(f"[Auth] 계정 승인 user={user.username} by={admin}") return UserOut.model_validate(user, from_attributes=True) @router.post("/users/{user_id}/revoke", response_model=UserOut) async def revoke_user(user_id: int, admin: str = Depends(require_admin), db: AsyncSession = Depends(get_db)): user = await db.get(User, user_id) if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="계정을 찾을 수 없습니다.") if user.is_admin and user.username == admin: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="자기 자신은 비활성화할 수 없습니다.") user.is_active = False await db.commit() logger.info(f"[Auth] 계정 비활성화 user={user.username} by={admin}") return UserOut.model_validate(user, from_attributes=True) # ── 멀티테넌트 이관 관리자 API (설계문서 §4, §5.10) ───────────────────────── # `ensure_bootstrap_admin()`(app/services/tenant/bootstrap.py, main.py lifespan 에서 # 앱 기동 시 1회 자동 실행)와 동일한 이관/백필 로직을 관리자가 수동으로 재실행할 수 # 있게 노출한다. 예: 관리자 계정이 앱 기동 이후 처음 생성된 경우(부트스트랩 시점에는 # legacy owner 가 없어 자동 이관이 스킵됨) 이 API로 지연 이관을 수행한다. @router.post( "/admin/tenant/migrate-legacy", response_model=TenantMigrateOut, dependencies=[Depends(require_admin)], ) async def migrate_legacy_tenant(db: AsyncSession = Depends(get_db)): """.env KIS 자격증명을 legacy owner 로 이관 + user_id NULL 행 백필 (idempotent).""" from app.core.crypto import is_encryption_configured from app.models.models import UserKisCredential from app.services.tenant.bootstrap import ( _BACKFILL_TABLES, _backfill_legacy_owner_ids, _get_legacy_owner, _seed_legacy_kis_credential, ) owner = await _get_legacy_owner(db) if owner is None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="관리자 계정이 없습니다. 먼저 회원가입하세요.") existing = ( await db.execute(select(UserKisCredential).where(UserKisCredential.user_id == owner.id)) ).scalar_one_or_none() if existing is not None: cred_result = "exists" elif not ( settings.KOREA_INVESTMENT_APP_KEY and settings.KOREA_INVESTMENT_APP_SECRET and settings.KOREA_INVESTMENT_ACCOUNT_NUMBER ): cred_result = "skipped_no_env" elif not is_encryption_configured(): cred_result = "skipped_no_encryption" else: await _seed_legacy_kis_credential(db, owner) cred_result = "created" # 백필 대상 행 수는 백필 "직전" 상태를 반환(백필 후에는 0이 되므로 결과 확인용으로 직전 값 사용) # M8: 문장별 SAVEPOINT — 한 테이블(컬럼 미존재 등)의 SELECT 실패가 PostgreSQL # 트랜잭션을 InFailedSqlTransaction으로 만들어 이후 테이블 카운트까지 연쇄로 # 0 오보되는 것을 방지한다. backfilled: dict[str, int] = {} for table in _BACKFILL_TABLES: try: async with db.begin_nested(): backfilled[table] = ( await db.execute(text(f"SELECT COUNT(*) FROM {table} WHERE user_id IS NULL")) ).scalar() or 0 except Exception: backfilled[table] = 0 await _backfill_legacy_owner_ids(db, owner.id) logger.info( f"[Auth] 레거시 이관 실행 owner={owner.username} credentials={cred_result} backfilled={backfilled}" ) return TenantMigrateOut(legacy_owner=owner.username, credentials=cred_result, backfilled=backfilled) @router.get( "/admin/tenant/status", response_model=TenantStatusOut, dependencies=[Depends(require_admin)], ) async def tenant_status(db: AsyncSession = Depends(get_db)): """legacy owner, MULTI_TENANT_ENABLED, 자격증명 등록 수, 테이블별 user_id NULL 행 수.""" from app.models.models import UserKisCredential from app.services.tenant.bootstrap import _BACKFILL_TABLES, _get_legacy_owner owner = await _get_legacy_owner(db) cred_count = (await db.execute(select(func.count()).select_from(UserKisCredential))).scalar() or 0 null_counts: dict[str, int] = {} for table in _BACKFILL_TABLES: try: null_counts[table] = ( await db.execute(text(f"SELECT COUNT(*) FROM {table} WHERE user_id IS NULL")) ).scalar() or 0 except Exception: null_counts[table] = 0 return TenantStatusOut( legacy_owner=owner.username if owner else None, multi_tenant_enabled=settings.MULTI_TENANT_ENABLED, credentials_registered=cred_count, null_user_id_counts=null_counts, )