import logging from dataclasses import dataclass import httpx try: from app.settings import settings except ModuleNotFoundError: from settings import settings logger = logging.getLogger("app.wechat_login") INVALID_CODE_ERRCODES = {40029, 40163} MISCONFIGURED_APP_ERRCODES = {40013, 40125} @dataclass(frozen=True) class WechatCodeSession: openid: str session_key: str unionid: str | None = None class WechatAuthError(Exception): def __init__(self, detail: str, *, status_code: int, errcode: int | None = None): super().__init__(detail) self.status_code = status_code self.errcode = errcode class WechatAuthService: def __init__(self, client: httpx.Client | None = None): self._client = client def exchange_code(self, code: str) -> WechatCodeSession: if not settings.wechat_app_id or not settings.wechat_app_secret: raise WechatAuthError("wechat login is not configured", status_code=503) response = self._request_code2session(code) data = self._parse_response_json(response) errcode = self._parse_errcode(data.get("errcode")) if errcode not in (None, 0): errmsg = data.get("errmsg") logger.warning( "wechat code2session rejected login code", extra={ "event": "wechat_code2session_rejected", "errcode": errcode, "errmsg": errmsg, }, ) raise self._map_exchange_error(errcode) openid = data.get("openid") session_key = data.get("session_key") unionid = data.get("unionid") if not isinstance(openid, str) or not openid: raise WechatAuthError("wechat login response missing openid", status_code=502) if not isinstance(session_key, str) or not session_key: raise WechatAuthError("wechat login response missing session_key", status_code=502) if not isinstance(unionid, str) or not unionid: unionid = None return WechatCodeSession(openid=openid, session_key=session_key, unionid=unionid) def _request_code2session(self, code: str) -> httpx.Response: params = { "appid": settings.wechat_app_id, "secret": settings.wechat_app_secret, "js_code": code, "grant_type": "authorization_code", } client = self._client owns_client = client is None if client is None: client = httpx.Client( base_url=settings.wechat_api_base_url.rstrip("/"), timeout=settings.wechat_http_timeout_seconds, ) try: response = client.get("/sns/jscode2session", params=params) response.raise_for_status() return response except httpx.HTTPError as exc: logger.warning( "wechat code2session request failed", extra={"event": "wechat_code2session_request_failed"}, ) raise WechatAuthError("wechat login service unavailable", status_code=502) from exc finally: if owns_client: client.close() def _parse_response_json(self, response: httpx.Response) -> dict: try: data = response.json() except ValueError as exc: raise WechatAuthError("invalid response from wechat login service", status_code=502) from exc if not isinstance(data, dict): raise WechatAuthError("invalid response from wechat login service", status_code=502) return data def _parse_errcode(self, value: object) -> int | None: if value is None: return None try: return int(value) except (TypeError, ValueError): return None def _map_exchange_error(self, errcode: int) -> WechatAuthError: if errcode in INVALID_CODE_ERRCODES: return WechatAuthError("invalid or expired wechat login code", status_code=401, errcode=errcode) if errcode in MISCONFIGURED_APP_ERRCODES: return WechatAuthError("wechat login is not configured correctly", status_code=503, errcode=errcode) return WechatAuthError("wechat login service unavailable", status_code=502, errcode=errcode) def get_wechat_auth_service() -> WechatAuthService: return WechatAuthService()