fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소. 주문·리스크 - MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문) - KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류) - 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백 - 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락 - 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드) - 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단 데이트레이딩 - 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한 - 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정 스윙 - 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT) - 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed - 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일 - 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON 인프라·보안 - 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹 - KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환 - db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시 - KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패 하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준 CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신 backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,13 +12,26 @@
|
||||
|
||||
@router.get("/data")
|
||||
async def get_data(api_key: str | None = Depends(optional_api_key)): ...
|
||||
|
||||
[2026-10-02 검증 루프 2회차]
|
||||
- H-2: 세션 토큰은 서명/만료만이 아니라 **사용자 is_active** 까지 확인한다
|
||||
(`is_valid_credential_v2`). 이전에는 revoke 후에도 토큰 만료(24h)까지 통과했다.
|
||||
사용자 상태는 `SESSION_ACTIVE_CACHE_SECONDS` 동안 캐시한다.
|
||||
- H-2: 위험 쓰기 엔드포인트는 관리자만(`require_admin_for_listed_routes`). API 키(서버 간)는 관리자.
|
||||
- M-5: 세션 JWT 서명키를 SECRET_KEY 에서 파생한 별도 키로 분리했다. API_KEY 미설정 시
|
||||
API 키 = SECRET_KEY 이므로, 이전에는 API 키를 아는 쪽이 임의 사용자 세션을 위조할 수 있었다.
|
||||
- M-2: `client_ip_v2` — 신뢰 프록시에서 온 X-Real-IP 만 사용한다.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import Depends, HTTPException, Security, status
|
||||
from fastapi import Depends, HTTPException, Request, Security, status
|
||||
from fastapi.security import APIKeyHeader
|
||||
from jose import JWTError, jwt
|
||||
|
||||
@@ -30,6 +43,8 @@ logger = logging.getLogger(__name__)
|
||||
# 프론트 api 클라이언트·WebSocket 은 변경 없이 세션 토큰을 실어 보낼 수 있다.
|
||||
_SESSION_ALG = "HS256"
|
||||
_SESSION_AUD = "autostock-dashboard"
|
||||
# 세션 서명키 파생 컨텍스트 (M-5). 바꾸면 기존 세션이 전부 무효가 된다.
|
||||
_SESSION_KEY_CONTEXT = b"autostock-session-jwt-v2"
|
||||
|
||||
# API 키 헤더 스키마
|
||||
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
@@ -45,30 +60,169 @@ def _get_api_key() -> str:
|
||||
return settings.API_KEY if settings.API_KEY else settings.SECRET_KEY
|
||||
|
||||
|
||||
def _session_signing_key() -> str:
|
||||
"""세션 JWT 서명키 — SECRET_KEY 에서 HMAC 으로 파생 (M-5).
|
||||
|
||||
API_KEY 미설정 시 API 키가 SECRET_KEY 와 같아지므로, SECRET_KEY 로 직접 서명하면
|
||||
API 키 보유자(MCP 서버·스크립트)가 임의 사용자(관리자 포함) 세션을 위조할 수 있었다.
|
||||
"""
|
||||
return hmac.new(settings.SECRET_KEY.encode(), _SESSION_KEY_CONTEXT, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _session_verify_keys() -> list[str]:
|
||||
"""검증에 허용할 서명키 목록.
|
||||
|
||||
하위호환: 배포 전 발급된 세션(SECRET_KEY 직접 서명)은 **API_KEY 가 별도로 설정돼
|
||||
있을 때만** 받아준다 — 그 경우 API 키 보유자가 SECRET_KEY 를 모르므로 위조 위험이 없다.
|
||||
API_KEY 미설정 환경에서는 옛 토큰을 거부한다(배포 후 1회 재로그인).
|
||||
"""
|
||||
keys = [_session_signing_key()]
|
||||
if settings.API_KEY and settings.API_KEY != settings.SECRET_KEY:
|
||||
keys.append(settings.SECRET_KEY)
|
||||
return keys
|
||||
|
||||
|
||||
def create_session_token(username: str) -> tuple[str, datetime]:
|
||||
"""로그인 성공 시 발급하는 세션 토큰. (token, 만료시각 UTC) 반환."""
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.SESSION_TOKEN_EXPIRE_HOURS)
|
||||
payload = {"sub": username, "aud": _SESSION_AUD, "exp": expires_at, "iat": datetime.now(timezone.utc)}
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm=_SESSION_ALG), expires_at
|
||||
return jwt.encode(payload, _session_signing_key(), algorithm=_SESSION_ALG), expires_at
|
||||
|
||||
|
||||
def verify_session_token(token: str) -> str | None:
|
||||
"""세션 토큰 검증. 유효하면 username, 아니면 None (서명/만료/대상 불일치)."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[_SESSION_ALG], audience=_SESSION_AUD)
|
||||
except JWTError:
|
||||
return None
|
||||
sub = payload.get("sub")
|
||||
return sub if isinstance(sub, str) and sub else None
|
||||
"""세션 토큰 검증. 유효하면 username, 아니면 None (서명/만료/대상 불일치).
|
||||
|
||||
⚠️ 서명·만료만 본다 — 사용자 활성 여부는 `is_valid_credential_v2` 가 확인한다.
|
||||
"""
|
||||
for key in _session_verify_keys():
|
||||
try:
|
||||
payload = jwt.decode(token, key, algorithms=[_SESSION_ALG], audience=_SESSION_AUD)
|
||||
except JWTError:
|
||||
continue
|
||||
sub = payload.get("sub")
|
||||
return sub if isinstance(sub, str) and sub else None
|
||||
return None
|
||||
|
||||
|
||||
def is_valid_credential(token: str) -> bool:
|
||||
"""X-API-Key 값이 API 키이거나 유효한 세션 토큰이면 True."""
|
||||
"""X-API-Key 값이 API 키이거나 서명이 유효한 세션 토큰이면 True.
|
||||
|
||||
⚠️ 동기 레거시 판정 — 사용자 is_active 를 보지 않는다. 인증 경로는
|
||||
`is_valid_credential_v2` 를 쓸 것(require_api_key/optional_api_key/WebSocket 은 전환됨).
|
||||
"""
|
||||
if _constant_time_compare(token, _get_api_key()):
|
||||
return True
|
||||
return verify_session_token(token) is not None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# H-2: 세션 사용자 상태(is_active/is_admin) 확인 — 짧은 TTL 캐시
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@dataclass(frozen=True)
|
||||
class SessionUserFlags:
|
||||
exists: bool
|
||||
is_active: bool
|
||||
is_admin: bool
|
||||
|
||||
|
||||
# username -> (flags, monotonic fetched_at)
|
||||
_session_user_cache: dict[str, tuple[SessionUserFlags, float]] = {}
|
||||
|
||||
|
||||
def invalidate_session_user_cache(username: str | None = None) -> None:
|
||||
"""승인/회수 직후 호출 — 캐시 TTL 을 기다리지 않고 즉시 반영한다."""
|
||||
if username is None:
|
||||
_session_user_cache.clear()
|
||||
else:
|
||||
_session_user_cache.pop(username, None)
|
||||
|
||||
|
||||
async def _fetch_session_user_flags_impl(username: str) -> SessionUserFlags:
|
||||
"""DB 에서 사용자 상태 조회 (SELECT 전용, commit 없음).
|
||||
|
||||
엔드포인트 의존성(HTTP 요청·WebSocket 핸드셰이크)에서만 호출된다 —
|
||||
실시간 매매 계층이 아니다(CLAUDE.md §3.1 대상 아님).
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.models import User
|
||||
|
||||
# DB-READ-EXCEPTION: 인증 의존성의 사용자 상태 SELECT 전용(commit 없음). HTTP 요청·WebSocket
|
||||
# 핸드셰이크 단계에서만, 캐시 미스(TTL SESSION_ACTIVE_CACHE_SECONDS)일 때만 실행 — 매매 실시간 계층 아님.
|
||||
async with AsyncSessionLocal() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(User.is_active, User.is_admin).where(User.username == username)
|
||||
)
|
||||
).first()
|
||||
if row is None:
|
||||
return SessionUserFlags(exists=False, is_active=False, is_admin=False)
|
||||
return SessionUserFlags(exists=True, is_active=bool(row[0]), is_admin=bool(row[1]))
|
||||
|
||||
|
||||
async def get_session_user_flags(username: str) -> SessionUserFlags:
|
||||
"""캐시 우선 사용자 상태 조회.
|
||||
|
||||
DB 조회 실패 시: 이전 캐시값이 있으면(만료됐어도) 그것을 쓰고, 없으면 503.
|
||||
401 을 돌려주면 프론트가 토큰을 지우고 로그아웃시키므로 일시 장애에는 503 이 맞다.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
cached = _session_user_cache.get(username)
|
||||
ttl = max(0, int(settings.SESSION_ACTIVE_CACHE_SECONDS))
|
||||
if cached is not None and (now - cached[1]) < ttl:
|
||||
return cached[0]
|
||||
try:
|
||||
flags = await _fetch_session_user_flags_impl(username)
|
||||
except Exception as e:
|
||||
if cached is not None:
|
||||
logger.warning(
|
||||
"[Security] 사용자 상태 조회 실패 — 만료된 캐시값 사용(user=%s): %s", username, e
|
||||
)
|
||||
return cached[0]
|
||||
logger.error("[Security] 사용자 상태 조회 실패 — 인증 보류(user=%s): %s", username, e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="인증 상태를 확인할 수 없습니다. 잠시 후 다시 시도하세요.",
|
||||
)
|
||||
_session_user_cache[username] = (flags, now)
|
||||
if len(_session_user_cache) > 1000:
|
||||
for k in [k for k, (_, t) in _session_user_cache.items() if (now - t) > ttl]:
|
||||
_session_user_cache.pop(k, None)
|
||||
return flags
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CredentialInfo:
|
||||
kind: str # "api_key" | "session"
|
||||
username: str
|
||||
is_admin: bool
|
||||
|
||||
|
||||
async def resolve_credential_v2(token: str | None) -> CredentialInfo | None:
|
||||
"""X-API-Key 값 해석. API 키 → 관리자. 세션 → 서명·만료 + **활성 사용자**만. 그 외 None."""
|
||||
if not token:
|
||||
return None
|
||||
if _constant_time_compare(token, _get_api_key()):
|
||||
return CredentialInfo(kind="api_key", username="api-key", is_admin=True)
|
||||
username = verify_session_token(token)
|
||||
if not username:
|
||||
return None
|
||||
flags = await get_session_user_flags(username)
|
||||
if not flags.exists or not flags.is_active:
|
||||
logger.warning(
|
||||
"[Security] %s 사용자 세션 토큰 거부: user=%s",
|
||||
"삭제된" if not flags.exists else "비활성(회수/미승인)", username,
|
||||
)
|
||||
return None
|
||||
return CredentialInfo(kind="session", username=username, is_admin=flags.is_admin)
|
||||
|
||||
|
||||
async def is_valid_credential_v2(token: str | None) -> bool:
|
||||
"""`is_valid_credential` 의 비동기 판 — 세션 사용자의 is_active 까지 확인 (H-2)."""
|
||||
return (await resolve_credential_v2(token)) is not None
|
||||
|
||||
|
||||
async def require_api_key(api_key: str | None = Security(_api_key_header)) -> str:
|
||||
"""매매 실행, 관리 API 등 민감한 엔드포인트에 필수 적용.
|
||||
|
||||
@@ -81,8 +235,8 @@ async def require_api_key(api_key: str | None = Security(_api_key_header)) -> st
|
||||
detail="API 키가 필요합니다. X-API-Key 헤더를 설정하세요.",
|
||||
)
|
||||
|
||||
if not is_valid_credential(api_key):
|
||||
# 401: 자격증명 자체가 무효(잘못된 키·만료된 세션) — 프론트가 저장된 토큰을
|
||||
if not await is_valid_credential_v2(api_key):
|
||||
# 401: 자격증명 자체가 무효(잘못된 키·만료된 세션·회수된 계정) — 프론트가 저장된 토큰을
|
||||
# 지우고 로그인 화면으로 보내는 트리거. 403은 권한 부족(관리자 전용)에만 사용.
|
||||
# (세션 24h 만료 후 403이 돌아와 화면 전체가 오류로 멈추던 문제, 2026-09-22)
|
||||
logger.warning("[Security] API 키/세션 토큰 불일치 — 요청 거부")
|
||||
@@ -102,7 +256,7 @@ async def optional_api_key(api_key: str | None = Security(_api_key_header)) -> s
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
if not is_valid_credential(api_key):
|
||||
if not await is_valid_credential_v2(api_key):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="유효하지 않은 API 키이거나 만료된 세션입니다. 다시 로그인하세요.",
|
||||
@@ -110,6 +264,151 @@ async def optional_api_key(api_key: str | None = Security(_api_key_header)) -> s
|
||||
return api_key
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# H-2: 위험 쓰기 엔드포인트 — 관리자 전용
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# (METHOD, /api/v1 이후 라우트 템플릿). 라우터 파일을 건드리지 않고 한 곳에서 관리한다.
|
||||
# 새 위험 엔드포인트를 만들면 여기에 추가할 것.
|
||||
ADMIN_ONLY_ROUTES: frozenset[tuple[str, str]] = frozenset({
|
||||
("POST", "/risk/mdd/reset"),
|
||||
("PUT", "/strategy-config/{key}"),
|
||||
("PUT", "/strategy-config"),
|
||||
("POST", "/strategy-config/{key}/reset"),
|
||||
("POST", "/strategy-config/reset-all"),
|
||||
("PATCH", "/strategy-config/{key}/toggle"),
|
||||
("POST", "/daytrading/close-all"),
|
||||
("POST", "/daytrading/execute"),
|
||||
("POST", "/daytrading-v2/close-all"),
|
||||
("POST", "/playbook/circuit-breaker"),
|
||||
("DELETE", "/playbook/circuit-breaker"),
|
||||
("POST", "/mcp/tools/call"),
|
||||
})
|
||||
# 접두 일치 — 하위 전 경로·전 메서드가 관리자 전용
|
||||
ADMIN_ONLY_PREFIXES: tuple[str, ...] = ("/debug/",)
|
||||
_API_PREFIX = "/api/v1"
|
||||
|
||||
|
||||
def is_admin_only_route(method: str, route_path: str) -> bool:
|
||||
"""라우트 템플릿(예: /api/v1/strategy-config/{key})이 관리자 전용인가."""
|
||||
path = route_path[len(_API_PREFIX):] if route_path.startswith(_API_PREFIX) else route_path
|
||||
if any(path.startswith(p) for p in ADMIN_ONLY_PREFIXES):
|
||||
return True
|
||||
return (method.upper(), path) in ADMIN_ONLY_ROUTES
|
||||
|
||||
|
||||
async def _assert_admin_impl(api_key: str | None) -> CredentialInfo:
|
||||
info = await resolve_credential_v2(api_key)
|
||||
if info is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="유효하지 않은 API 키이거나 만료된 세션입니다. 다시 로그인하세요.",
|
||||
)
|
||||
if not info.is_admin:
|
||||
logger.warning("[Security] 관리자 전용 엔드포인트 — 비관리자 거부: user=%s", info.username)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="관리자 권한이 필요합니다.")
|
||||
return info
|
||||
|
||||
|
||||
async def require_admin_v2(api_key: str | None = Security(_api_key_header)) -> str:
|
||||
"""관리자 전용 의존성 (DB 세션 의존 없음). API 키 또는 활성 관리자 세션만 통과."""
|
||||
info = await _assert_admin_impl(api_key)
|
||||
return info.username
|
||||
|
||||
|
||||
async def require_admin_for_listed_routes(
|
||||
request: Request, api_key: str | None = Security(_api_key_header)
|
||||
) -> None:
|
||||
"""api_router 전역 의존성 — `ADMIN_ONLY_ROUTES` 에 해당하는 요청만 관리자 확인.
|
||||
|
||||
그 외 라우트에는 아무것도 하지 않는다(각 라우터의 require/optional 정책이 그대로 적용).
|
||||
"""
|
||||
route = request.scope.get("route")
|
||||
route_path = getattr(route, "path", None) or request.url.path
|
||||
if not is_admin_only_route(request.method, route_path):
|
||||
return
|
||||
await _assert_admin_impl(api_key)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# M-2: 클라이언트 IP — 신뢰 프록시의 X-Real-IP 만 사용
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_trusted_cache: tuple[str, list] | None = None
|
||||
|
||||
|
||||
def _trusted_proxy_networks() -> list:
|
||||
global _trusted_cache
|
||||
raw = settings.TRUSTED_PROXY_CIDRS or ""
|
||||
if _trusted_cache is not None and _trusted_cache[0] == raw:
|
||||
return _trusted_cache[1]
|
||||
nets = []
|
||||
for part in raw.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
nets.append(ipaddress.ip_network(part, strict=False))
|
||||
except ValueError:
|
||||
logger.warning("[Security] TRUSTED_PROXY_CIDRS 항목 무시(형식 오류): %r", part)
|
||||
_trusted_cache = (raw, nets)
|
||||
return nets
|
||||
|
||||
|
||||
def _is_trusted_proxy(host: str | None) -> bool:
|
||||
if not host:
|
||||
return False
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return False
|
||||
return any(ip in net for net in _trusted_proxy_networks())
|
||||
|
||||
|
||||
def client_ip_v2(request) -> str:
|
||||
"""요청자 IP.
|
||||
|
||||
- 직전 홉(소켓 주소)이 `TRUSTED_PROXY_CIDRS` 안이면 nginx 가 덮어쓴 `X-Real-IP` 를 쓴다.
|
||||
- 그 외에는 소켓 주소. **X-Forwarded-For 는 보지 않는다** — 클라이언트가 임의로 넣을 수
|
||||
있어 로그인 잠금을 우회하는 데 쓰였다(첫 값 신뢰).
|
||||
"""
|
||||
peer = request.client.host if getattr(request, "client", None) else None
|
||||
if _is_trusted_proxy(peer):
|
||||
real = (request.headers.get("x-real-ip") or "").strip()
|
||||
if real:
|
||||
try:
|
||||
ipaddress.ip_address(real)
|
||||
return real
|
||||
except ValueError:
|
||||
logger.warning("[Security] X-Real-IP 형식 오류 무시: %r", real[:64])
|
||||
return peer or "unknown"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# M-5: 키 강도 점검
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_WEAK_SECRETS = frozenset({
|
||||
"change-this-secret-key",
|
||||
"auto_stock_secret_key_change_in_production_2024",
|
||||
"secret",
|
||||
"your-secret-key-here",
|
||||
})
|
||||
|
||||
|
||||
def security_key_issues() -> list[str]:
|
||||
"""키 설정 문제 목록(값은 노출하지 않는다). 빈 리스트면 정상."""
|
||||
issues: list[str] = []
|
||||
min_len = int(settings.MIN_SECRET_KEY_LENGTH)
|
||||
sk = settings.SECRET_KEY or ""
|
||||
if sk.lower() in _WEAK_SECRETS:
|
||||
issues.append("SECRET_KEY 가 기본/예제 값이다")
|
||||
elif len(sk) < min_len:
|
||||
issues.append(f"SECRET_KEY 길이 {len(sk)} < {min_len}")
|
||||
if not settings.API_KEY:
|
||||
issues.append("API_KEY 미설정 — API 키가 SECRET_KEY 로 폴백된다(키 분리 권장)")
|
||||
elif len(settings.API_KEY) < min_len:
|
||||
issues.append(f"API_KEY 길이 {len(settings.API_KEY)} < {min_len}")
|
||||
return issues
|
||||
|
||||
|
||||
def generate_secure_key(length: int = 64) -> str:
|
||||
"""보안 키 생성 유틸리티 (운영 환경 시크릿 생성용)."""
|
||||
return secrets.token_urlsafe(length)
|
||||
|
||||
Reference in New Issue
Block a user