import asyncio import json from typing import Any, Union from fastapi import Depends from fastapi.responses import ORJSONResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer import bcrypt from jose import jwt, JWTError, ExpiredSignatureError from common.enums import ( EXCEPTION_ACCESS_TOKEN_EXPIRED, EXCEPTION_INVALID_CLIENT_ACCESS, EXCEPTION_REFRESH_TOKEN_EXPIRED, ) 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) ------------------------------------------------ # bcrypt 는 CPU 바운드 동기 작업이라 그대로 호출하면 asyncio 이벤트 루프를 막아 # 같은 워커의 다른 요청(healthz 등)까지 멈춘다. 스레드풀(asyncio.to_thread)로 보낸다. # bcrypt 는 해싱 중 GIL 을 해제하므로 스레드들이 여러 코어에서 실제 병렬로 돈다. # 입력은 최대 72 bytes 까지만 사용하므로 사전에 잘라준다. 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 __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 용 토큰 검증기 (디코드만; DB 존재/활성 검증은 service 가 담당) ----- async def IsValidAccessToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo: return DecodeAccessToken(credentials.credentials) async def IsValidRefreshToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo: return DecodeRefreshToken(credentials.credentials) # ---- ResponseNone 처리 ----------------------------------------------------- # 응답 객체에서 값이 None 인 필드를 재귀적으로 제거하여 페이로드를 줄인다. # 모든 라우터는 return RemoveNoneResponse(await service....) 형태로 반환한다. 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) -> ORJSONResponse: return ORJSONResponse(content=RemoveNoneValues(obj.model_dump()))