feat: SSO via shared session with dbx-main, sub-path hosting at /orderlist
This commit is contained in:
+33
-11
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user