o2o-site-AEO/solution/backend/router/v1/validator/dependencies.py
Mina Choi 14b6e95f9d [feat] solution/backend,frontend: 관리자가 모든 사업장을 열고 관리 — 사이트관리 검색·업종 필터·삭제, 사장님 더보기 정리
사업장 API 가 전부 "요청자 = 소유자" 로 걸러져서 관리자가 남의 빌더·미니블로그를 열면
PLACE_NOT_FOUND 였다. 운영 중 사업장 정리도 사장님 계정으로 들어가야 했다.

- validator/dependencies: DEVELOPER 가 place_id 경로를 부르면 그 사업장 소유자 신원으로 처리 — [admin-access] 로그
- ops/sites: search(상호·소유자 이메일·이름·아이디) · category 필터, 건수도 같은 조건
- OpsSitesPage: 검색창 · 업종 칩 · 행마다 빌더·미니블로그·삭제(공개 중이면 발행 내린 뒤 삭제)
- SitesPage: 더보기에서 미니블로그 관리 · 예약요청 관리 제거

테스트 4건 추가(test_admin_access). 전체 900 passed · 145 failed(변경 전과 같은 파일: agent_runtime·gemini 등)
frontend tsc·eslint 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:34:20 +09:00

171 lines
5.9 KiB
Python

import asyncio
import json
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Union
from fastapi import Depends, Request
from sqlalchemy import select
from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
import bcrypt
from jose import jwt, JWTError, ExpiredSignatureError
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import places
from common.enums import (
DBWRType,
ErrorType,
EXCEPTION_ACCESS_TOKEN_EXPIRED,
EXCEPTION_FORBIDDEN,
EXCEPTION_INVALID_CLIENT_ACCESS,
EXCEPTION_REFRESH_TOKEN_EXPIRED,
UserRole,
)
from common.logger import LOG
from common.models.gmodel import UserInfo
from common.utils.gtime import GTime
from config.server_configs import jwt_token_config
security = HTTPBearer()
# 비밀번호 해시 (bcrypt)
def _hash_pw(pw: str) -> str:
return bcrypt.hashpw(pw.encode("utf-8")[:72], bcrypt.gensalt()).decode("utf-8")
def _verify_pw(pw: str, hashed_pw: str) -> bool:
try:
return bcrypt.checkpw(pw.encode("utf-8")[:72], hashed_pw.encode("utf-8"))
except (ValueError, TypeError):
return False
async def GetHashedPW(pw: str) -> str:
return await asyncio.to_thread(_hash_pw, pw)
async def VerifyPW(pw: str, hashed_pw: str) -> bool:
return await asyncio.to_thread(_verify_pw, pw, hashed_pw)
# JWT 토큰 발급/검증
JWT_ALGORITHM = "HS256"
JWT_ACCESS_SECRET = jwt_token_config.access_key
JWT_REFRESH_SECRET = jwt_token_config.refresh_key
ACCESS_TOKEN_EXPIRE_MIN = jwt_token_config.access_expire_min
REFRESH_TOKEN_EXPIRE_MIN = 60 * 24 * jwt_token_config.refresh_expire_day
def __create_token(subject: Union[str, Any], secret_key: str, expire_min: int) -> str:
to_encode = {
"sub": str(subject),
"exp": GTime.AddMinutes(expire_min), # jose 가 exp 클레임을 자동 검증
}
return jwt.encode(to_encode, secret_key, JWT_ALGORITHM)
def CreateAccessToken(subject: UserInfo) -> str:
return __create_token(subject.to_json(), JWT_ACCESS_SECRET, ACCESS_TOKEN_EXPIRE_MIN)
def CreateRefreshToken(subject: UserInfo) -> str:
return __create_token(subject.to_json(), JWT_REFRESH_SECRET, REFRESH_TOKEN_EXPIRE_MIN)
def CreateDayPassToken(subject: UserInfo) -> str:
"""그날 자정(KST)까지만 사는 접근 토큰 — 미니 블로그 메일의 "수정하기" 링크 전용."""
now_kst = datetime.now(timezone(timedelta(hours=9)))
midnight_kst = (now_kst + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
expire_min = max(1, int((midnight_kst - now_kst).total_seconds() // 60))
return __create_token(subject.to_json(), JWT_ACCESS_SECRET, expire_min)
def __decode_token(jwt_token: str, secret_key: str, expired_exception) -> UserInfo:
try:
decoded = jwt.decode(jwt_token, secret_key, algorithms=[JWT_ALGORITHM])
return UserInfo(**json.loads(decoded.get("sub")))
except ExpiredSignatureError:
raise expired_exception
except JWTError as ex:
LOG.e_no_callstack(ex)
raise EXCEPTION_INVALID_CLIENT_ACCESS
def DecodeAccessToken(jwt_token: str) -> UserInfo:
return __decode_token(jwt_token, JWT_ACCESS_SECRET, EXCEPTION_ACCESS_TOKEN_EXPIRED)
def DecodeRefreshToken(jwt_token: str) -> UserInfo:
return __decode_token(jwt_token, JWT_REFRESH_SECRET, EXCEPTION_REFRESH_TOKEN_EXPIRED)
# Depends 용 토큰 검증기
async def _place_owner(place_id: str) -> str | None:
try:
pid = uuid.UUID(place_id)
except ValueError:
return None
err, rows = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(
s, select(places.owner_user_id).where(places.place_id == pid, places.deleted == False) # noqa: E712
),
)
if err != ErrorType.SUCCESS or not rows:
return None
return str(rows[0])
async def _act_as_place_owner(request: Request, user_info: UserInfo) -> UserInfo:
if (user_info.role or 0) < UserRole.DEVELOPER.value:
return user_info
place_id = request.path_params.get("place_id") or request.query_params.get("place_id")
if not place_id:
return user_info
owner = await _place_owner(str(place_id))
if owner is None or owner == str(user_info.user_id):
return user_info
LOG.i(f"[admin-access] {user_info.id} → place={place_id} owner={owner} {request.method} {request.url.path}")
return UserInfo(user_id=owner, id=user_info.id, role=user_info.role, token_version=user_info.token_version)
async def IsValidAccessToken(
request: Request, credentials: HTTPAuthorizationCredentials = Depends(security)
) -> UserInfo:
return await _act_as_place_owner(request, DecodeAccessToken(credentials.credentials))
async def IsValidRefreshToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo:
return DecodeRefreshToken(credentials.credentials)
# 최고관리자 이상(OWNER/DEVELOPER) 게이트.
async def RequireOwner(user_info: UserInfo = Depends(IsValidAccessToken)) -> UserInfo:
if (user_info.role or 0) < UserRole.OWNER.value:
raise EXCEPTION_FORBIDDEN
return user_info
# 개발자(내부 운영) 전용 게이트.
async def RequireDeveloper(user_info: UserInfo = Depends(IsValidAccessToken)) -> UserInfo:
if (user_info.role or 0) < UserRole.DEVELOPER.value:
raise EXCEPTION_FORBIDDEN
return user_info
# ResponseNone 처리
def RemoveNoneValues(obj: Any) -> Any:
if isinstance(obj, dict):
return {k: RemoveNoneValues(v) for k, v in obj.items() if v is not None}
if isinstance(obj, list):
return [RemoveNoneValues(v) for v in obj]
return obj
def RemoveNoneResponse(obj) -> JSONResponse:
# mode="json": uuid/datetime 등 DB 네이티브 타입(asyncpg.UUID 포함)을 pydantic 단에서 JSON 안전한 문자열로 변환한다.
return JSONResponse(content=RemoveNoneValues(obj.model_dump(mode="json")))