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:
@@ -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 = {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user