From 98f0619b469b3a6853d69ba8e6695d255455ad1c Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sat, 5 Sep 2026 17:11:38 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E5=BE=AE=E4=BF=A1OAuth=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=B7=A5=E5=8E=82=E6=94=B9=E4=B8=BA=E7=9C=9F=E5=8D=95?= =?UTF-8?q?=E4=BE=8B=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=9B=9E=E8=B0=83state?= =?UTF-8?q?=E5=BF=85=E6=A0=A1=E9=AA=8C=E5=A4=B1=E8=B4=A5(#1718)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_wechat_oauth_service() 注释写单例但每次 new WechatOAuthService(), /wechat/url 生成的 state 与 /wechat/callback 校验落在不同 MemoryStateStore, 真实环境配置后回调必现 400「无效的 state 参数」,微信登录完全不可用。 - 模块级 _oauth_service_singleton 懒加载,state store 跨请求共享 - 新增2个回归测试:单例同一性 + state 跨工厂调用可校验/一次性消费 --- .../application/auth/wechat_oauth_service.py | 14 ++++-- tests/unit/test_wechat_oauth_service.py | 49 +++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/packages/application/auth/wechat_oauth_service.py b/packages/application/auth/wechat_oauth_service.py index 16cdf5d47..d8cd3d125 100755 --- a/packages/application/auth/wechat_oauth_service.py +++ b/packages/application/auth/wechat_oauth_service.py @@ -200,7 +200,15 @@ class WechatOAuthService: 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 服务单例""" - # TODO: 可替换为 Redis state store - return WechatOAuthService() + """获取微信 OAuth 服务单例(state store 跨请求共享)""" + global _oauth_service_singleton + if _oauth_service_singleton is None: + _oauth_service_singleton = WechatOAuthService() + return _oauth_service_singleton diff --git a/tests/unit/test_wechat_oauth_service.py b/tests/unit/test_wechat_oauth_service.py index 7a2ed3397..759732c62 100755 --- a/tests/unit/test_wechat_oauth_service.py +++ b/tests/unit/test_wechat_oauth_service.py @@ -388,3 +388,52 @@ class TestGetWechatOAuthService: """返回 WechatOAuthService 实例""" service = get_wechat_oauth_service() assert isinstance(service, WechatOAuthService) + + def test_singleton_same_instance_across_calls(self, monkeypatch): + """#1718 回归:工厂必须返回同一实例,否则 state store 不共享""" + import packages.application.auth.wechat_oauth_service as mod + + monkeypatch.setattr(mod, "_oauth_service_singleton", None) + s1 = get_wechat_oauth_service() + s2 = get_wechat_oauth_service() + assert s1 is s2 + + def test_state_survives_across_factory_calls(self, monkeypatch): + """#1718 回归:/wechat/url 与 /wechat/callback 经工厂拿到同一 state store + + 模拟两次请求各自调用工厂:第一个实例生成 state,第二个实例(同一单例) + 必须能校验通过。修复前工厂每次 new 一个实例,回调必现 400「无效的 state」。 + """ + import packages.application.auth.wechat_oauth_service as mod + + monkeypatch.setattr(mod, "_oauth_service_singleton", None) + monkeypatch.setenv("WECHAT_OPEN_APP_ID", "wx-test") + monkeypatch.setenv("WECHAT_OPEN_APP_SECRET", "secret-test") + monkeypatch.setenv("WECHAT_OPEN_REDIRECT_URI", "https://example.com/cb") + + # 请求1:生成授权链接(state 写入单例 store) + _, state = get_wechat_oauth_service().generate_auth_url() + + # 请求2:回调校验(应命中同一个 store;微信 API 用 mock 避免外网) + with patch("packages.application.auth.wechat_oauth_service.requests.get") as mock_get: + mock_get.return_value = MagicMock( + json=MagicMock( + return_value={ + "access_token": "at", + "openid": "oid", + "unionid": "uid", + "nickname": "n", + "headimgurl": "http://x/a.png", + } + ) + ) + user_info, error = get_wechat_oauth_service().handle_callback("code-x", state) + + assert error is None, f"state 应跨请求共享,实际报错: {error}" + assert user_info is not None + assert user_info.openid == "oid" + + # state 一次性消费,重放必须失败 + user_info2, error2 = get_wechat_oauth_service().handle_callback("code-y", state) + assert user_info2 is None + assert "state" in error2 -- 2.54.0