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
+28
View File
@@ -31,3 +31,31 @@ CAFE24_REDIRECT_URI=
# 앱 설정
# ============================================
APP_PORT=8002
# NPM 리버스 프록시 뒤 서브경로 호스팅
APP_ROOT_PATH=/corm
# ============================================
# SSO (dbx-main 세션 공유)
# ============================================
# dbx-main, OMS 와 반드시 같은 값으로 맞춰야 SSO 동작.
# 미설정 시 컨테이너 기동 실패 (fail-fast). openssl rand -hex 32 로 생성.
SESSION_SECRET_KEY=__paste-same-value-as-dbx-main__
SESSION_COOKIE_NAME=session
SESSION_SAME_SITE=lax
SESSION_HTTPS_ONLY=1
SESSION_MAX_AGE=28800
# 세션에서 읽을 사용자 키. dbx-main 이 어느 키로 쓰는지에 맞춰라.
SESSION_USER_KEY=user_email
SESSION_NAME_KEY=user_name
# 미인증 시 보낼 dbx-main 로그인/로그아웃 URL
MAIN_LOGIN_URL=https://dbx.no1king.freeddns.org/login
MAIN_LOGOUT_URL=https://dbx.no1king.freeddns.org/logout
# ============================================
# Docker network
# ============================================
POSTGRES_NETWORK=postgres_default
WEB_NETWORK=npm_default
+4 -1
View File
@@ -16,4 +16,7 @@ COPY . .
EXPOSE 8002
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8002", "--proxy-headers", "--forwarded-allow-ips=*"]
# APP_ROOT_PATH 를 uvicorn --root-path 로 넘긴다 (shell form 필요 — 환경변수 확장).
# NPM 이 prefix 를 잘라 백엔드의 / 로 전달하므로 라우트는 prefix 없이 등록되고
# URL 생성 시에만 ASGI scope.root_path 가 prepend 된다.
CMD uvicorn main:app --host 0.0.0.0 --port 8002 --proxy-headers --forwarded-allow-ips=* --root-path "${APP_ROOT_PATH:-}"
+22 -6
View File
@@ -4,19 +4,35 @@ services:
image: dbx-corm:latest
container_name: dbx-corm
restart: unless-stopped
ports:
- "8002:8002"
env_file:
- .env
environment:
# NPM 리버스 프록시 뒤 서브경로 호스팅.
APP_ROOT_PATH: ${APP_ROOT_PATH:-/corm}
# 호스트 포트 바인딩 없음 — NPM(Nginx Proxy Manager) 를 통해서만 접근.
expose:
- "8002"
volumes:
# 민감 파일은 Git에 안 올리고 서버 호스트에서 직접 마운트
- ./cafe24_tokens.json:/app/cafe24_tokens.json
- ./manual-ordering.json:/app/manual-ordering.json
# 토큰 파일 자동 갱신 결과를 호스트에 영구 보존
networks:
- postgres-net
- postgres_net
- web_net
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8002/health/db"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
networks:
postgres-net:
# 이미 운영 중인 postgres-db 컨테이너가 속해 있는 외부 네트워크.
postgres_net:
external: true
name: postgres_default # 서버의 실제 PostgreSQL 네트워크 이름으로 수정
name: ${POSTGRES_NETWORK:-postgres_default}
# NPM(Nginx Proxy Manager) 가 속해 있는 외부 네트워크.
# NPM 이 dbx-corm 컨테이너에 컨테이너 이름으로 도달 가능해야 한다.
web_net:
external: true
name: ${WEB_NETWORK:-npm_default}
+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 = {}
+21
View File
@@ -1,4 +1,25 @@
// ============================================================
// 서브경로 호스팅 — fetch wrapper
// 모든 /api/* 호출에 APP_BASE_PATH 를 prepend, 401 응답 시 dbx-main 로그인으로 이동.
// ============================================================
(function () {
const APP_BASE_PATH = (window.APP_BASE_PATH || '').replace(/\/$/, '');
const origFetch = window.fetch.bind(window);
window.fetch = async function (input, init) {
if (typeof input === 'string' && APP_BASE_PATH && input.startsWith('/api/')) {
input = APP_BASE_PATH + input;
}
const response = await origFetch(input, init);
if (response.status === 401) {
// 세션 만료 — dbx-main 로그인으로 위임 (next 로 현재 위치 보존)
const next = encodeURIComponent((APP_BASE_PATH || '') + window.location.pathname + window.location.search);
window.location.href = (APP_BASE_PATH || '') + '/login?next=' + next;
}
return response;
};
})();
function showToast(message) {
const toast = document.createElement('div');
toast.textContent = message;
+6 -2
View File
@@ -6,8 +6,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>[No.1 King] CS 통합 관리앱 (미라클주방)</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/styles.css?v=5">
<link rel="stylesheet" href="{{ root_path }}/static/css/styles.css?v=5">
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
<script>
// 서브경로 호스팅 정보 — JS 가 fetch 시 prefix 로 사용한다.
window.APP_BASE_PATH = "{{ root_path }}";
</script>
<style>
.hover-row:hover {
background-color: #a8e0ff !important;
@@ -891,7 +895,7 @@
{% include 'version_history.html' %}
</main>
</div>
<script src="/static/js/app.js?v=30"></script>
<script src="{{ root_path }}/static/js/app.js?v=30"></script>
<!-- Toast Notification -->
<div id="toast-notification"