一个 Laravel 版微信公众号网页授权代理。它解决的问题是:只有一个公众号网页授权域名,但有多个示例项目、测试项目或 H5 项目需要使用同一个公众号登录。
当前实现只代理微信 OAuth code,不在代理项目中换取 access_token/openid。业务项目仍负责使用 code + app secret 换取用户 OpenID,并完成自己的会员创建、登录 token 签发、业务绑定等逻辑。
面向 agent 的接口文档放在:
/docs/api.md
实现一个独立代理站点,例如:
https://wx-pub-api.example.com
微信公众号后台只配置这个代理域名作为网页授权域名。业务项目发起登录时不再直接跳微信,而是跳到代理:
https://wx-pub-api.example.com/oauth/wechat
代理再跳微信。微信回调代理域名后,代理把 code/state 原样转回业务项目自己的 callback。
- 代理项目只需要公众号
appid。 - 代理项目不需要公众号
secret。 state必须由业务项目生成并透传给代理。- 业务项目原来的
code -> openid -> member -> token流程尽量保持不变。 - 代理只解决微信网页授权域名限制,不做业务登录。
sequenceDiagram
participant H5 as H5/示例项目
participant API as 业务项目 API
participant Proxy as OAuth 代理项目
participant WeChat as 微信网页授权
H5->>API: GET /api/mobile/v1/auth/wechat/base?url=H5_CALLBACK
API->>API: 生成 state,缓存 state -> campus/tenant/return_url/type
API->>Proxy: 302 /oauth/wechat?scope=snsapi_base&state=STATE&redirect_uri=API_CALLBACK
Proxy->>Proxy: 缓存 state -> API_CALLBACK
Proxy->>WeChat: 302 connect/oauth2/authorize?appid=...&redirect_uri=PROXY_CALLBACK&state=STATE
WeChat->>Proxy: GET /oauth/wechat/callback?code=CODE&state=STATE
Proxy->>API: 302 API_CALLBACK?code=CODE&state=STATE
API->>WeChat: 用 code + secret 换 openid
API->>API: 创建/更新会员,签发业务 token
API->>H5: 302 H5_CALLBACK?token=TOKEN&auth=wechat
新增 config/wechat_oauth.php:
<?php
return [
'app_id' => env('WECHAT_OFFICIAL_ACCOUNT_APP_ID', env('WECHAT_APP_ID')),
'default_scope' => env('WECHAT_OAUTH_DEFAULT_SCOPE', 'snsapi_base'),
'allowed_scopes' => [
'snsapi_base',
'snsapi_userinfo',
],
'allowed_redirect_hosts' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('WECHAT_OAUTH_ALLOWED_REDIRECT_HOSTS', ''))
))),
'state_ttl_minutes' => (int) env('WECHAT_OAUTH_STATE_TTL_MINUTES', 10),
];环境变量:
APP_URL=https://wx-pub-api.example.com
CACHE_DRIVER=file
WECHAT_OFFICIAL_ACCOUNT_APP_ID=wx...
WECHAT_OAUTH_DEFAULT_SCOPE=snsapi_base
WECHAT_OAUTH_ALLOWED_REDIRECT_HOSTS=school.example.com,h5-dev.example.test
WECHAT_OAUTH_STATE_TTL_MINUTES=10WECHAT_OAUTH_ALLOWED_REDIRECT_HOSTS 留空表示不限制业务项目回跳 host。支持:
- 精确 host:
school.example.com - 通配全部:
* - 子域名通配:
*.example.com
在 routes/web.php 注册:
use App\Http\Controllers\OAuth\WeChatOAuthController;
Route::get('/oauth/wechat', [WeChatOAuthController::class, 'redirect'])
->name('oauth.wechat.redirect');
Route::get('/oauth/wechat/callback', [WeChatOAuthController::class, 'callback'])
->name('oauth.wechat.callback');这两个接口放在 web.php 是为了得到干净的根路径,不带 /api 前缀。
接口:
GET /oauth/wechat?scope=snsapi_base&state=<业务项目生成的 state>&redirect_uri=<urlencoded 业务项目 callback>
参数:
| 参数 | 必填 | 说明 |
|---|---|---|
redirect_uri |
是 | 业务项目自己的微信授权 callback,例如 https://school.example.com/api/mobile/v1/auth/wechat/base-callback?... |
scope |
否 | snsapi_base 或 snsapi_userinfo,默认 snsapi_base |
state |
否 | 推荐业务项目必传;代理在缺失时可生成随机 state |
app_id / appid |
否 | 只有代理项目未配置 WECHAT_OFFICIAL_ACCOUNT_APP_ID 时才作为兜底 |
处理步骤:
-
读取并解码
redirect_uri。 -
校验
redirect_uri必须是完整http/https地址。 -
如果配置了
WECHAT_OAUTH_ALLOWED_REDIRECT_HOSTS,校验redirect_uri的 host。 -
校验
scope必须是snsapi_base或snsapi_userinfo。 -
读取
state,没有则生成 32 位随机字符串。 -
将
state -> redirect_uri/scope/app_id/created_at写入 Cache,TTL 默认 10 分钟。 -
构造微信 OAuth URL:
https://open.weixin.qq.com/connect/oauth2/authorize ?appid=APPID &redirect_uri=https://wx-pub-api.example.com/oauth/wechat/callback &response_type=code &scope=snsapi_base &state=STATE #wechat_redirect -
使用
redirect()->away($wechatAuthorizeUrl)跳转微信。
接口:
GET /oauth/wechat/callback?code=<wechat-code>&state=<state>
处理步骤:
- 校验
code/state必须存在。 - 根据
state从 Cache 读取原始redirect_uri。 - 如果 state 不存在,返回
419,提示授权状态过期。 - 将
code/state追加到原始redirect_uri。 - 跳回业务项目 callback。
追加 query 时要正确处理:
-
原 URL 没有 query:追加
?code=...&state=... -
原 URL 已有 query:追加
&code=...&state=... -
原 URL 是 hash route:例如
/h5/#/pages/auth/callback,应追加到 hash 内部:/h5/#/pages/auth/callback?code=...&state=...
微信网页授权域名验证文件放在代理项目 public/ 目录。
示例:
public/MP_verify_xxxxxxxxxxxxxxxx.txt
Nginx 站点根目录必须指向:
/path/to/wechat-public-auth/public
这样微信才能访问:
https://wx-pub-api.example.com/MP_verify_xxxxxxxxxxxxxxxx.txt
业务项目原本通常是:
业务项目 -> 微信 -> 业务项目 callback
接入代理后变成:
业务项目 -> 代理 -> 微信 -> 代理 callback -> 业务项目 callback
以下以 Laravel 业务项目为例。
在 config/services.php 的微信移动端配置里增加:
'wechat_mobile' => [
'app_id' => env('WECHAT_OFFICIAL_ACCOUNT_APP_ID', env('WECHAT_APP_ID')),
'secret' => env('WECHAT_OFFICIAL_ACCOUNT_SECRET', env('WECHAT_SECRET')),
'callback_base_url' => env('MOBILE_WECHAT_CALLBACK_BASE_URL'),
'oauth_proxy_url' => env('MOBILE_WECHAT_OAUTH_PROXY_URL'),
'proxy_allow_insecure_callback' => env('MOBILE_WECHAT_PROXY_ALLOW_INSECURE_CALLBACK', false),
'allowed_redirect_hosts' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('MOBILE_H5_ALLOWED_REDIRECT_HOSTS', ''))
))),
],业务项目环境变量:
WECHAT_OFFICIAL_ACCOUNT_APP_ID=wx...
WECHAT_OFFICIAL_ACCOUNT_SECRET=...
MOBILE_WECHAT_CALLBACK_BASE_URL=https://school.example.com
MOBILE_WECHAT_OAUTH_PROXY_URL=https://wx-pub-api.example.com/oauth/wechat
MOBILE_WECHAT_PROXY_ALLOW_INSECURE_CALLBACK=false
MOBILE_H5_ALLOWED_REDIRECT_HOSTS=school.example.com本地或内网示例项目调试时,如果最终用户手机能访问内网地址,可以显式放宽:
MOBILE_WECHAT_CALLBACK_BASE_URL=http://api-dev.example.test:10001
MOBILE_WECHAT_PROXY_ALLOW_INSECURE_CALLBACK=true
MOBILE_H5_ALLOWED_REDIRECT_HOSTS=h5-dev.example.test,localhost,127.0.0.1业务项目的授权入口负责生成 state,并缓存业务上下文。
伪代码:
private function redirectToWechatOauth(Request $request, string $type): RedirectResponse
{
$validated = $request->validate([
'url' => ['required', 'string', 'max:1000'],
]);
$redirectUrl = $this->safeH5RedirectUrl($request, $validated['url']);
$state = Str::random(32);
$campusCode = $this->campusCode($request);
Cache::put('mobile_wechat_oauth:'.$state, [
'campus_code' => $campusCode,
'tenant_id' => $this->tenantId(),
'redirect_url' => $redirectUrl,
'type' => $type,
], now()->addMinutes(10));
$callbackUrl = $this->wechatCallbackUrl($request, $type, $campusCode, $redirectUrl);
$redirectTo = $this->wechatAuthorizeRedirectUrl($type, $state, $callbackUrl);
return redirect()->away($redirectTo);
}新增一个方法:
private function wechatAuthorizeRedirectUrl(string $type, string $state, string $callbackUrl): string
{
$proxyUrl = trim((string) config('services.wechat_mobile.oauth_proxy_url'));
if ($proxyUrl !== '') {
return $this->appendQuery($proxyUrl, [
'scope' => $type === 'info' ? 'snsapi_userinfo' : 'snsapi_base',
'state' => $state,
'redirect_uri' => $callbackUrl,
]);
}
return $this->wechatProvider($type, $callbackUrl)
->withState($state)
->redirect();
}这样可以做到:
- 配置了
MOBILE_WECHAT_OAUTH_PROXY_URL:跳代理。 - 没配置:保留原来的直连微信逻辑。
业务项目直连微信时,MOBILE_WECHAT_CALLBACK_BASE_URL 必须是线上 HTTPS,因为微信会直接访问业务项目 callback。
代理模式下,微信只访问代理域名;业务项目 callback 是用户浏览器后续访问的地址。因此可以通过配置允许内网 HTTP callback:
private function wechatCallbackUrl(Request $request, string $type, string $campusCode, ?string $returnUrl = null): string
{
$baseUrl = rtrim(trim((string) config('services.wechat_mobile.callback_base_url')), '/');
$parts = parse_url($baseUrl);
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
$host = trim((string) ($parts['host'] ?? ''));
if ($baseUrl === '' || ! is_array($parts) || $host === '') {
throw ValidationException::withMessages([
'wechat' => ['MOBILE_WECHAT_CALLBACK_BASE_URL 必须配置为线上 HTTPS 地址'],
]);
}
if ($scheme !== 'https' && ! $this->allowsInsecureWechatCallbackInProxyMode()) {
throw ValidationException::withMessages([
'wechat' => ['MOBILE_WECHAT_CALLBACK_BASE_URL 必须配置为线上 HTTPS 地址'],
]);
}
$query = ['campus_code' => $campusCode];
if ($returnUrl !== null && $returnUrl !== '') {
$query['return_url'] = $this->safeH5RedirectUrl($request, $returnUrl);
}
return $baseUrl.'/api/mobile/v1/auth/wechat/'.$type.'-callback?'.http_build_query($query, '', '&', PHP_QUERY_RFC3986);
}
private function allowsInsecureWechatCallbackInProxyMode(): bool
{
return trim((string) config('services.wechat_mobile.oauth_proxy_url')) !== ''
&& (bool) config('services.wechat_mobile.proxy_allow_insecure_callback');
}业务 callback 仍然接收:
code
state
然后:
- 用
state读取业务项目缓存。 - 用
code调微信接口换 openid。 - 按
tenant_id + openid创建或更新会员。 - 签发业务系统 token。
- 跳回 H5 callback,附带
token。
代理不参与这一步。
openid、头像、昵称都应该由业务后端获取,不建议放到代理项目中获取。原因是换取这些信息需要公众号 secret,而 openid 最终也是业务项目用来识别会员、绑定支付和签发登录 token 的后端数据。
代理项目只负责把微信返回的 code 交还给业务项目。业务项目拿到 code 后,根据本次授权类型决定能拿到哪些信息:
| 授权类型 | 微信 scope | 用户感知 | 后端可得到的信息 | 推荐场景 |
|---|---|---|---|---|
| 静默授权 | snsapi_base |
通常不弹确认页 | openid |
默认登录、支付前识别用户 |
| 资料授权 | snsapi_userinfo |
会要求用户确认 | openid、昵称、头像等 |
个人资料页同步微信资料 |
sequenceDiagram
participant H5 as H5
participant API as 业务后端
participant Proxy as OAuth 代理
participant WeChat as 微信
H5->>API: GET /auth/wechat/base?url=H5_CALLBACK
API->>Proxy: 302 /oauth/wechat?scope=snsapi_base&state=STATE&redirect_uri=BASE_CALLBACK
Proxy->>WeChat: 302 OAuth(scope=snsapi_base)
WeChat->>Proxy: callback?code=CODE&state=STATE
Proxy->>API: 302 BASE_CALLBACK?code=CODE&state=STATE
API->>WeChat: code + secret 换 openid
API->>API: 按 tenant_id + openid 创建/登录会员
API->>H5: 302 H5_CALLBACK?token=TOKEN&auth=wechat
静默授权不要求头像昵称一定存在。业务项目可以给新会员生成默认昵称,例如 微信用户1234。
sequenceDiagram
participant H5 as H5
participant API as 业务后端
participant Proxy as OAuth 代理
participant WeChat as 微信
H5->>API: GET /auth/wechat/info?url=H5_CALLBACK
API->>Proxy: 302 /oauth/wechat?scope=snsapi_userinfo&state=STATE&redirect_uri=INFO_CALLBACK
Proxy->>WeChat: 302 OAuth(scope=snsapi_userinfo)
WeChat->>Proxy: callback?code=CODE&state=STATE
Proxy->>API: 302 INFO_CALLBACK?code=CODE&state=STATE
API->>WeChat: code + secret 换 openid/userinfo
API->>API: 保存 openid、nickname、avatar
API->>H5: 302 H5_CALLBACK?token=TOKEN&auth=wechat
注意:
openid默认只保存在业务后端,不放到 H5 回调 URL。- H5 只需要拿业务
token,再通过/bootstrap或/me读取昵称头像。 - 代理 callback 仍然只转发
code/state,不返回openid/nickname/avatar。 - H5 默认未登录流程建议走
base;个人资料页“同步微信资料”按钮再走info。
代理项目建议至少覆盖:
/oauth/wechat能跳转到微信 OAuth。- 跳微信 URL 中包含正确
appid/scope/state/redirect_uri。 - 发起授权时会缓存
state -> redirect_uri。 - 缺少
state时代理能生成 state。 scope=snsapi_userinfo会被原样透传到微信 OAuth。/oauth/wechat/callback只把code/state转回原始redirect_uri,不夹带openid/nickname/avatar。- 原始
redirect_uri已有 query 时能正确追加参数。 - hash route URL 能正确把 query 追加到 hash 内部。
- 不允许的 redirect host 返回 422。
- 不支持的 scope 返回 422。
- 过期或未知 state 返回 419。
业务项目建议至少覆盖:
- 未配置代理时,仍然直连微信。
- 配置代理时,授权入口跳到代理 URL。
- 跳代理时透传
scope/state/redirect_uri。 - 业务项目自己的
state缓存仍存在。 - 代理模式下可通过配置允许内网 HTTP callback。
base授权使用snsapi_base,callback 收到code/state后能创建会员并返回 token。info授权使用snsapi_userinfo,callback 能保存openid/nickname/avatar并返回 token。- H5 回调 URL 不暴露
openid,登录后通过/bootstrap或/me获取昵称头像。
代理项目:
composer install --no-dev --optimize-autoloader
cp .env.example .env
php artisan key:generate
php artisan config:clearNginx root 指向:
/var/www/wechat-public-auth/public
生产环境变量示例:
APP_ENV=production
APP_DEBUG=false
APP_URL=https://wx-pub-api.example.com
CACHE_DRIVER=file
WECHAT_OFFICIAL_ACCOUNT_APP_ID=wx...
WECHAT_OAUTH_DEFAULT_SCOPE=snsapi_base
WECHAT_OAUTH_ALLOWED_REDIRECT_HOSTS=
WECHAT_OAUTH_STATE_TTL_MINUTES=10部署后验证:
curl -i https://wx-pub-api.example.com/MP_verify_xxxxxxxxxxxxxxxx.txt
curl -i "https://wx-pub-api.example.com/oauth/wechat?redirect_uri=https%3A%2F%2Fschool.example.com%2Fapi%2Fmobile%2Fv1%2Fauth%2Fwechat%2Fbase-callback%3Fcampus_code%3Ddemo-campus&state=test-state"预期:
- 校验文件返回
200和正确文本。 /oauth/wechat返回302到https://open.weixin.qq.com/connect/oauth2/authorize?...。- 微信 OAuth URL 中的
redirect_uri是代理 callback。
部署后按实际域名验证链路即可,文档中只保留占位域名:
代理域名:https://wx-pub-api.example.com
业务域名:https://school.example.com
建议验证:
- 代理域名下的
MP_verify_xxxxxxxxxxxxxxxx.txt返回200。 - 业务项目授权入口会
302到代理。 - 代理会
302到微信。 - 代理 callback 收到测试
code/state后会302回业务项目 callback。
下面这段可以直接作为给代码助手的提示词,用来在一个 Laravel 项目里一次性实现同类功能。
请在当前 Laravel 项目中实现一个微信公众号网页授权 code 代理,目标是让多个业务/示例项目共用一个公众号网页授权域名。
核心要求:
1. 只代理 OAuth code,不在代理项目中换 access_token/openid。
2. 代理项目只配置公众号 appid,不配置也不使用 app secret。
3. 业务项目生成 state,并把 state 原样传给代理;代理不能替换业务项目 state。
4. 代理将 state -> redirect_uri/scope/app_id 缓存 10 分钟。
5. 微信回调代理后,代理按 state 找回原始 redirect_uri,并把 code/state 追加后跳回业务项目。
6. 支持 snsapi_base 和 snsapi_userinfo 两种 scope:base 只拿 openid,info 拿 openid、昵称、头像;这些信息都由业务项目后端用 code + secret 获取。
7. openid 默认只保存在业务后端,不放到 H5 回调 URL。
请实现:
1. 新增 config/wechat_oauth.php:
- app_id: env('WECHAT_OFFICIAL_ACCOUNT_APP_ID', env('WECHAT_APP_ID'))
- default_scope: env('WECHAT_OAUTH_DEFAULT_SCOPE', 'snsapi_base')
- allowed_scopes: ['snsapi_base', 'snsapi_userinfo']
- allowed_redirect_hosts: 从 WECHAT_OAUTH_ALLOWED_REDIRECT_HOSTS 逗号分隔读取,留空表示不限制
- state_ttl_minutes: env('WECHAT_OAUTH_STATE_TTL_MINUTES', 10)
2. 新增 App\Http\Controllers\OAuth\WeChatOAuthController:
- GET /oauth/wechat 对应 redirect 方法
- GET /oauth/wechat/callback 对应 callback 方法
- redirect 方法读取 redirect_uri/scope/state/app_id
- redirect_uri 必须是完整 http/https 地址
- 如果 allowed_redirect_hosts 非空,则校验 redirect_uri 的 host;支持精确 host、*、*.example.com
- scope 只允许 snsapi_base/snsapi_userinfo
- state 缺失时生成 Str::random(32)
- state 存 Cache,key 前缀 wechat_oauth_proxy:states:
- 构造微信授权 URL:
https://open.weixin.qq.com/connect/oauth2/authorize?appid=...&redirect_uri=route('oauth.wechat.callback')&response_type=code&scope=...&state=...#wechat_redirect
- 使用 redirect()->away 跳转微信
- callback 方法校验 code/state,根据 state 从 Cache 取 redirect_uri
- state 不存在返回 419
- 把 code/state 追加到 redirect_uri 后 redirect()->away 回业务项目
- 追加 query 时正确处理原 URL 已有 query 和 hash route
3. 在 routes/web.php 注册:
- Route::get('/oauth/wechat', [WeChatOAuthController::class, 'redirect'])->name('oauth.wechat.redirect')
- Route::get('/oauth/wechat/callback', [WeChatOAuthController::class, 'callback'])->name('oauth.wechat.callback')
4. 更新 .env.example,加入:
- WECHAT_OFFICIAL_ACCOUNT_APP_ID=
- WECHAT_OAUTH_DEFAULT_SCOPE=snsapi_base
- WECHAT_OAUTH_ALLOWED_REDIRECT_HOSTS=
- WECHAT_OAUTH_STATE_TTL_MINUTES=10
5. 把微信 MP_verify_xxx.txt 校验文件放到 public/ 下。Nginx root 必须指向 Laravel public 目录。
6. 添加 Feature tests:
- /oauth/wechat 会 302 到微信 OAuth
- 微信 OAuth URL 包含正确 appid/scope/state/redirect_uri
- 发起授权会缓存 state payload
- state 缺失时会生成 32 位 state
- scope=snsapi_userinfo 会被原样透传到微信 OAuth
- /oauth/wechat/callback 会把 code/state 转回原 redirect_uri
- callback 不返回 openid/nickname/avatar,只转发 code/state
- redirect_uri 已有 query 时追加 &
- hash route 时追加到 hash 内部
- 不允许 host 返回 422
- 不支持 scope 返回 422
- 未知 state 返回 419
如果还需要接入一个现有 Laravel 业务项目,请在业务项目中这样改:
1. config/services.php 的 wechat_mobile 增加:
- oauth_proxy_url: env('MOBILE_WECHAT_OAUTH_PROXY_URL')
- proxy_allow_insecure_callback: env('MOBILE_WECHAT_PROXY_ALLOW_INSECURE_CALLBACK', false)
2. 业务项目提供两类授权入口:
- /auth/wechat/base 使用 snsapi_base,静默获取 openid
- /auth/wechat/info 使用 snsapi_userinfo,获取 openid、nickname、avatar
3. 业务项目发起微信授权时,先生成 state 并缓存业务上下文。
4. 根据授权类型生成业务项目自己的 callbackUrl:
- base -> /auth/wechat/base-callback
- info -> /auth/wechat/info-callback
5. 如果 MOBILE_WECHAT_OAUTH_PROXY_URL 非空,则跳到代理:
proxyUrl?scope=<snsapi_base|snsapi_userinfo>&state=<业务 state>&redirect_uri=<业务 callbackUrl>
6. 如果代理 URL 为空,保持原直连微信逻辑,并按授权类型设置 WeChat provider scope。
7. callback URL 校验默认要求 HTTPS;只有配置了代理 URL 且 MOBILE_WECHAT_PROXY_ALLOW_INSECURE_CALLBACK=true 时允许 HTTP callback。
8. base callback 用 code + secret 换 openid,按 tenant_id + openid 创建/登录会员,签发业务 token。
9. info callback 用 code + secret 换 openid/userinfo,保存 nickname/avatar,签发业务 token。
10. H5 回调 URL 只附带 token/auth,不附带 openid;H5 登录后通过 /bootstrap 或 /me 读取昵称头像。
11. 添加业务项目测试:
- /auth/wechat/info 配置代理时跳到代理
- 代理 URL 中 scope=snsapi_userinfo,redirect_uri 指向 info-callback
- mock 微信用户后,info-callback 保存 openid/nickname/avatar
- 回跳 URL 不包含 openid/nickname/avatar
完成后运行测试,并输出部署所需环境变量、路由和验证 curl 命令。
代理只负责获取微信返回的临时 code,不换取用户信息。换取 openid 需要 secret,这一步仍在业务项目中完成。
业务项目需要用 state 找回登录前上下文,例如校区、租户、H5 回跳页、授权类型等。代理如果替换 state,业务项目 callback 就无法恢复上下文。
微信网页授权回调会访问代理 callback。生产环境中代理域名应配置 HTTPS,并且 APP_URL 应设置为代理 HTTPS 域名,确保生成的微信 redirect_uri 是 HTTPS。
Nginx 通常按 server_name 匹配站点。必须用实际绑定的代理域名访问,例如:
https://wx-pub-api.example.com
而不是直接访问服务器 IP。