feat: SSO via shared session with dbx-main, sub-path hosting at /orderlist

This commit is contained in:
king
2026-05-24 05:35:19 +09:00
parent 75ab736e8f
commit c966d41628
4 changed files with 159 additions and 51 deletions
+33 -11
View File
@@ -1,28 +1,50 @@
# ===== Database (PostgreSQL) ===== # ===== Database (PostgreSQL) =====
# 앱 컨테이너는 같은 Docker network 안의 postgres-db 컨테이너에 접속합니다.
DB_HOST=postgres-db DB_HOST=postgres-db
DB_PORT=5432 DB_PORT=5432
DB_USER=king DB_USER=king
DB_PASSWORD=change-me-strong-password DB_PASSWORD=change-me-strong-password
# 프로젝트별 DB 이름
ORDER_DB_NAME=orderlist_db ORDER_DB_NAME=orderlist_db
ITEM_DB_NAME=itemcode_db ITEM_DB_NAME=itemcode_db
RETURN_DB_NAME=return_db RETURN_DB_NAME=return_db
CHECKLIST_DB_NAME=checklist_db CHECKLIST_DB_NAME=checklist_db
# (선택) DATABASE_URL을 직접 지정하면 위 DB_* 보다 우선 사용됩니다. # (선택) 직접 DATABASE_URL 지정 위 DB_* 보다 우선
# DATABASE_URL=postgresql+psycopg2://king:password@postgres-db:5432/orderlist_db # DATABASE_URL=postgresql+psycopg2://king:password@postgres-db:5432/orderlist_db
# ===== App ===== # ===== App =====
APP_ROOT_PATH= # NPM 리버스 프록시 뒤에서 /orderlist/ 서브경로로 호스팅된다.
SESSION_SECRET_KEY=please-change-this-to-a-long-random-string APP_ROOT_PATH=/orderlist
# ===== Default admin (구글 로그인 제거 후, /login 시 자동 생성/로그인되는 계정) ===== # ===== SSO (dbx-main 세션 공유) =====
DEFAULT_ADMIN_EMAIL=king@dbxcorp.co.kr # dbx-main 과 OMS 가 반드시 같은 값으로 맞춰야 SSO가 동작한다.
DEFAULT_ADMIN_NAME=Administrator # - SESSION_SECRET_KEY : 두 앱이 동일해야 세션 쿠키를 서로 복호화 가능
# - SESSION_COOKIE_NAME : 두 앱이 동일해야 같은 쿠키를 본다 (기본 'session')
SESSION_SECRET_KEY=__paste-same-value-as-dbx-main__
SESSION_COOKIE_NAME=session
SESSION_SAME_SITE=lax
SESSION_HTTPS_ONLY=1
SESSION_MAX_AGE=1209600
# 세션에서 읽을 사용자 식별 키. 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
# (선택) 신뢰된 업스트림 헤더로 사용자 정보 받기 (auth_request 패턴)
# 활성화하려면 1, 그리고 NPM/nginx 에서 인증 검증 후 헤더를 주입해야 한다.
ALLOW_TRUSTED_USER_HEADER=0
TRUSTED_USER_HEADER=X-Forwarded-User
TRUSTED_NAME_HEADER=X-Forwarded-User-Name
# dbx-main 에서 인증된 사용자를 OMS DB 에 자동 등록할지
AUTO_PROVISION_USERS=1
# 자동 등록 시 is_admin=True 로 만들 이메일(쉼표 구분)
ADMIN_EMAILS=king@dbxcorp.co.kr
# ===== Docker network ===== # ===== Docker network =====
# 이미 운영 중인 postgres-db가 속한 외부 네트워크 이름. POSTGRES_NETWORK=postgres_default
# `docker inspect postgres-db --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}'` 로 확인. WEB_NETWORK=npm_default
POSTGRES_NETWORK=postgres-net
+101 -28
View File
@@ -1,4 +1,24 @@
"""
SSO 모드 인증 모듈.
dbx-main(메인 페이지) 이 Google OAuth 를 수행하여 같은 도메인의 세션 쿠키에
사용자 이메일을 저장한다고 가정한다. OMS 는 그 세션을 읽기만 한다.
호환 방식 (둘 중 하나 이상이 충족되면 인증 성공):
1. Starlette SessionMiddleware 공유 — dbx-main 과 OMS 가 동일한
SESSION_SECRET_KEY / SESSION_COOKIE_NAME 을 사용하고, 세션에
'user_email' 키가 저장돼 있다.
2. 신뢰된 업스트림 헤더 — Nginx/NPM 의 auth_request 같은 메커니즘으로
dbx-main 이 인증한 사용자의 이메일을 헤더로 전달한다.
ALLOW_TRUSTED_USER_HEADER=1 일 때만 활성화. 헤더 이름은
TRUSTED_USER_HEADER (기본 X-Forwarded-User).
세션에 사용자 이메일이 있고 OMS DB 에 그 사용자가 없으면, 정책에 따라
자동 프로비저닝한다 (AUTO_PROVISION_USERS=1 일 때, 기본 1).
"""
import os import os
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
@@ -10,8 +30,22 @@ from app.models import User
router = APIRouter(tags=["auth"]) router = APIRouter(tags=["auth"])
DEFAULT_ADMIN_EMAIL = os.getenv("DEFAULT_ADMIN_EMAIL", "king@dbxcorp.co.kr") # ---- 환경설정 ----------------------------------------------------------
DEFAULT_ADMIN_NAME = os.getenv("DEFAULT_ADMIN_NAME", "Administrator") 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")
ALLOW_TRUSTED_USER_HEADER = os.getenv("ALLOW_TRUSTED_USER_HEADER", "0") == "1"
TRUSTED_USER_HEADER = os.getenv("TRUSTED_USER_HEADER", "X-Forwarded-User")
TRUSTED_NAME_HEADER = os.getenv("TRUSTED_NAME_HEADER", "X-Forwarded-User-Name")
AUTO_PROVISION_USERS = os.getenv("AUTO_PROVISION_USERS", "1") == "1"
ADMIN_EMAILS = {
e.strip().lower()
for e in os.getenv("ADMIN_EMAILS", "king@dbxcorp.co.kr").split(",")
if e.strip()
}
def app_path(path: str = "/") -> str: def app_path(path: str = "/") -> str:
@@ -21,17 +55,59 @@ def app_path(path: str = "/") -> str:
return f"{root_path}{path}" return f"{root_path}{path}"
def _resolve_authenticated_email(request: Request) -> Optional[tuple[str, Optional[str]]]:
"""현재 요청에서 인증된 사용자의 (email, name) 을 추출. 없으면 None."""
# 1) 신뢰된 업스트림 헤더 우선 (명시적으로 켜진 경우만)
if ALLOW_TRUSTED_USER_HEADER:
header_email = request.headers.get(TRUSTED_USER_HEADER)
if header_email:
header_name = request.headers.get(TRUSTED_NAME_HEADER)
return header_email.strip().lower(), header_name
# 2) 공유 세션 쿠키 (Starlette SessionMiddleware)
session_email = request.session.get(SESSION_USER_KEY)
if session_email:
session_name = request.session.get(SESSION_NAME_KEY)
return session_email.strip().lower(), session_name
return None
def _provision_user(db: Session, email: str, name: Optional[str]) -> User:
"""OMS DB 에 사용자를 새로 생성."""
is_admin = email.lower() in ADMIN_EMAILS
user = User(
email=email,
name=name or email.split("@")[0],
is_admin=is_admin,
is_approved=True, # dbx-main 에서 인증된 사용자는 신뢰
)
db.add(user)
db.commit()
db.refresh(user)
return user
def get_current_user(request: Request, db: Session = Depends(get_db)): def get_current_user(request: Request, db: Session = Depends(get_db)):
user_email = request.session.get("user_email") resolved = _resolve_authenticated_email(request)
if not user_email: if not resolved:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
user = db.query(User).filter(User.email == user_email).first() email, name = resolved
user = db.query(User).filter(User.email == email).first()
if not user: if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") if not AUTO_PROVISION_USERS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User is not registered in OMS",
)
user = _provision_user(db, email, name)
if not user.is_approved and not user.is_admin: if not user.is_approved and not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is not approved by administrator") raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User is not approved by administrator",
)
return user return user
@@ -42,35 +118,32 @@ def get_admin_user(current_user: User = Depends(get_current_user)):
return current_user return current_user
def _login_as_default_admin(request: Request, db: Session) -> RedirectResponse: def _next_param(request: Request) -> str:
email = DEFAULT_ADMIN_EMAIL """로그인 후 돌아올 절대 경로(서브경로 prefix 포함)."""
user = db.query(User).filter(User.email == email).first() root = os.getenv("APP_ROOT_PATH", "").rstrip("/")
if not user: return f"{root}/" if root else "/"
user = User(email=email, name=DEFAULT_ADMIN_NAME, is_admin=True, is_approved=True)
db.add(user)
db.commit()
db.refresh(user)
request.session["user_email"] = email
return RedirectResponse(url=app_path("/"))
@router.get("/login") @router.get("/login")
async def login(request: Request, db: Session = Depends(get_db)): async def login(request: Request):
return _login_as_default_admin(request, db) """OMS 자체 로그인 없음. dbx-main 의 로그인 페이지로 위임."""
sep = "&" if "?" in MAIN_LOGIN_URL else "?"
target = f"{MAIN_LOGIN_URL}{sep}next={_next_param(request)}"
@router.get("/login/test") return RedirectResponse(url=target, status_code=302)
async def login_test(request: Request, db: Session = Depends(get_db)):
return _login_as_default_admin(request, db)
@router.get("/logout") @router.get("/logout")
async def logout(request: Request): async def logout(request: Request):
request.session.pop("user_email", None) """OMS 세션 흔적 제거 후 dbx-main 로그아웃으로 위임."""
return RedirectResponse(url=app_path("/")) request.session.pop(SESSION_USER_KEY, None)
request.session.pop(SESSION_NAME_KEY, None)
return RedirectResponse(url=MAIN_LOGOUT_URL, status_code=302)
@router.get("/api/users/me") @router.get("/api/users/me")
async def read_users_me(current_user: User = Depends(get_current_user)): async def read_users_me(current_user: User = Depends(get_current_user)):
return {"email": current_user.email, "name": current_user.name, "is_admin": current_user.is_admin} return {
"email": current_user.email,
"name": current_user.name,
"is_admin": current_user.is_admin,
}
+12 -3
View File
@@ -31,9 +31,18 @@ async def health_db():
status_code = 200 if ok else 503 status_code = 200 if ok else 503
return JSONResponse({"ok": ok, "detail": msg}, status_code=status_code) return JSONResponse({"ok": ok, "detail": msg}, status_code=status_code)
# Session middleware for OAuth # ---- Session middleware ----
# In production, use a secure secret key from environment variables # dbx-main 과 SSO 하려면 SESSION_SECRET_KEY 와 SESSION_COOKIE_NAME 을
app.add_middleware(SessionMiddleware, secret_key=os.getenv("SESSION_SECRET_KEY", "super-secret-key")) # 두 앱이 동일하게 맞춰야 한다. 그래야 dbx-main 이 발급한 세션 쿠키를
# OMS 가 그대로 읽을 수 있다.
app.add_middleware(
SessionMiddleware,
secret_key=os.getenv("SESSION_SECRET_KEY", "super-secret-key"),
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", str(60 * 60 * 24 * 14))), # 14d
)
# Include routers # Include routers
app.include_router(auth_router) app.include_router(auth_router)
+13 -9
View File
@@ -7,14 +7,16 @@ services:
env_file: env_file:
- .env - .env
environment: environment:
# .env에 적힌 값이 컨테이너에서 그대로 보입니다.
# 컨테이너 내부에서 DB_HOST는 반드시 postgres-db 여야 합니다. # 컨테이너 내부에서 DB_HOST는 반드시 postgres-db 여야 합니다.
DB_HOST: ${DB_HOST:-postgres-db} DB_HOST: ${DB_HOST:-postgres-db}
ports: # NPM 리버스 프록시 뒤 서브경로 호스팅.
- "8000:8000" APP_ROOT_PATH: ${APP_ROOT_PATH:-/orderlist}
# ports 블록 의도적으로 없음.
# 외부에는 NPM(Nginx Proxy Manager) 를 통해서만 노출되어야 한다.
# 직접 호스트 포트로 노출하면 dbx.no1king.freeddns.org/orderlist/ 우회로 IP:PORT 접근이 가능해진다.
networks: networks:
- postgres_net - postgres_net
- default - web_net
healthcheck: healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/health/db"] test: ["CMD", "curl", "-fsS", "http://localhost:8000/health/db"]
interval: 30s interval: 30s
@@ -23,10 +25,12 @@ services:
start_period: 20s start_period: 20s
networks: networks:
# 이미 운영 중인 postgres-db 컨테이너가 속해 있는 외부 네트워크 # 이미 운영 중인 postgres-db 컨테이너가 속해 있는 외부 네트워크.
# 그대로 attach 합니다. (compose가 새로 만들지 않음)
postgres_net: postgres_net:
external: true external: true
name: ${POSTGRES_NETWORK:-postgres-net} name: ${POSTGRES_NETWORK:-postgres_default}
default: # NPM(Nginx Proxy Manager) 또는 dbx-main nginx 가 속해 있는 외부 네트워크.
driver: bridge # NPM이 orderlist-app 컨테이너에 도달할 수 있어야 한다.
web_net:
external: true
name: ${WEB_NETWORK:-npm_default}