feat(sso): dbx-main SSO 통합, /corm/ 서브경로 호스팅, 인증 미들웨어 적용

- SessionMiddleware 추가 (dbx-main/OMS 와 동일 SECRET_KEY/COOKIE_NAME/MAX_AGE).
  미설정 시 fail-fast.
- AuthGuardMiddleware: 미인증 시 HTML 요청은 dbx-main /login?next=... 으로 302,
  API 요청은 401 JSON. 예외 경로: /static/, /health, OAuth 콜백, /login, /logout.
- /health/db 엔드포인트 추가 (DB ping) — dbx-main 헬스 배지가 폴링.
- /login, /logout 라우트 추가 (자체 OAuth 없음, dbx-main 으로 302 위임).
- APP_ROOT_PATH 환경변수 도입 — Dockerfile 의 uvicorn --root-path 로 전달.
- templates/index.html: 모든 /static/, /js 절대경로를 {{ root_path }} 기반으로,
  window.APP_BASE_PATH 변수 주입.
- static/js/app.js: fetch wrapper 1개로 /api/* 호출에 APP_BASE_PATH prepend,
  401 응답 시 dbx-main 로그인으로 자동 이동. 32 곳 호출 사이트는 그대로 둠.
- Cafe24 OAuth 콜백의 hard-coded redirect 도 root_path 인지하도록 수정.
- docker-compose.yml: 호스트 포트 바인딩 제거(0.0.0.0:8002 노출 차단),
  expose 추가, web_net(npm_default) 외부 네트워크 attach, healthcheck 추가.
- .env.example 에 SSO/네트워크 항목 추가.
This commit is contained in:
2026-05-24 15:44:28 +09:00
parent a5fa1f43f3
commit 31fa8d573b
6 changed files with 203 additions and 16 deletions
+122 -7
View File
@@ -12,9 +12,12 @@ import configparser
import urllib.request
import urllib.parse
import uuid
from urllib.parse import quote
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request, Body, UploadFile, File, Form, Query, BackgroundTasks
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, FileResponse
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, FileResponse, RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.sessions import SessionMiddleware
import pandas as pd
from naver_api import get_access_token, get_payment_done_orders, get_product_order_details, dispatch_orders, confirm_orders, get_product_details
import cafe24_api
@@ -26,6 +29,69 @@ from typing import List, Optional, Dict, Any
# .env 로드
load_dotenv()
# ==========================================
# SSO / 서브경로 호스팅 설정
# ==========================================
APP_ROOT_PATH = os.getenv("APP_ROOT_PATH", "").rstrip("/")
MAIN_LOGIN_URL = os.getenv("MAIN_LOGIN_URL", "https://dbx.no1king.freeddns.org/login")
MAIN_LOGOUT_URL = os.getenv("MAIN_LOGOUT_URL", "https://dbx.no1king.freeddns.org/logout")
SESSION_USER_KEY = os.getenv("SESSION_USER_KEY", "user_email")
SESSION_NAME_KEY = os.getenv("SESSION_NAME_KEY", "user_name")
# 인증 우회 경로 — 정적 자원, 헬스, OAuth 콜백, 로그인/로그아웃 위임
# Starlette scope.path 는 root_path 가 제거된 형태로 들어오므로 prefix 없이 매칭.
_AUTH_EXEMPT_PREFIXES = (
"/static/",
"/health",
"/api/cafe24/callback",
"/admin/oauth/callback",
"/login",
"/logout",
"/favicon.ico",
)
def _require_session_secret() -> str:
secret = os.getenv("SESSION_SECRET_KEY", "").strip()
if not secret:
raise RuntimeError(
"SESSION_SECRET_KEY 환경변수가 설정되지 않았습니다. "
"openssl rand -hex 32 로 새 값을 만들어 .env 에 넣고 컨테이너를 재기동하세요. "
"dbx-main 과 SSO 하려면 양쪽 .env 에 같은 값이어야 합니다."
)
return secret
class AuthGuardMiddleware(BaseHTTPMiddleware):
"""세션에 user_email 이 없으면 차단.
- HTML 요청 (Accept: text/html 또는 / 같은 페이지) → dbx-main /login?next=... 302
- 그 외 (API 등) → 401 JSON
- _AUTH_EXEMPT_PREFIXES 에 해당하는 경로는 통과
"""
async def dispatch(self, request: Request, call_next):
path = request.url.path # ASGI 에서 root_path 가 빠진 경로
for prefix in _AUTH_EXEMPT_PREFIXES:
if path == prefix or path.startswith(prefix):
return await call_next(request)
email = request.session.get(SESSION_USER_KEY)
if email:
return await call_next(request)
# 미인증
accept = request.headers.get("accept", "")
wants_html = "text/html" in accept or not path.startswith("/api/")
if wants_html:
next_path = f"{APP_ROOT_PATH}{path}" if APP_ROOT_PATH else path
if request.url.query:
next_path = f"{next_path}?{request.url.query}"
sep = "&" if "?" in MAIN_LOGIN_URL else "?"
target = f"{MAIN_LOGIN_URL}{sep}next={quote(next_path, safe='/')}"
return RedirectResponse(url=target, status_code=302)
return JSONResponse({"detail": "Not authenticated"}, status_code=401)
# ==========================================
# 데이터베이스 접속 설정 (환경변수 기반)
# ==========================================
@@ -72,6 +138,49 @@ except ImportError:
app = FastAPI(title="CS Web Program")
# 세션 미들웨어 — dbx-main 과 동일한 SESSION_SECRET_KEY / SESSION_COOKIE_NAME 사용 시
# 같은 도메인의 세션 쿠키를 그대로 읽을 수 있다 (SSO).
app.add_middleware(
SessionMiddleware,
secret_key=_require_session_secret(),
session_cookie=os.getenv("SESSION_COOKIE_NAME", "session"),
same_site=os.getenv("SESSION_SAME_SITE", "lax"),
https_only=os.getenv("SESSION_HTTPS_ONLY", "0") == "1",
max_age=int(os.getenv("SESSION_MAX_AGE", "28800")),
path="/",
)
# 인증 가드 — 정적/헬스/콜백/로그인·로그아웃 외 모든 경로에 적용
app.add_middleware(AuthGuardMiddleware)
@app.get("/health/db")
async def health_db():
"""DB ping — 인증 불필요. dbx-main 의 헬스 배지가 이 경로를 폴링한다."""
try:
conn = psycopg2.connect(**DB_CONFIG, connect_timeout=3)
conn.close()
return JSONResponse({"ok": True, "detail": "ok"})
except Exception as e:
return JSONResponse({"ok": False, "detail": str(e)}, status_code=503)
@app.get("/login")
async def corm_login(request: Request, next: Optional[str] = None):
"""OMS 와 동일 패턴 — 자체 OAuth 없음. dbx-main 으로 위임."""
root = APP_ROOT_PATH
fallback = f"{root}/" if root else "/"
safe = next if (next and next.startswith("/") and not next.startswith("//") and not next.startswith("/\\")) else fallback
sep = "&" if "?" in MAIN_LOGIN_URL else "?"
target = f"{MAIN_LOGIN_URL}{sep}next={quote(safe, safe='/')}"
return RedirectResponse(url=target, status_code=302)
@app.get("/logout")
async def corm_logout():
"""전적으로 dbx-main 에 위임. CORM 은 세션을 직접 만지지 않는다 (쿠키 path 분리 방지)."""
return RedirectResponse(url=MAIN_LOGOUT_URL, status_code=302)
from routers.returns import router as returns_router
app.include_router(returns_router)
@@ -168,11 +277,16 @@ async def index(request: Request):
except Exception as e:
print("버전 히스토리 로드 실패:", e)
version_history = []
return templates.TemplateResponse(
request=request,
name="index.html",
context={"version_history": version_history}
request=request,
name="index.html",
context={
"version_history": version_history,
"root_path": APP_ROOT_PATH,
"user_email": request.session.get(SESSION_USER_KEY, ""),
"user_name": request.session.get(SESSION_NAME_KEY, ""),
}
)
@app.get("/api/config")
@@ -1368,11 +1482,12 @@ async def cafe24_login():
@app.get("/api/cafe24/callback")
@app.get("/admin/oauth/callback")
async def cafe24_callback(code: str):
home = f"{APP_ROOT_PATH}/" if APP_ROOT_PATH else "/"
try:
cafe24_api.request_new_token(code)
return HTMLResponse("<script>alert('카페24 연동 성공!'); window.location.href='/';</script>")
return HTMLResponse(f"<script>alert('카페24 연동 성공!'); window.location.href='{home}';</script>")
except Exception as e:
return HTMLResponse(f"<script>alert('인증 실패: {e}'); window.location.href='/';</script>")
return HTMLResponse(f"<script>alert('인증 실패: {e}'); window.location.href='{home}';</script>")
task_store = {}