227 lines
8.7 KiB
Python
227 lines
8.7 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import os
|
||
|
|
from typing import Any
|
||
|
|
from urllib.parse import urlparse
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
|
||
|
|
|
||
|
|
class JmsAuthClientError(Exception):
|
||
|
|
def __init__(self, code: str, message: str, status_code: int) -> None:
|
||
|
|
super().__init__(message)
|
||
|
|
self.code = code
|
||
|
|
self.message = message
|
||
|
|
self.status_code = status_code
|
||
|
|
|
||
|
|
|
||
|
|
class JmsAuthClient:
|
||
|
|
"""Small typed boundary around the JMS authentication endpoints."""
|
||
|
|
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
*,
|
||
|
|
base_url: str | None = None,
|
||
|
|
timeout_seconds: float | None = None,
|
||
|
|
transport: httpx.AsyncBaseTransport | None = None,
|
||
|
|
) -> None:
|
||
|
|
self.base_url = (
|
||
|
|
base_url
|
||
|
|
or os.environ.get("JMS_AUTH_BASE_URL")
|
||
|
|
or "https://jms-mes-api.jzplat.com"
|
||
|
|
).rstrip("/")
|
||
|
|
try:
|
||
|
|
self.timeout_seconds = timeout_seconds or float(
|
||
|
|
os.environ.get("JMS_AUTH_TIMEOUT_SECONDS") or "10"
|
||
|
|
)
|
||
|
|
except ValueError as exc:
|
||
|
|
raise JmsAuthClientError(
|
||
|
|
"AUTH_CONFIG_INVALID", "JMS 认证超时时间配置无效", 503,
|
||
|
|
) from exc
|
||
|
|
if self.timeout_seconds <= 0:
|
||
|
|
raise JmsAuthClientError(
|
||
|
|
"AUTH_CONFIG_INVALID", "JMS 认证超时时间必须大于 0", 503,
|
||
|
|
)
|
||
|
|
self.transport = transport
|
||
|
|
parsed = urlparse(self.base_url)
|
||
|
|
if parsed.scheme != "https" or not parsed.netloc:
|
||
|
|
raise JmsAuthClientError(
|
||
|
|
"AUTH_CONFIG_INVALID",
|
||
|
|
"JMS 认证服务地址必须是有效的 HTTPS 地址",
|
||
|
|
503,
|
||
|
|
)
|
||
|
|
|
||
|
|
async def _request(
|
||
|
|
self,
|
||
|
|
method: str,
|
||
|
|
path: str,
|
||
|
|
*,
|
||
|
|
params: dict[str, str] | None = None,
|
||
|
|
json_body: dict[str, Any] | None = None,
|
||
|
|
form_body: dict[str, str] | None = None,
|
||
|
|
token_name: str | None = None,
|
||
|
|
token_value: str | None = None,
|
||
|
|
) -> Any:
|
||
|
|
headers: dict[str, str] = {"Accept": "application/json"}
|
||
|
|
if token_name and token_value:
|
||
|
|
headers[token_name] = token_value
|
||
|
|
try:
|
||
|
|
async with httpx.AsyncClient(
|
||
|
|
base_url=self.base_url,
|
||
|
|
timeout=self.timeout_seconds,
|
||
|
|
follow_redirects=False,
|
||
|
|
transport=self.transport,
|
||
|
|
) as client:
|
||
|
|
response = await client.request(
|
||
|
|
method,
|
||
|
|
path,
|
||
|
|
params=params,
|
||
|
|
json=json_body,
|
||
|
|
data=form_body,
|
||
|
|
headers=headers,
|
||
|
|
)
|
||
|
|
except httpx.TimeoutException as exc:
|
||
|
|
raise JmsAuthClientError(
|
||
|
|
"AUTH_UPSTREAM_TIMEOUT", "JMS 认证服务响应超时", 504,
|
||
|
|
) from exc
|
||
|
|
except httpx.RequestError as exc:
|
||
|
|
raise JmsAuthClientError(
|
||
|
|
"AUTH_UPSTREAM_UNAVAILABLE", "无法连接 JMS 认证服务", 502,
|
||
|
|
) from exc
|
||
|
|
|
||
|
|
try:
|
||
|
|
envelope = response.json()
|
||
|
|
except ValueError as exc:
|
||
|
|
raise JmsAuthClientError(
|
||
|
|
"AUTH_UPSTREAM_INVALID", "JMS 认证服务返回了无效响应", 502,
|
||
|
|
) from exc
|
||
|
|
if not isinstance(envelope, dict):
|
||
|
|
raise JmsAuthClientError(
|
||
|
|
"AUTH_UPSTREAM_INVALID", "JMS 认证服务返回了无效响应", 502,
|
||
|
|
)
|
||
|
|
|
||
|
|
remote_code = envelope.get("code", 0)
|
||
|
|
success = response.is_success and remote_code in (0, "0", None)
|
||
|
|
if not success:
|
||
|
|
message = str(envelope.get("message") or envelope.get("msg") or "JMS 认证请求失败")
|
||
|
|
numeric_code = int(remote_code) if str(remote_code).isdigit() else response.status_code
|
||
|
|
if path == "/admin/index/login":
|
||
|
|
diagnostic = f"{message} {envelope.get('error') or ''}".lower()
|
||
|
|
if "验证码" in diagnostic or "captcha" in diagnostic:
|
||
|
|
raise JmsAuthClientError(
|
||
|
|
"AUTH_CAPTCHA_INVALID", "验证码错误,请重新输入", 400,
|
||
|
|
)
|
||
|
|
if any(word in diagnostic for word in ("停用", "禁用", "锁定", "disabled", "locked")):
|
||
|
|
raise JmsAuthClientError(
|
||
|
|
"AUTH_USER_DISABLED", "账号已停用或锁定,请联系管理员", 403,
|
||
|
|
)
|
||
|
|
if any(word in diagnostic for word in ("租户", "企业", "tenant")):
|
||
|
|
raise JmsAuthClientError(
|
||
|
|
"AUTH_TENANT_INVALID", "企业名称或账号不匹配", 400,
|
||
|
|
)
|
||
|
|
credential_rejected = (
|
||
|
|
response.status_code in {400, 401, 403}
|
||
|
|
or numeric_code in {400, 401, 403}
|
||
|
|
or any(word in diagnostic for word in (
|
||
|
|
"validation failure", "bad_request", "bad request",
|
||
|
|
"用户名", "用户不存在", "账号", "密码", "credential",
|
||
|
|
))
|
||
|
|
)
|
||
|
|
if credential_rejected:
|
||
|
|
raise JmsAuthClientError(
|
||
|
|
"AUTH_INVALID_CREDENTIALS", "账号或密码错误", 401,
|
||
|
|
)
|
||
|
|
raise JmsAuthClientError(
|
||
|
|
"AUTH_UPSTREAM_ERROR", "认证服务暂时不可用,请稍后重试", 502,
|
||
|
|
)
|
||
|
|
if response.status_code >= 500 or numeric_code >= 500:
|
||
|
|
raise JmsAuthClientError("AUTH_UPSTREAM_ERROR", message, 502)
|
||
|
|
status_code = response.status_code if response.status_code in {400, 401, 403} else 400
|
||
|
|
raise JmsAuthClientError("AUTH_UPSTREAM_REJECTED", message, status_code)
|
||
|
|
return envelope.get("data")
|
||
|
|
|
||
|
|
async def precheck(self, *, tenant_code: str, username: str = "") -> dict[str, Any]:
|
||
|
|
data = await self._request(
|
||
|
|
"GET",
|
||
|
|
"/admin/index/login/precheck",
|
||
|
|
params={"tenantCode": tenant_code, "username": username},
|
||
|
|
)
|
||
|
|
if not isinstance(data, dict):
|
||
|
|
raise JmsAuthClientError(
|
||
|
|
"AUTH_UPSTREAM_INVALID", "JMS 登录预检响应格式无效", 502,
|
||
|
|
)
|
||
|
|
return data
|
||
|
|
|
||
|
|
async def tenants(self) -> list[dict[str, Any]]:
|
||
|
|
data = await self._request("GET", "/admin/index/tenant")
|
||
|
|
if not isinstance(data, list):
|
||
|
|
raise JmsAuthClientError(
|
||
|
|
"AUTH_UPSTREAM_INVALID", "JMS 租户列表响应格式无效", 502,
|
||
|
|
)
|
||
|
|
return [row for row in data if isinstance(row, dict)]
|
||
|
|
|
||
|
|
async def captcha(self) -> dict[str, str]:
|
||
|
|
data = await self._request("GET", "/admin/index/captcha")
|
||
|
|
if not isinstance(data, dict):
|
||
|
|
raise JmsAuthClientError(
|
||
|
|
"AUTH_UPSTREAM_INVALID", "JMS 验证码响应格式无效", 502,
|
||
|
|
)
|
||
|
|
return {str(key): str(value) for key, value in data.items() if value is not None}
|
||
|
|
|
||
|
|
async def login(
|
||
|
|
self,
|
||
|
|
*,
|
||
|
|
tenant_code: str,
|
||
|
|
tenant_name: str,
|
||
|
|
username: str,
|
||
|
|
password: str,
|
||
|
|
captcha_id: str = "",
|
||
|
|
captcha_code: str = "",
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
form_body = {
|
||
|
|
"tenantCode": tenant_code,
|
||
|
|
"tenantName": tenant_name,
|
||
|
|
"username": username,
|
||
|
|
"password": password,
|
||
|
|
"device": "PC",
|
||
|
|
}
|
||
|
|
if captcha_id:
|
||
|
|
form_body["captchaId"] = captcha_id
|
||
|
|
if captcha_code:
|
||
|
|
form_body["captchaCode"] = captcha_code
|
||
|
|
data = await self._request(
|
||
|
|
"POST",
|
||
|
|
"/admin/index/login",
|
||
|
|
form_body=form_body,
|
||
|
|
)
|
||
|
|
if not isinstance(data, dict):
|
||
|
|
raise JmsAuthClientError(
|
||
|
|
"AUTH_UPSTREAM_INVALID", "JMS 登录响应格式无效", 502,
|
||
|
|
)
|
||
|
|
return data
|
||
|
|
|
||
|
|
async def profile(self, *, token_name: str, token_value: str) -> dict[str, Any]:
|
||
|
|
data = await self._request(
|
||
|
|
"GET", "/admin/auth/profile", token_name=token_name, token_value=token_value,
|
||
|
|
)
|
||
|
|
if not isinstance(data, dict):
|
||
|
|
raise JmsAuthClientError(
|
||
|
|
"AUTH_UPSTREAM_INVALID", "JMS 用户信息响应格式无效", 502,
|
||
|
|
)
|
||
|
|
return data
|
||
|
|
|
||
|
|
async def tenant(self, *, token_name: str, token_value: str) -> dict[str, Any]:
|
||
|
|
data = await self._request(
|
||
|
|
"GET", "/admin/auth/tenant", token_name=token_name, token_value=token_value,
|
||
|
|
)
|
||
|
|
if not isinstance(data, dict):
|
||
|
|
raise JmsAuthClientError(
|
||
|
|
"AUTH_UPSTREAM_INVALID", "JMS 租户信息响应格式无效", 502,
|
||
|
|
)
|
||
|
|
return data
|
||
|
|
|
||
|
|
async def logout(self, *, token_name: str, token_value: str) -> None:
|
||
|
|
await self._request(
|
||
|
|
"POST", "/admin/auth/logout", token_name=token_name, token_value=token_value,
|
||
|
|
)
|