126 lines
4.3 KiB
Python
126 lines
4.3 KiB
Python
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()
|