diff --git a/.env.example b/.env.example index 8d24d78..bf0917a 100644 --- a/.env.example +++ b/.env.example @@ -1,28 +1,50 @@ # ===== Database (PostgreSQL) ===== -# 앱 컨테이너는 같은 Docker network 안의 postgres-db 컨테이너에 접속합니다. DB_HOST=postgres-db DB_PORT=5432 DB_USER=king DB_PASSWORD=change-me-strong-password -# 프로젝트별 DB 이름 ORDER_DB_NAME=orderlist_db ITEM_DB_NAME=itemcode_db RETURN_DB_NAME=return_db CHECKLIST_DB_NAME=checklist_db -# (선택) DATABASE_URL을 직접 지정하면 위 DB_* 값보다 우선 사용됩니다. +# (선택) 직접 DATABASE_URL 지정 시 위 DB_* 보다 우선 # DATABASE_URL=postgresql+psycopg2://king:password@postgres-db:5432/orderlist_db # ===== App ===== -APP_ROOT_PATH= -SESSION_SECRET_KEY=please-change-this-to-a-long-random-string +# NPM 리버스 프록시 뒤에서 /orderlist/ 서브경로로 호스팅된다. +APP_ROOT_PATH=/orderlist -# ===== Default admin (구글 로그인 제거 후, /login 시 자동 생성/로그인되는 계정) ===== -DEFAULT_ADMIN_EMAIL=king@dbxcorp.co.kr -DEFAULT_ADMIN_NAME=Administrator +# ===== SSO (dbx-main 세션 공유) ===== +# dbx-main 과 OMS 가 반드시 같은 값으로 맞춰야 SSO가 동작한다. +# - 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 ===== -# 이미 운영 중인 postgres-db가 속한 외부 네트워크 이름. -# `docker inspect postgres-db --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}'` 로 확인. -POSTGRES_NETWORK=postgres-net +POSTGRES_NETWORK=postgres_default +WEB_NETWORK=npm_default diff --git a/app/auth.py b/app/auth.py index a8324b5..d14af78 100644 --- a/app/auth.py +++ b/app/auth.py @@ -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 +from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import RedirectResponse @@ -10,8 +30,22 @@ from app.models import User 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: @@ -21,17 +55,59 @@ def app_path(path: str = "/") -> str: 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)): - user_email = request.session.get("user_email") - if not user_email: + resolved = _resolve_authenticated_email(request) + if not resolved: 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: - 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: - 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 @@ -42,35 +118,32 @@ def get_admin_user(current_user: User = Depends(get_current_user)): return current_user -def _login_as_default_admin(request: Request, db: Session) -> RedirectResponse: - email = DEFAULT_ADMIN_EMAIL - user = db.query(User).filter(User.email == email).first() - if not user: - 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("/")) +def _next_param(request: Request) -> str: + """로그인 후 돌아올 절대 경로(서브경로 prefix 포함).""" + root = os.getenv("APP_ROOT_PATH", "").rstrip("/") + return f"{root}/" if root else "/" @router.get("/login") -async def login(request: Request, db: Session = Depends(get_db)): - return _login_as_default_admin(request, db) - - -@router.get("/login/test") -async def login_test(request: Request, db: Session = Depends(get_db)): - return _login_as_default_admin(request, db) +async def login(request: Request): + """OMS 자체 로그인 없음. dbx-main 의 로그인 페이지로 위임.""" + sep = "&" if "?" in MAIN_LOGIN_URL else "?" + target = f"{MAIN_LOGIN_URL}{sep}next={_next_param(request)}" + return RedirectResponse(url=target, status_code=302) @router.get("/logout") async def logout(request: Request): - request.session.pop("user_email", None) - return RedirectResponse(url=app_path("/")) + """OMS 세션 흔적 제거 후 dbx-main 로그아웃으로 위임.""" + 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") 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, + } diff --git a/app/main.py b/app/main.py index 6c4257a..fd93df9 100644 --- a/app/main.py +++ b/app/main.py @@ -31,9 +31,18 @@ async def health_db(): status_code = 200 if ok else 503 return JSONResponse({"ok": ok, "detail": msg}, status_code=status_code) -# Session middleware for OAuth -# In production, use a secure secret key from environment variables -app.add_middleware(SessionMiddleware, secret_key=os.getenv("SESSION_SECRET_KEY", "super-secret-key")) +# ---- Session middleware ---- +# dbx-main 과 SSO 하려면 SESSION_SECRET_KEY 와 SESSION_COOKIE_NAME 을 +# 두 앱이 동일하게 맞춰야 한다. 그래야 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 app.include_router(auth_router) diff --git a/docker-compose.yml b/docker-compose.yml index 81a83dc..ba5d942 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,14 +7,16 @@ services: env_file: - .env environment: - # .env에 적힌 값이 컨테이너에서 그대로 보입니다. # 컨테이너 내부에서 DB_HOST는 반드시 postgres-db 여야 합니다. DB_HOST: ${DB_HOST:-postgres-db} - ports: - - "8000:8000" + # NPM 리버스 프록시 뒤 서브경로 호스팅. + APP_ROOT_PATH: ${APP_ROOT_PATH:-/orderlist} + # ports 블록 의도적으로 없음. + # 외부에는 NPM(Nginx Proxy Manager) 를 통해서만 노출되어야 한다. + # 직접 호스트 포트로 노출하면 dbx.no1king.freeddns.org/orderlist/ 우회로 IP:PORT 접근이 가능해진다. networks: - postgres_net - - default + - web_net healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:8000/health/db"] interval: 30s @@ -23,10 +25,12 @@ services: start_period: 20s networks: - # 이미 운영 중인 postgres-db 컨테이너가 속해 있는 외부 네트워크에 - # 그대로 attach 합니다. (compose가 새로 만들지 않음) + # 이미 운영 중인 postgres-db 컨테이너가 속해 있는 외부 네트워크. postgres_net: external: true - name: ${POSTGRES_NETWORK:-postgres-net} - default: - driver: bridge + name: ${POSTGRES_NETWORK:-postgres_default} + # NPM(Nginx Proxy Manager) 또는 dbx-main nginx 가 속해 있는 외부 네트워크. + # NPM이 orderlist-app 컨테이너에 도달할 수 있어야 한다. + web_net: + external: true + name: ${WEB_NETWORK:-npm_default}