Files
xiaoxia-saas/packages/application/auth/wechat_oauth_service.py
T
saas-backend-agent f11b71361d
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 26s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m42s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m47s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m6s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m16s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m2s
AI Code Review / AI Code Review (pull_request) Successful in 3m53s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m31s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 15m42s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 8s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m14s
feat(#1718): 微信 state 存储 Redis 化 + 回调 UA 日志 + 中文昵称 UTF-8 修复
- state store 改 Redis(复用 celery Redis,key 前缀 wechat:state:,
  TTL 10 分钟):SET NX EX 写入,Lua 脚本原子 GET+DEL 一次性消费
  (兼容 Redis <6.2 无 GETDEL);Redis 不可用时自动降级内存,登录不中断;
  容器重启/多实例后 state 不丢,修复 worker 扩容后回调 state 失效
- /wechat/callback 加可观测日志:User-Agent(识别 MicroMessenger
  微信内置浏览器)、state 校验结果、失败上下文,便于排查回调停滞
- 修复微信中文昵称乱码:sns/oauth2/access_token 与 sns/userinfo
  响应在 .json() 前显式 encoding=utf-8(微信响应头不带 charset,
  requests 默认 ISO-8859-1 解码导致中文乱码)
- 15 个新单测(全 mock/fake,CI 无 redis 也覆盖):Redis state
  存取/一次性消费/eval 降级/异常降级内存/ping 失败降级、中文昵称
  UTF-8 解析、errcode 透传、callback 路由日志分支、工厂降级分支
2026-09-05 20:45:02 +08:00

308 lines
11 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
微信 OAuth 服务
- 生成授权链接(网页扫码登录)
- 处理回调,用 code 换 access_token + 用户信息
"""
from __future__ import annotations
import logging
import os
import time
import urllib.parse
from dataclasses import dataclass
from threading import Lock
from typing import Optional
from uuid import uuid4
import requests
logger = logging.getLogger(__name__)
STATE_TTL_SECONDS = 600 # state 有效期 10 分钟
STATE_KEY_PREFIX = "wechat:state:" # Redis key 前缀(独立逻辑命名空间)
class MemoryStateStore:
"""内存 state 存储(简单实现,单节点可用)
多实例部署时建议替换为 Redis 实现。
"""
def __init__(self, ttl_seconds: int = STATE_TTL_SECONDS):
self._ttl = ttl_seconds
self._states: dict[str, float] = {} # state -> expire_at
self._lock = Lock()
def put(self, state: str) -> None:
with self._lock:
self._clean_expired()
self._states[state] = time.time() + self._ttl
def verify_and_consume(self, state: str) -> bool:
with self._lock:
self._clean_expired()
if state in self._states:
del self._states[state]
return True
return False
def _clean_expired(self) -> None:
now = time.time()
expired = [s for s, exp in self._states.items() if exp < now]
for s in expired:
del self._states[s]
class RedisStateStore:
"""Redis state 存储(多实例/容器重启安全)。
复用现有 Rediscelery broker 同实例),key 前缀 wechat:state:
TTL 10 分钟,SET NX EX + GETDEL 保证一次性消费。
Redis 不可用时降级为内存存储,保证登录流程不中断(单节点场景)。
"""
def __init__(
self,
redis_url: str = "",
ttl_seconds: int = STATE_TTL_SECONDS,
key_prefix: str = STATE_KEY_PREFIX,
client=None,
):
self._ttl = ttl_seconds
self._prefix = key_prefix
self._fallback = MemoryStateStore(ttl_seconds=ttl_seconds)
self._redis = None
if client is not None:
# 测试/显式注入
self._redis = client
return
try:
import redis
self._redis = redis.Redis.from_url(redis_url, decode_responses=True)
self._redis.ping()
logger.info(
"微信 state 存储使用 Redis: %s db=%s",
self._redis.connection_pool.connection_kwargs.get("host"),
self._redis.connection_pool.connection_kwargs.get("db"),
)
except Exception as e: # noqa: BLE001 — Redis 不可用降级内存,登录流程不中断
logger.warning("微信 state Redis 不可用,降级为内存存储: %s", e)
self._redis = None
def _key(self, state: str) -> str:
return f"{self._prefix}{state}"
def put(self, state: str) -> None:
if self._redis is None:
self._fallback.put(state)
return
try:
# SET key 1 NX EX ttl:不存在才写入,自带过期
self._redis.set(self._key(state), "1", nx=True, ex=self._ttl)
except Exception as e: # noqa: BLE001
logger.warning("微信 state 写入 Redis 失败,降级内存: %s", e)
self._fallback.put(state)
# Lua:原子读取并删除(单线程执行),兼容所有 Redis 版本(GETDEL 需 6.2+
_CONSUME_LUA = """
local v = redis.call('GET', KEYS[1])
if v then redis.call('DEL', KEYS[1]) end
return v
"""
def verify_and_consume(self, state: str) -> bool:
if self._redis is None:
return self._fallback.verify_and_consume(state)
try:
try:
val = self._redis.eval(self._CONSUME_LUA, 1, self._key(state))
except Exception: # noqa: BLE001 — eval 不可用时退化 GET+DELETE
val = self._redis.get(self._key(state))
if val is not None:
self._redis.delete(self._key(state))
return val is not None
except Exception as e: # noqa: BLE001
logger.warning("微信 state 校验 Redis 失败,降级内存: %s", e)
return self._fallback.verify_and_consume(state)
@dataclass
class WechatUserInfo:
"""微信用户信息"""
openid: str
unionid: str = ""
nickname: str = ""
avatar_url: str = ""
class WechatOAuthService:
"""微信开放平台 OAuth 服务(网页扫码登录)"""
def __init__(
self,
app_id: str | None = None,
app_secret: str | None = None,
redirect_uri: str | None = None,
state_store=None,
):
self.app_id = app_id or os.environ.get("WECHAT_OPEN_APP_ID", "")
self.app_secret = app_secret or os.environ.get("WECHAT_OPEN_APP_SECRET", "")
self.redirect_uri = redirect_uri or os.environ.get("WECHAT_OPEN_REDIRECT_URI", "")
# state 存储(CSRF 防护),默认内存实现
self._state_store = state_store or MemoryStateStore()
def is_configured(self) -> bool:
"""检查微信配置是否完整"""
return bool(self.app_id and self.app_secret and self.redirect_uri)
def generate_auth_url(self, scope: str = "snsapi_login") -> tuple[str, str]:
"""
生成微信授权链接
Returns:
(授权URL, state)
"""
state = uuid4().hex
# 保存 state 用于回调校验(防 CSRF)
self._state_store.put(state)
if not self.is_configured():
# 未配置时返回 mock URL,方便前端联调
mock_params = urllib.parse.urlencode(
{
"app_id": "mock",
"redirect_uri": self.redirect_uri,
"scope": scope,
"state": state,
}
)
return f"/mock/wechat/auth?{mock_params}", state
params = {
"appid": self.app_id,
"redirect_uri": self.redirect_uri,
"response_type": "code",
"scope": scope,
"state": state,
}
url = "https://open.weixin.qq.com/connect/qrconnect?" + urllib.parse.urlencode(params) + "#wechat_redirect"
return url, state
def handle_callback(self, code: str, state: str) -> tuple[Optional[WechatUserInfo], Optional[str]]:
"""
处理微信回调
Args:
code: 微信授权码
state: 防 CSRF 状态
Returns:
(微信用户信息, 错误信息)
"""
if not code:
return None, "缺少授权码"
# 校验 state(防 CSRF)—— 一次性使用
if not state or not self._state_store.verify_and_consume(state):
logger.warning("微信回调 state 校验失败: state=%s", state)
return None, "无效的 state 参数,请求可能已过期或被篡改"
if not self.is_configured():
# 开发模式:返回 mock 用户信息
logger.info("微信未配置,使用 mock 用户信息")
return (
WechatUserInfo(
openid=f"mock_{code[:20]}",
unionid=f"mock_union_{code[:16]}",
nickname="微信测试用户",
avatar_url="",
),
None,
)
try:
# 1. 用 code 换 access_token
token_url = "https://api.weixin.qq.com/sns/oauth2/access_token"
token_params = {
"appid": self.app_id,
"secret": self.app_secret,
"code": code,
"grant_type": "authorization_code",
}
token_resp = requests.get(token_url, params=token_params, timeout=10)
# 微信响应头不带 charsetrequests 默认按 ISO-8859-1 解码会导致中文乱码
token_resp.encoding = "utf-8"
token_data = token_resp.json()
if "errcode" in token_data and token_data["errcode"] != 0:
logger.error("微信获取 access_token 失败: %s", token_data)
return None, f"微信授权失败: {token_data.get('errmsg', '未知错误')}"
access_token = token_data["access_token"]
openid = token_data["openid"]
unionid = token_data.get("unionid", "")
# 2. 获取用户信息
user_url = "https://api.weixin.qq.com/sns/userinfo"
user_params = {
"access_token": access_token,
"openid": openid,
"lang": "zh_CN",
}
user_resp = requests.get(user_url, params=user_params, timeout=10)
# 同上:显式 UTF-8 解码,保证中文昵称/unionid 等不乱码
user_resp.encoding = "utf-8"
user_data = user_resp.json()
if "errcode" in user_data and user_data["errcode"] != 0:
logger.error("微信获取用户信息失败: %s", user_data)
return None, f"获取用户信息失败: {user_data.get('errmsg', '未知错误')}"
return (
WechatUserInfo(
openid=openid,
unionid=unionid,
nickname=user_data.get("nickname", ""),
avatar_url=user_data.get("headimgurl", ""),
),
None,
)
except requests.RequestException as e:
logger.error("微信 OAuth 请求异常: %s", e, exc_info=True)
return None, "微信服务暂不可用,请稍后再试"
except Exception as e:
logger.error("微信回调处理异常: %s", e, exc_info=True)
return None, "微信登录处理失败"
# 模块级单例:state 存储必须跨请求共享,否则 /wechat/url 生成的 state
# 与 /wechat/callback 校验时不在同一个 MemoryStateStore,回调必然 400。
# 多实例部署时应替换为 Redis state store(单容器多 worker 也需如此)。
_oauth_service_singleton: WechatOAuthService | None = None
def _build_default_state_store():
"""默认 state 存储:优先 Redis(多实例/重启安全),不可用由 store 内部降级内存。"""
redis_url = ""
try:
from app.config import get_settings
redis_url = get_settings().CELERY_BROKER_URL or get_settings().REDIS_URL
except Exception: # noqa: BLE001 — API 配置不可用时退回环境变量
redis_url = os.environ.get("CELERY_BROKER_URL", "") or os.environ.get("REDIS_URL", "")
if redis_url:
return RedisStateStore(redis_url)
return MemoryStateStore()
def get_wechat_oauth_service() -> WechatOAuthService:
"""获取微信 OAuth 服务单例(state store 跨请求共享)"""
global _oauth_service_singleton
if _oauth_service_singleton is None:
_oauth_service_singleton = WechatOAuthService(state_store=_build_default_state_store())
return _oauth_service_singleton