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:
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -172,7 +281,12 @@ async def index(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={"version_history": version_history}
|
||||
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 = {}
|
||||
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
Reference in New Issue
Block a user