Files
auto-stock/backend/app/api/endpoints/auth.py
shuim 8b8c7a423e fix: 세션 로그인 시 포트폴리오 화면 갱신 안 됨 — MULTI_TENANT_ENABLED=false면 v1 API 사용
세션 로그인 사용자는 프론트가 v2(/portfolio/v2)를 쓰는데, 멀티테넌트가 꺼져 있으면
v2 컨텍스트가 legacy로 떨어져 KIS 동기화 없이 DB 스냅숏만 반환 → 현재가·손익이 갱신되지 않음.
/auth/me 에 multi_tenant_enabled 를 추가하고, 프론트는 세션 로그인 AND 플래그 true 일 때만 v2 사용.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:16:30 +09:00

481 lines
20 KiB
Python

"""대시보드 계정 인증 엔드포인트 (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'(관리자)."""
# multi_tenant_enabled: 프론트가 v2(사용자 스코프) API를 쓸지 결정하는 근거.
# False면 세션 로그인이라도 v2 컨텍스트가 legacy로 떨어져 /portfolio/v2 는
# KIS 동기화 없는 DB 스냅숏만 반환하므로(화면 갱신 안 됨) v1을 써야 한다.
_mt = bool(settings.MULTI_TENANT_ENABLED)
if hmac.compare_digest(credential.encode(), _get_api_key().encode()):
return {"username": "api-key", "auth_type": "api_key", "is_admin": True, "multi_tenant_enabled": _mt}
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, "multi_tenant_enabled": _mt}
@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)
class AssignEnvCredentialsIn(BaseModel):
username: str = Field(..., min_length=1, max_length=32)
@router.post(
"/admin/tenant/assign-env-credentials",
dependencies=[Depends(require_admin)],
)
async def assign_env_credentials(body: AssignEnvCredentialsIn, db: AsyncSession = Depends(get_db)):
""".env 의 KIS 자격증명(운영자가 테스트하던 계좌)을 지정 사용자에게 (재)할당.
- 동일 앱키는 한 사용자에게만 등록 가능하므로, 현재 보유자(보통 부트스트랩 admin)가
있으면 그 행을 대상 사용자로 **이동**한다. 대상 사용자가 다른 자격증명을 갖고 있으면 교체.
- 이동/교체된 사용자들의 UserContext 캐시는 무효화한다.
- 단일 트랜잭션 직접 접근 허용 경로(CLAUDE.md §3 예외: credentials).
"""
import hashlib
from app.core.crypto import current_key_version, encrypt, is_encryption_configured
from app.models.models import UserKisCredential
from app.services.tenant.registry import user_context_registry
target = (await db.execute(select(User).where(User.username == body.username.strip()))).scalar_one_or_none()
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="사용자를 찾을 수 없습니다.")
app_key = settings.KOREA_INVESTMENT_APP_KEY
app_secret = settings.KOREA_INVESTMENT_APP_SECRET
account_no = (settings.KOREA_INVESTMENT_ACCOUNT_NUMBER or "").replace("-", "")
if not (app_key and app_secret and account_no):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=".env 에 KIS 자격증명이 설정되어 있지 않습니다.")
if not is_encryption_configured():
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="자격증명 암호화 키가 설정되지 않았습니다.")
app_key_hash = hashlib.sha256(app_key.encode()).hexdigest()
holder = (await db.execute(
select(UserKisCredential).where(UserKisCredential.app_key_hash == app_key_hash)
)).scalar_one_or_none()
previous_owner_id = holder.user_id if holder else None
if holder is not None and holder.user_id == target.id:
return {"username": target.username, "action": "already_assigned", "app_key_hint": app_key[:6]}
# 대상 사용자의 기존(다른 키) 자격증명은 제거 — 사용자당 1세트
existing_target = (await db.execute(
select(UserKisCredential).where(UserKisCredential.user_id == target.id)
)).scalar_one_or_none()
if existing_target is not None and existing_target is not holder:
await db.delete(existing_target)
await db.flush()
if holder is not None:
holder.user_id = target.id
action = "moved"
else:
db.add(UserKisCredential(
user_id=target.id,
mode=settings.KIS_MODE,
app_key_enc=encrypt(app_key),
app_secret_enc=encrypt(app_secret),
account_no_enc=encrypt(account_no),
account_product_code=account_no[8:10] if len(account_no) >= 10 else "01",
app_key_hash=app_key_hash,
app_key_hint=app_key[:6],
is_enabled=True,
auto_trade_enabled=False,
key_version=current_key_version(),
))
action = "created"
await db.commit()
for uid in {previous_owner_id, target.id} - {None}:
await user_context_registry.invalidate(uid)
logger.info(
f"[Auth] .env KIS 자격증명 {action}: → {target.username} (from user_id={previous_owner_id}, app_key={app_key[:6]}…)"
)
return {
"username": target.username,
"action": action,
"previous_owner_id": previous_owner_id,
"mode": settings.KIS_MODE,
"app_key_hint": app_key[:6],
}
# ── 멀티테넌트 이관 관리자 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,
)