Files
xiaoxia-saas/packages/application/auth/wechat_oauth_service.py
T
xiaoxia 9f69fde30b
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 40s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m36s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m43s
CI/CD Pipeline / Unit Tests (push) Successful in 2m34s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m42s
CI/CD Pipeline / Integration Tests (push) Successful in 1m2s
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
fix(#558): 微信登录3个后端修复 - 验证码500 + 绑定500 + state校验 (#684)
fix(#558): 微信登录3个后端修复 - 验证码500 + 绑定500 + state校验
2026-07-21 17:51:48 +08:00

207 lines
6.7 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, "微信登录处理失败"
def get_wechat_oauth_service() -> WechatOAuthService:
"""获取微信 OAuth 服务单例"""
# TODO: 可替换为 Redis state store
return WechatOAuthService()