Selaa lähdekoodia

fix: 小程序服务号绑定先打开门户域名再跳转微信授权。

web-view 不能直接打开 open.weixin.qq.com,移动端改为打开已配置的业务域名中转页。

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 1 päivä sitten
vanhempi
commit
e7c1762113

+ 50 - 0
backend/app/api/wechat_notices.py

@@ -1,5 +1,6 @@
 """本人服务号绑定;OAuth 回调仅暂存候选,不直接绑定、不授予门户登录。"""
 from html import escape
+import json
 from pathlib import Path
 import secrets
 from typing import Literal, Optional
@@ -49,6 +50,17 @@ def remove(user: dict = Depends(get_current_user), db: Session = Depends(get_db)
     return success_response(svc.unbind(db, user))
 
 
+@router.get("/public/wechat-notices/oauth/launch", response_class=HTMLResponse)
+def oauth_launch(state: str = Query(default="")):
+    """小程序 web-view 入口。先由业务域名打开本页,再跳到微信授权。"""
+    try:
+        target = svc.resolve_mobile_authorize_target(state)
+    except HTTPException as exc:
+        message = exc.detail if isinstance(exc.detail, str) else "授权页面打开失败,请返回小程序重试"
+        return _binding_message_page(message)
+    return _oauth_redirect_page(target)
+
+
 @router.get("/public/wechat-notices/oauth/callback", response_class=HTMLResponse)
 def callback(state: str = Query(min_length=20, max_length=100), code: str = Query(min_length=1, max_length=512)):
     try:
@@ -68,6 +80,44 @@ def callback(state: str = Query(min_length=20, max_length=100), code: str = Quer
     )
 
 
+def _page_headers(nonce):
+    return {
+        "Cache-Control": "no-store", "Referrer-Policy": "no-referrer",
+        "Content-Security-Policy": (
+            "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; "
+            f"script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'"
+        ),
+    }
+
+
+def _binding_message_page(message):
+    nonce = secrets.token_urlsafe(24)
+    return HTMLResponse(
+        '<!doctype html><html lang="zh-CN"><meta charset="utf-8">'
+        '<meta name="viewport" content="width=device-width, initial-scale=1">'
+        '<meta name="referrer" content="no-referrer"><title>微信授权</title>'
+        f'<style nonce="{nonce}">body{{font:16px/1.6 sans-serif;color:#444;padding:24px}}</style>'
+        f'<body><p>{escape(message)}</p></body></html>',
+        status_code=200,
+        headers=_page_headers(nonce),
+    )
+
+
+def _oauth_redirect_page(target):
+    nonce = secrets.token_urlsafe(24)
+    payload = json.dumps(target).replace("<", "\\u003c")
+    return HTMLResponse(
+        '<!doctype html><html lang="zh-CN"><meta charset="utf-8">'
+        '<meta name="viewport" content="width=device-width, initial-scale=1">'
+        '<meta name="referrer" content="no-referrer"><title>微信授权</title>'
+        f'<body><p>正在打开微信授权…</p>'
+        f'<style nonce="{nonce}">body{{font:16px/1.6 sans-serif;color:#444;padding:24px}}</style>'
+        f'<script nonce="{nonce}">location.replace({payload})</script></body></html>',
+        status_code=200,
+        headers=_page_headers(nonce),
+    )
+
+
 def mobile_callback_page(subscribed: bool):
     """只返回原小程序;真实绑定仍由原登录客户端提交并显示最终结果。"""
     nonce = secrets.token_urlsafe(24)

+ 1 - 0
backend/app/core/config.py

@@ -206,6 +206,7 @@ class Settings(BaseSettings):
         "/api/web/auth/refresh-token,"
         "/api/web/auth/mp-webview-exchange,"
         "/api/public/wechat-notices/oauth/callback,"
+        "/api/public/wechat-notices/oauth/launch,"
         "/api/web/admin/attachment/upload,"
         "/api/web/doclib/upload,"
         "/api/web/admin/products/upload-image,"

+ 28 - 1
backend/app/services/wechat_notice_binding.py

@@ -1,5 +1,6 @@
 """服务号身份绑定:扫码候选 + 原登录端确认,两步均在短时会话内完成。"""
 import json
+import re
 import secrets
 import time
 from urllib.parse import urlencode, urlparse
@@ -15,6 +16,7 @@ from app.models.wechat_notice import WechatBinding
 from app.services.wechat_oa_client import client, WechatAPIError
 
 TTL = 300
+_STATE_RE = re.compile(r"^[A-Za-z0-9_-]{20,100}$")
 _GETDEL = "local v=redis.call('GET',KEYS[1]); redis.call('DEL',KEYS[1]); return v"
 _UNLOCK = "if redis.call('GET',KEYS[1]) == ARGV[1] then return redis.call('DEL',KEYS[1]) end return 0"
 
@@ -125,10 +127,35 @@ def _start_binding(user, *, mode="desktop"):
     _save(session_id, data)
     _call("set", "wechat:oa:state:" + state, session_id, ex=TTL)
     _call("set", _owner_key(user), session_id, ex=TTL)
+    # 小程序 web-view 只能打开已配置的业务域名。open.weixin.qq.com 不能作为 src,
+    # 移动端先打开门户中转页,再由该页跳到微信授权;电脑扫码仍直接使用授权链接。
+    authorize_url = mobile_launch_url(state) if mode == "mobile" else oauth_authorize_url(state)
+    return {"sessionId": session_id, "authorizeUrl": authorize_url, "expiresIn": TTL}
+
+
+def oauth_authorize_url(state):
     callback = settings.WECHAT_OA_PUBLIC_BASE_URL.rstrip("/") + settings.API_V1_PREFIX + "/public/wechat-notices/oauth/callback"
     params = {"appid": settings.WECHAT_OA_APPID, "redirect_uri": callback,
               "response_type": "code", "scope": "snsapi_base", "state": state}
-    return {"sessionId": session_id, "authorizeUrl": "https://open.weixin.qq.com/connect/oauth2/authorize?" + urlencode(params) + "#wechat_redirect", "expiresIn": TTL}
+    return "https://open.weixin.qq.com/connect/oauth2/authorize?" + urlencode(params) + "#wechat_redirect"
+
+
+def mobile_launch_url(state):
+    base = settings.WECHAT_OA_PUBLIC_BASE_URL.rstrip("/") + settings.API_V1_PREFIX
+    return base + "/public/wechat-notices/oauth/launch?" + urlencode({"state": state})
+
+
+def resolve_mobile_authorize_target(state):
+    """校验尚未消费的 state,返回官方授权地址。不删除 state,授权回调才消费。"""
+    if not _STATE_RE.fullmatch(state or ""):
+        raise HTTPException(400, "授权链接无效,请返回小程序重新绑定")
+    if not enabled():
+        raise HTTPException(503, "服务号通知尚未配置启用")
+    raw = _call("get", "wechat:oa:state:" + state)
+    session_id = _decode(raw) if raw else ""
+    if not session_id or not _load(session_id):
+        raise HTTPException(400, "授权已过期,请返回小程序重新绑定")
+    return oauth_authorize_url(state)
 
 
 def oauth_callback(state, code):

+ 26 - 0
backend/tests/test_wechat_notice_binding.py

@@ -174,6 +174,32 @@ def test_callback_instructs_mobile_return_without_binding(binding, monkeypatch):
     assert response.headers['referrer-policy'] == 'no-referrer'
 
 
+def test_mobile_web_view_opens_business_domain_before_wechat(binding):
+    from app.api.wechat_notices import oauth_launch
+    from urllib.parse import parse_qs, urlparse
+    svc, _, redis, user, _ = binding
+    started = svc.start_binding(user, mode='mobile')
+    launch = urlparse(started['authorizeUrl'])
+    assert launch.scheme == 'https'
+    assert launch.netloc == 'portal.example.test'
+    assert launch.path == '/api/public/wechat-notices/oauth/launch'
+    assert 'open.weixin.qq.com' not in started['authorizeUrl']
+    state = parse_qs(launch.query)['state'][0]
+    page = oauth_launch(state)
+    body = page.body.decode()
+    assert 'location.replace' in body
+    assert 'open.weixin.qq.com/connect/oauth2/authorize' in body
+    assert state in body
+    assert "'unsafe-inline'" not in page.headers['content-security-policy']
+    assert redis.get('wechat:oa:state:' + state)
+    missing = oauth_launch('a' * 32)
+    assert '过期' in missing.body.decode()
+    assert 'open.weixin.qq.com' not in missing.body.decode()
+    invalid = oauth_launch('javascript:alert(1)')
+    assert '无效' in invalid.body.decode()
+    assert 'open.weixin.qq.com' not in invalid.body.decode()
+
+
 def test_mobile_callback_only_returns_to_original_authenticated_client(binding):
     from app.api.wechat_notices import callback, start, StartBinding
     from urllib.parse import urlparse, parse_qs

+ 1 - 1
docs/wechat-approval-notices-setup.md

@@ -72,7 +72,7 @@ WECHAT_OA_CERT_REMINDER_MINUTE=0
 ### 小程序内绑定上线与验收
 
 - 保持现有服务号网页授权域名与回调配置,另将回调所在HTTPS域名配置为小程序的业务域名;小程序须具备web-view使用资格。参考[腾讯官方域名配置说明](https://cloud.tencent.com.cn/document/product/1301/100434)及[uni-app web-view说明](https://uniapp.dcloud.io/component/web-view.html)。回调域名若不能在小程序中打开,授权页会失败,需管理员配置;不能仅凭本地编译认定真机授权成功。
-- 新页面 `pages/common/wechat-binding-authorize` 仅从当前关注页通过EventChannel取得服务端签发的官方授权URL,等待onReady再挂载。登录JWT、服务号OpenID和AppSecret不放进路由参数或本地存储;不接受任意URL query作为web-view来源。
+- 新页面 `pages/common/wechat-binding-authorize` 仅从当前关注页通过EventChannel取得服务端签发的门户中转地址(`/api/public/wechat-notices/oauth/launch`),等待onReady再挂载。web-view 不能直接打开 `open.weixin.qq.com`,中转页位于已配置的业务域名,再跳到微信授权。登录JWT、服务号OpenID和AppSecret不放进路由参数或本地存储;不接受任意URL query作为web-view来源。
 - 小程序发起会话传 `{mode: "mobile"}`,服务端记录模式;电脑无请求体时默认 desktop,仍保留验证码人工确认。移动回调页 `backend/app/templates/wechat-binding-mobile.html` 使用固定官方 JS SDK 地址 `https://res.wx.qq.com/open/js/jweixin-1.6.0.js` 和 nonce CSP,尝试 `wx.miniProgram.navigateBack` 返回上一页,不跳转首页以免丢失发起会话。部署时须包含该模板文件。SDK 或桥接失败时显示返回按钮、左上角返回提示。
 - 返回后仅对当前登录用户在本页主动发起且授权、关注均完成的会话自动提交确认;普通进入页面不会自动发起绑定。失败可刷新重试,过期或未关注可重新授权;切换账号或离开页面后忽略旧异步响应。关闭/重新进入页面可重新发起会话。
 - 真机验收:关注→一键绑定当前账号→网页授权→自动返回→自动绑定;桥接异常时左上角返回仍应完成绑定,再次进入显示已绑定;验证未关注、授权取消、会话过期、账号切换、重复点击和重复微信冲突。未作真机授权或发布。

+ 14 - 1
miniprogram/api/wechat-notices.js

@@ -1,5 +1,13 @@
+import { resolveApiBase, resolveWebOrigin } from '@/config/api'
 import { request, parseResponseJson } from '@/utils/request'
 
+const LAUNCH_PATH = '/api/public/wechat-notices/oauth/launch'
+
+function httpsHost(raw) {
+  const match = String(raw || '').match(/^https:\/\/([^/?#]+)/i)
+  return match ? match[1].toLowerCase() : ''
+}
+
 export async function fetchWechatNoticeStatus() {
   const response = await request({
     url: '/web/wechat-notices/binding',
@@ -27,7 +35,12 @@ async function bindingRequest(method, path, data) {
 }
 
 export function isWechatAuthorizeUrl(value) {
-  return typeof value === 'string' && /^https:\/\/open\.weixin\.qq\.com\/connect\/oauth2\/authorize\?[^\s]+$/.test(value)
+  // web-view 只能打开业务域名。这里只接受门户上的授权中转页,不直接打开 open.weixin.qq.com。
+  if (typeof value !== 'string' || value.length > 300) return false
+  const match = value.match(/^https:\/\/([^/?#@]+)(\/[^?#]*)\?state=([A-Za-z0-9_-]{20,100})$/)
+  if (!match || match[2] !== LAUNCH_PATH) return false
+  const allowed = new Set([httpsHost(resolveWebOrigin()), httpsHost(resolveApiBase())].filter(Boolean))
+  return allowed.has(match[1].toLowerCase())
 }
 
 export async function startWechatBinding() {

+ 17 - 5
miniprogram/scripts/test-wechat-binding-integration.mjs

@@ -8,16 +8,22 @@ const { parse, compileTemplate } = require('@vue/compiler-sfc')
 const read = p => readFileSync(new URL(p, import.meta.url), 'utf8')
 const strip = s => s.replace(/^import[^\n]+\n/gm, '').replace(/^export /gm, '')
 
+const launchState = 'a'.repeat(32)
+const launchUrl = `https://w.shanxiachem.com/api/public/wechat-notices/oauth/launch?state=${launchState}`
+
 function api(data, statusCode = 200, code = 200) {
   const calls = []
   const source = strip(read('../api/wechat-notices.js'))
-  const fns = new Function('request', 'parseResponseJson', `${source}; return { startWechatBinding, fetchWechatBindingSession, confirmWechatBinding, isWechatAuthorizeUrl }`)(
-    async options => { calls.push(options); return { statusCode, data: { code, data, message: '请求失败' } } }, response => response.data)
+  const fns = new Function('request', 'parseResponseJson', 'resolveWebOrigin', 'resolveApiBase', `${source}; return { startWechatBinding, fetchWechatBindingSession, confirmWechatBinding, isWechatAuthorizeUrl }`)(
+    async options => { calls.push(options); return { statusCode, data: { code, data, message: '请求失败' } } },
+    response => response.data,
+    () => 'https://w.shanxiachem.com',
+    () => 'https://w.shanxiachem.com/api')
   return { ...fns, calls }
 }
 
 test('binding API uses owner-authenticated endpoints and only returns necessary fields', async () => {
-  const start = api({ sessionId: 'a'.repeat(32), authorizeUrl: 'https://open.weixin.qq.com/connect/oauth2/authorize?state=state#wechat_redirect', openid: 'private' })
+  const start = api({ sessionId: 'a'.repeat(32), authorizeUrl: launchUrl, openid: 'private' })
   assert.equal((await start.startWechatBinding()).openid, undefined)
   assert.equal(start.calls[0].method, 'POST')
   assert.deepEqual(start.calls[0].data, { mode: 'mobile' })
@@ -32,7 +38,13 @@ test('binding API uses owner-authenticated endpoints and only returns necessary
 
 test('binding API rejects foreign URLs, failed and malformed results', async () => {
   const fns = api({})
-  for (const url of ['javascript:alert(1)', 'http://open.weixin.qq.com/connect/oauth2/authorize?state=x', 'https://open.weixin.qq.com.evil.test/connect/oauth2/authorize?state=x']) {
+  for (const url of [
+    'javascript:alert(1)',
+    'http://open.weixin.qq.com/connect/oauth2/authorize?state=x',
+    'https://open.weixin.qq.com/connect/oauth2/authorize?state=' + launchState,
+    'https://evil.test/api/public/wechat-notices/oauth/launch?state=' + launchState,
+    launchUrl + '#wechat_redirect'
+  ]) {
     assert.equal(fns.isWechatAuthorizeUrl(url), false)
     await assert.rejects(api({ sessionId: 'x'.repeat(32), authorizeUrl: url }).startWechatBinding())
   }
@@ -53,7 +65,7 @@ test('authorization page mounts approved URL after ready and unregisters its cha
   const page = new Function(...Object.keys(deps), `${body}; return { src, errorText, onError }`)(...Object.values(deps))
   hooks.load({ url: 'https://evil.test' })
   assert.equal(page.src.value, '')
-  const url = 'https://open.weixin.qq.com/connect/oauth2/authorize?state=xyz'
+  const url = launchUrl
   handlers.wechatBindingAuthorize({ authorizeUrl: url })
   assert.equal(page.src.value, '')
   hooks.ready()