Files
xiaoxia 8a6d51f6c3
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m31s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 55s
CI/CD Pipeline / Build Staging API Image (push) Successful in 58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m57s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 29s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m11s
CI/CD Pipeline / Validate - Style (push) Successful in 2m21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m48s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m50s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m4s
CI/CD Pipeline / Validate - Security (push) Successful in 5m22s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m24s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m32s
AI Code Review / AI Code Review (pull_request) Failing after 6m21s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m55s
CI/CD Pipeline / Unit Tests (push) Successful in 9m46s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 17s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 33s
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 30s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m23s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
fix(auth): 微信OAuth服务工厂改为真单例,修复回调state必校验失败 (#1718) (#1719)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-05 17:17:07 +08:00

215 lines
7.1 KiB
Python
Executable File

"""
微信 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 分钟
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]
@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)
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)
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 get_wechat_oauth_service() -> WechatOAuthService:
"""获取微信 OAuth 服务单例(state store 跨请求共享)"""
global _oauth_service_singleton
if _oauth_service_singleton is None:
_oauth_service_singleton = WechatOAuthService()
return _oauth_service_singleton