200 lines
6.5 KiB
Python
200 lines
6.5 KiB
Python
import json
|
|
from typing import Any
|
|
from urllib.parse import urlsplit
|
|
from uuid import uuid4
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
|
|
|
from app.config import settings
|
|
from app.schemas import (
|
|
ErpLoginActionResponse,
|
|
ErpLoginConfirmRequest,
|
|
ErpLoginPreviewResponse,
|
|
ErpLoginQrcodeRequest,
|
|
ErpLoginQrcodeResponse,
|
|
)
|
|
from app.services.erp_login_security import (
|
|
SERVICE_NONCE_HEADER,
|
|
SERVICE_SIGNATURE_HEADER,
|
|
SERVICE_TIMESTAMP_HEADER,
|
|
signed_headers,
|
|
verify_service_signature,
|
|
)
|
|
from app.services.wechat import WechatConfigError, create_miniapp_qrcode, get_phone_number
|
|
from app.timezone import now
|
|
|
|
|
|
router = APIRouter(prefix="/api/erp-login", tags=["erp-login"])
|
|
|
|
ERP_LOGIN_PAGE = "pages/erpLoginConfirm/erpLoginConfirm"
|
|
WECHAT_SCENE_MAX_LENGTH = 32
|
|
|
|
|
|
def _json_body(payload: dict[str, Any]) -> bytes:
|
|
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
|
|
|
|
def _erp_url(path: str) -> str:
|
|
return f"{settings.erp_api_base_url.rstrip('/')}{path}"
|
|
|
|
|
|
def _require_session_id(session_id: int | None) -> int:
|
|
if session_id is None:
|
|
raise HTTPException(status_code=400, detail="缺少ERP扫码登录会话ID")
|
|
return session_id
|
|
|
|
|
|
def _scene(ticket: str, session_id: int) -> str:
|
|
scene = f"t={ticket}&s={session_id}"
|
|
if len(scene) > WECHAT_SCENE_MAX_LENGTH:
|
|
raise HTTPException(status_code=422, detail="ERP扫码登录二维码参数过长")
|
|
return scene
|
|
|
|
|
|
def _action_response(data: dict[str, Any]) -> ErpLoginActionResponse:
|
|
return ErpLoginActionResponse(
|
|
status=str(data.get("status") or ""),
|
|
failure_reason=data.get("failure_reason"),
|
|
)
|
|
|
|
|
|
async def _read_erp_json(response: httpx.Response) -> dict[str, Any]:
|
|
try:
|
|
response.raise_for_status()
|
|
except httpx.HTTPStatusError as exc:
|
|
raise HTTPException(status_code=502, detail="ERP扫码登录服务暂时不可用") from exc
|
|
try:
|
|
data = response.json()
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=502, detail="ERP扫码登录响应无效") from exc
|
|
if not isinstance(data, dict):
|
|
raise HTTPException(status_code=502, detail="ERP扫码登录响应无效")
|
|
return data
|
|
|
|
|
|
async def _get_erp_json(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
url = _erp_url(path)
|
|
body = b""
|
|
headers = signed_headers(
|
|
"GET",
|
|
urlsplit(url).path,
|
|
body,
|
|
settings.erp_qr_login_shared_secret,
|
|
)
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
response = await client.get(url, params=params, headers=headers)
|
|
except httpx.RequestError as exc:
|
|
raise HTTPException(status_code=502, detail="ERP扫码登录服务暂时不可用") from exc
|
|
return await _read_erp_json(response)
|
|
|
|
|
|
async def _post_erp_json(path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
url = _erp_url(path)
|
|
body = _json_body(payload)
|
|
headers = signed_headers(
|
|
"POST",
|
|
urlsplit(url).path,
|
|
body,
|
|
settings.erp_qr_login_shared_secret,
|
|
)
|
|
headers["content-type"] = "application/json"
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
response = await client.post(url, content=body, headers=headers)
|
|
except httpx.RequestError as exc:
|
|
raise HTTPException(status_code=502, detail="ERP扫码登录服务暂时不可用") from exc
|
|
return await _read_erp_json(response)
|
|
|
|
|
|
@router.post("/qrcode", response_model=ErpLoginQrcodeResponse)
|
|
async def create_erp_login_qrcode(
|
|
payload: ErpLoginQrcodeRequest,
|
|
request: Request,
|
|
timestamp: str | None = Header(default=None, alias=SERVICE_TIMESTAMP_HEADER),
|
|
nonce: str | None = Header(default=None, alias=SERVICE_NONCE_HEADER),
|
|
signature: str | None = Header(default=None, alias=SERVICE_SIGNATURE_HEADER),
|
|
) -> ErpLoginQrcodeResponse:
|
|
body = await request.body()
|
|
verify_service_signature(
|
|
"POST",
|
|
request.url.path,
|
|
timestamp,
|
|
nonce,
|
|
body,
|
|
signature,
|
|
settings.erp_qr_login_shared_secret,
|
|
)
|
|
scene = _scene(payload.ticket, payload.session_id)
|
|
try:
|
|
qr_url = await create_miniapp_qrcode(
|
|
key=f"erp-login-{payload.session_id}",
|
|
page=ERP_LOGIN_PAGE,
|
|
scene=scene,
|
|
public_base_url=settings.public_base_url,
|
|
directory=settings.erp_login_qrcode_dir,
|
|
label="ERP扫码登录",
|
|
)
|
|
except WechatConfigError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
except RuntimeError as exc:
|
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
return ErpLoginQrcodeResponse(qr_url=qr_url, page=ERP_LOGIN_PAGE, scene=scene)
|
|
|
|
|
|
@router.get("/sessions/{ticket}", response_model=ErpLoginPreviewResponse)
|
|
async def preview_erp_login_session(
|
|
ticket: str,
|
|
session_id: int | None = Query(default=None),
|
|
) -> ErpLoginPreviewResponse:
|
|
session_id = _require_session_id(session_id)
|
|
data = await _get_erp_json(
|
|
f"/api/auth/qr-login/sessions/{session_id}/preview",
|
|
params={"ticket": ticket},
|
|
)
|
|
await _post_erp_json(
|
|
f"/api/auth/qr-login/sessions/{session_id}/scanned",
|
|
{"ticket": ticket},
|
|
)
|
|
return ErpLoginPreviewResponse(**data)
|
|
|
|
|
|
@router.post("/sessions/{ticket}/confirm", response_model=ErpLoginActionResponse)
|
|
async def confirm_erp_login_session(
|
|
ticket: str,
|
|
payload: ErpLoginConfirmRequest,
|
|
session_id: int | None = Query(default=None),
|
|
) -> ErpLoginActionResponse:
|
|
session_id = _require_session_id(session_id)
|
|
try:
|
|
phone = await get_phone_number(payload.phone_code)
|
|
except WechatConfigError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
except RuntimeError as exc:
|
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
|
|
data = await _post_erp_json(
|
|
f"/api/auth/qr-login/sessions/{session_id}/confirm",
|
|
{
|
|
"ticket": ticket,
|
|
"phone": phone,
|
|
"confirmed_at": now().isoformat(),
|
|
"nonce": uuid4().hex,
|
|
},
|
|
)
|
|
return _action_response(data)
|
|
|
|
|
|
@router.post("/sessions/{ticket}/cancel", response_model=ErpLoginActionResponse)
|
|
async def cancel_erp_login_session(
|
|
ticket: str,
|
|
session_id: int | None = Query(default=None),
|
|
) -> ErpLoginActionResponse:
|
|
session_id = _require_session_id(session_id)
|
|
data = await _post_erp_json(
|
|
f"/api/auth/qr-login/sessions/{session_id}/cancel",
|
|
{"ticket": ticket},
|
|
)
|
|
return _action_response(data)
|