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校验
207 lines
6.7 KiB
Python
Executable File
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()
|