316e3fe8d1
- 로그인: dbxcorp.co.kr 도메인 화이트리스트만 사용. ALLOWED_EMAILS 게이트 제거 - king@dbxcorp.co.kr 슈퍼관리자 자동 부여 (강등/삭제 불가) - 신규 로그인 사용자는 JSON 저장소에 자동 등록 (기본 모듈 권한 0) - 관리자 페이지(/admin): 역할/모듈별 토글로 직원 권한 수정 - 메인 대시보드를 ERP 시스템(monochromatic shadcn)으로 재설계 · 모듈: CORM, Order, 개인경비(준비중), 휴가(준비중) · 권한 없는 모듈은 '권한 없음' 배지 + 클릭 불가 - DESIGN.md 토큰 기반 styles → app/static/erp.css - docker-compose: dbx-main-data 볼륨 마운트 (DATA_DIR=/data) 으로 권한 JSON 영구 보관 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
394 lines
13 KiB
Python
394 lines
13 KiB
Python
import inspect
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from authlib.integrations.starlette_client import OAuth, OAuthError
|
|
from dotenv import load_dotenv
|
|
from fastapi import Depends, FastAPI, HTTPException, Request
|
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from pydantic import BaseModel
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
|
|
from .store import (
|
|
MODULE_KEYS,
|
|
SUPER_ADMIN_EMAIL,
|
|
UserStore,
|
|
allowed_modules,
|
|
has_module,
|
|
is_admin,
|
|
)
|
|
|
|
# OMS(orderlist) 와 공유하는 세션 키. SessionMiddleware 의 session_cookie 도 동일 이름.
|
|
SESSION_COOKIE_DEFAULT = "session"
|
|
|
|
|
|
load_dotenv()
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent
|
|
ALLOWED_DOMAIN = "dbxcorp.co.kr"
|
|
|
|
|
|
def _data_dir() -> Path:
|
|
"""사용자/권한 JSON 저장소 위치. 컨테이너 재배포에도 살아남도록
|
|
DATA_DIR 환경변수로 마운트된 볼륨을 가리킬 수 있다."""
|
|
override = os.getenv("DATA_DIR", "").strip()
|
|
if override:
|
|
return Path(override)
|
|
return BASE_DIR / "data"
|
|
|
|
|
|
DATA_DIR = _data_dir()
|
|
|
|
|
|
def env(name: str, default: str = "") -> str:
|
|
value = os.getenv(name, "").strip()
|
|
return value if value else default
|
|
|
|
|
|
def _require_session_secret() -> str:
|
|
secret = env("SESSION_SECRET_KEY", "")
|
|
if not secret:
|
|
raise RuntimeError(
|
|
"SESSION_SECRET_KEY 환경변수가 설정되지 않았습니다. "
|
|
"openssl rand -hex 32 로 새 값을 만들어 .env 에 넣고 컨테이너를 재기동하세요. "
|
|
"OMS(orderlist) 와 SSO 하려면 양쪽 .env 에 같은 값이어야 합니다."
|
|
)
|
|
return secret
|
|
|
|
|
|
def build_google_oauth() -> OAuth:
|
|
oauth = OAuth()
|
|
oauth.register(
|
|
name="google",
|
|
client_id=env("GOOGLE_CLIENT_ID"),
|
|
client_secret=env("GOOGLE_CLIENT_SECRET"),
|
|
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
|
client_kwargs={"scope": "openid email profile"},
|
|
)
|
|
return oauth
|
|
|
|
|
|
app = FastAPI(title="DBX 메인 페이지")
|
|
app.add_middleware(
|
|
SessionMiddleware,
|
|
secret_key=_require_session_secret(),
|
|
session_cookie=env("SESSION_COOKIE_NAME", SESSION_COOKIE_DEFAULT),
|
|
https_only=env("SESSION_COOKIE_SECURE", "true").lower() == "true",
|
|
same_site="lax",
|
|
max_age=int(env("SESSION_MAX_AGE", "28800")),
|
|
path="/",
|
|
)
|
|
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
|
|
|
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
|
oauth = build_google_oauth()
|
|
user_store = UserStore(DATA_DIR / "users.json")
|
|
|
|
|
|
def public_url_for(request: Request, route_name: str) -> str:
|
|
public_base_url = env("PUBLIC_BASE_URL").rstrip("/")
|
|
if public_base_url:
|
|
return f"{public_base_url}{request.url_for(route_name).path}"
|
|
return str(request.url_for(route_name))
|
|
|
|
|
|
def get_session_user(request: Request) -> dict[str, Any] | None:
|
|
"""세션에서 로그인 사용자(이메일/이름/사진) 추출. OMS SSO 호환."""
|
|
user = request.session.get("user")
|
|
if isinstance(user, dict):
|
|
return user
|
|
email = request.session.get("user_email")
|
|
if email:
|
|
return {
|
|
"email": str(email),
|
|
"name": request.session.get("user_name") or str(email),
|
|
"picture": request.session.get("user_picture", "") or "",
|
|
}
|
|
return None
|
|
|
|
|
|
def get_current_user_record(request: Request) -> dict[str, Any] | None:
|
|
"""세션 + 저장소를 합쳐 권한이 포함된 사용자 레코드 반환."""
|
|
sess = get_session_user(request)
|
|
if not sess:
|
|
return None
|
|
rec = user_store.get(sess["email"])
|
|
if rec is None:
|
|
# 세션은 살아있지만 저장소에 없음 — 세션 무효화
|
|
return None
|
|
return rec
|
|
|
|
|
|
def require_admin(request: Request) -> dict[str, Any]:
|
|
rec = get_current_user_record(request)
|
|
if rec is None:
|
|
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
|
if not is_admin(rec):
|
|
raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
|
|
return rec
|
|
|
|
|
|
def safe_next(raw: str | None) -> str:
|
|
"""Open redirect 방지: 같은 호스트의 절대 경로만 허용."""
|
|
if not raw:
|
|
return "/"
|
|
if not raw.startswith("/") or raw.startswith("//") or raw.startswith("/\\"):
|
|
return "/"
|
|
return raw
|
|
|
|
|
|
def render_template(
|
|
request: Request,
|
|
name: str,
|
|
context: dict[str, Any] | None = None,
|
|
status_code: int = 200,
|
|
) -> HTMLResponse:
|
|
template_context = {"request": request, **(context or {})}
|
|
first_param = next(iter(inspect.signature(templates.TemplateResponse).parameters))
|
|
if first_param == "request":
|
|
return templates.TemplateResponse(
|
|
request,
|
|
name,
|
|
template_context,
|
|
status_code=status_code,
|
|
)
|
|
return templates.TemplateResponse(
|
|
name,
|
|
template_context,
|
|
status_code=status_code,
|
|
)
|
|
|
|
|
|
def is_allowed_google_user(userinfo: dict[str, Any]) -> tuple[bool, str]:
|
|
email = str(userinfo.get("email", "")).lower().strip()
|
|
email_verified = bool(userinfo.get("email_verified"))
|
|
domain = email.rsplit("@", 1)[-1] if "@" in email else ""
|
|
|
|
if not email_verified:
|
|
return False, "Google 계정 이메일 인증이 확인되지 않았습니다."
|
|
if domain != ALLOWED_DOMAIN:
|
|
return False, "회사 Google Workspace 계정만 접속할 수 있습니다."
|
|
return True, ""
|
|
|
|
|
|
# ── ERP 메뉴 정의 ──────────────────────────────────────────────
|
|
# 각 항목: key(권한키), title, description, url(env override), status(ready|preparing), category
|
|
def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
|
|
items = [
|
|
{
|
|
"key": "corm",
|
|
"title": "CORM",
|
|
"subtitle": "CS · 발주 · 반품 · 코드관리",
|
|
"description": "고객 응대와 발주/반품, 코드 관리 업무를 한 곳에서 처리합니다.",
|
|
"url": env("CS_ORDER_URL", "/corm/"),
|
|
"health_url": "/corm/health/db",
|
|
"status": "ready",
|
|
"category": "운영",
|
|
},
|
|
{
|
|
"key": "order",
|
|
"title": "Order",
|
|
"subtitle": "고객 주문 데이터베이스",
|
|
"description": "고객 주문 내역을 조회·검색하고 관련 데이터를 관리합니다.",
|
|
"url": env("CUSTOMER_ORDER_LIST_URL", "/orderlist/"),
|
|
"health_url": "/orderlist/health/db",
|
|
"status": "ready",
|
|
"category": "운영",
|
|
},
|
|
{
|
|
"key": "expense",
|
|
"title": "개인경비",
|
|
"subtitle": "Personal Expense",
|
|
"description": "법인카드/개인경비 사용 내역을 등록·증빙하고 정산을 신청합니다.",
|
|
"url": "#",
|
|
"health_url": None,
|
|
"status": "preparing",
|
|
"category": "관리",
|
|
},
|
|
{
|
|
"key": "vacation",
|
|
"title": "휴가",
|
|
"subtitle": "Vacation",
|
|
"description": "연차/반차/특별휴가 신청과 잔여일수, 결재 현황을 확인합니다.",
|
|
"url": "#",
|
|
"health_url": None,
|
|
"status": "preparing",
|
|
"category": "관리",
|
|
},
|
|
]
|
|
allowed = allowed_modules(user_rec)
|
|
for item in items:
|
|
item["allowed"] = item["key"] in allowed
|
|
return items
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def home(request: Request) -> HTMLResponse:
|
|
sess = get_session_user(request)
|
|
if not sess:
|
|
return render_template(request, "login.html")
|
|
user_rec = user_store.get(sess["email"])
|
|
if user_rec is None:
|
|
# 도메인은 통과했으나 저장소에 없음 — 세션 정리 후 재로그인
|
|
request.session.clear()
|
|
return render_template(request, "login.html")
|
|
menu_items = _menu_items_for(user_rec)
|
|
return render_template(
|
|
request,
|
|
"main.html",
|
|
{
|
|
"user": user_rec,
|
|
"menu_items": menu_items,
|
|
"is_admin": is_admin(user_rec),
|
|
},
|
|
)
|
|
|
|
|
|
@app.get("/login")
|
|
async def login(request: Request):
|
|
if not env("GOOGLE_CLIENT_ID") or not env("GOOGLE_CLIENT_SECRET"):
|
|
return render_template(
|
|
request,
|
|
"denied.html",
|
|
{"reason": "Google OAuth 환경 변수가 아직 설정되지 않았습니다."},
|
|
status_code=500,
|
|
)
|
|
|
|
request.session["_post_login_next"] = safe_next(request.query_params.get("next"))
|
|
|
|
redirect_uri = public_url_for(request, "auth_google")
|
|
return await oauth.google.authorize_redirect(
|
|
request,
|
|
redirect_uri,
|
|
hd=ALLOWED_DOMAIN,
|
|
prompt="select_account",
|
|
)
|
|
|
|
|
|
@app.get("/auth/google")
|
|
async def auth_google(request: Request):
|
|
try:
|
|
token = await oauth.google.authorize_access_token(request)
|
|
userinfo = token.get("userinfo")
|
|
if userinfo is None:
|
|
userinfo = await oauth.google.userinfo(token=token)
|
|
except OAuthError as exc:
|
|
return render_template(
|
|
request,
|
|
"denied.html",
|
|
{"reason": f"Google 로그인 실패: {exc.error}"},
|
|
status_code=401,
|
|
)
|
|
|
|
allowed, reason = is_allowed_google_user(dict(userinfo))
|
|
if not allowed:
|
|
request.session.clear()
|
|
return render_template(
|
|
request,
|
|
"denied.html",
|
|
{"reason": reason},
|
|
status_code=403,
|
|
)
|
|
|
|
email = str(userinfo.get("email", "")).lower().strip()
|
|
name = userinfo.get("name") or email
|
|
picture = userinfo.get("picture", "") or ""
|
|
|
|
# 저장소에 사용자 등록/갱신 (신규는 권한 0 — 관리자가 부여)
|
|
user_store.upsert_login(email=email, name=name, picture=picture)
|
|
|
|
# OMS 와 공유하는 top-level 키 (SSO 계약)
|
|
request.session["user_email"] = email
|
|
request.session["user_name"] = name
|
|
request.session["user_picture"] = picture
|
|
request.session["user"] = {
|
|
"email": email,
|
|
"name": name,
|
|
"picture": picture,
|
|
}
|
|
next_url = safe_next(request.session.pop("_post_login_next", "/"))
|
|
return RedirectResponse(url=next_url, status_code=303)
|
|
|
|
|
|
@app.get("/logout")
|
|
async def logout(request: Request) -> RedirectResponse:
|
|
request.session.clear()
|
|
return RedirectResponse(url="/", status_code=303)
|
|
|
|
|
|
@app.get("/healthz")
|
|
async def healthz() -> dict[str, str]:
|
|
return {"status": "ok"}
|
|
|
|
|
|
# ── 관리자 페이지 ──────────────────────────────────────────────
|
|
@app.get("/admin", response_class=HTMLResponse)
|
|
async def admin_page(request: Request) -> HTMLResponse:
|
|
rec = get_current_user_record(request)
|
|
if rec is None:
|
|
return RedirectResponse(url="/login", status_code=303)
|
|
if not is_admin(rec):
|
|
return render_template(
|
|
request,
|
|
"denied.html",
|
|
{"reason": "관리자만 접근할 수 있는 페이지입니다."},
|
|
status_code=403,
|
|
)
|
|
users = sorted(user_store.list_all(), key=lambda u: (u["email"] != SUPER_ADMIN_EMAIL, u["email"]))
|
|
return render_template(
|
|
request,
|
|
"admin.html",
|
|
{
|
|
"user": rec,
|
|
"users": users,
|
|
"module_keys": list(MODULE_KEYS),
|
|
"super_admin_email": SUPER_ADMIN_EMAIL,
|
|
"is_admin": True,
|
|
},
|
|
)
|
|
|
|
|
|
# ── 관리자 API ──────────────────────────────────────────────
|
|
class UpdatePermissionsBody(BaseModel):
|
|
role: str | None = None
|
|
modules: dict[str, bool] | None = None
|
|
|
|
|
|
@app.get("/api/users")
|
|
async def api_list_users(_: dict[str, Any] = Depends(require_admin)) -> JSONResponse:
|
|
return JSONResponse({"users": user_store.list_all()})
|
|
|
|
|
|
@app.put("/api/users/{email}")
|
|
async def api_update_user(
|
|
email: str,
|
|
body: UpdatePermissionsBody,
|
|
_: dict[str, Any] = Depends(require_admin),
|
|
) -> JSONResponse:
|
|
try:
|
|
rec = user_store.update_permissions(
|
|
email=email, role=body.role, modules=body.modules
|
|
)
|
|
except PermissionError as exc:
|
|
raise HTTPException(status_code=403, detail=str(exc))
|
|
except KeyError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc))
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
return JSONResponse({"user": rec})
|
|
|
|
|
|
@app.delete("/api/users/{email}")
|
|
async def api_delete_user(
|
|
email: str,
|
|
_: dict[str, Any] = Depends(require_admin),
|
|
) -> JSONResponse:
|
|
try:
|
|
user_store.delete(email)
|
|
except PermissionError as exc:
|
|
raise HTTPException(status_code=403, detail=str(exc))
|
|
return JSONResponse({"ok": True})
|