Mercure 的 topic 权限控制必须由 PHP 应用层强制实现,FrankenPHP 内置 Hub 不校验 topic 所有权,需在路由中解析 $_GET['topic'] 并比对用户身份,校验失败即返回 403,不可依赖前端隐藏路径。

Mercure 的 topic 权限控制必须由 PHP 应用层强制实现
FrankenPHP 内置的 Mercure 服务本身不校验 topic 所有权,topic 字符串只是路由标识,订阅 /api/user/123/orders 并不会自动限制只有用户 123 能订。越权风险完全取决于你是否在 PHP 逻辑里拦截并验证请求身份。
- 所有 Mercure 订阅请求(即前端发起的
new EventSource('/.well-known/mercure?topic=...'))最终都会作为 HTTP GET 请求落到你的 PHP 路由上 - FrankenPHP 默认把
/.well-known/mercure交给内置 Mercure Hub 处理,但你可以用自定义中间件或路由提前拦截 - 关键动作:在 Mercure Hub 接收请求前,必须用 PHP 解析
$_GET['topic'],比对当前登录用户的 ID、JWT 声明或 session 权限 - 若校验失败,直接返回
403 Forbidden或重定向到错误页,**不能只靠前端隐藏 topic 路径**
用 Laravel + FrankenPHP 实现动态 topic 白名单
Laravel 的 Mercure 集成依赖 symfony/mercure-bundle,但 FrankenPHP 不走 Symfony HTTP Kernel,所以得绕过 bundle,手动控制 topic 注册逻辑。
- 在路由中捕获 Mercure 订阅请求:
Route::get('/.well-known/mercure', [MercureController::class, 'subscribe']) -
MercureController::subscribe()方法里先调用Auth::user()获取当前上下文 - 解析
$_GET['topic'],例如匹配正则/^\/api\/user\/(\d+)\/.*/,提取用户 ID - 比对
Auth::id()和提取出的 ID,不一致就abort(403) - 校验通过后,才允许转发给 FrankenPHP 的 Mercure Hub(可通过
header('X-Mercure-Topic: ...')或直接透传)
避免用硬编码 topic 路径暴露权限边界
常见错误是把敏感 topic 写死在前端代码里,比如 topic: '/api/user/{{ $id }}/notifications' —— 这等于把权限判断逻辑下放到不可信环境。
- 前端应只请求一个泛型 endpoint,如
/.well-known/mercure?channel=user,具体 topic 由后端根据 session 动态生成并注入响应头 - 不要在 JS 中拼接用户 ID 到 topic 字符串,防止用户篡改 URL 直接订阅他人 topic
- 如果必须支持多 topic,用 base64 编码 + 签名方式生成临时 topic token,PHP 端验证签名后再解码真实 topic
- FrankenPHP 的
mercure.publish函数接收的是原始 topic 字符串,它不做任何解析,所以签名验证必须在调用前完成
HTTPS + JWT bearer 是最简可行的身份锚点
FrankenPHP 自动提供 HTTPS,但 Mercure 订阅请求默认不携带认证凭证。浏览器的 EventSource 不会自动发送 Cookie 或 Authorization header,必须显式配置。
立即学习“PHP免费学习笔记(深入)”;
- 前端创建 EventSource 时需设置
withCredentials: true,才能让浏览器附带 Cookie - 或者改用
fetch先获取带 JWT 的订阅令牌,再用该 token 初始化 EventSource(需后端提供/api/mercure-token接口) - JWT 中必须包含用户 ID 和过期时间,并在 PHP 订阅入口处用
firebase/php-jwt验证签名和exp字段 - 注意:FrankenPHP 的 Mercure Hub 不解析 JWT,token 校验纯属应用层责任
if ($userId !== $topicUserId) abort(403) 才真正决定你有没有被越权订阅。漏掉这步,再快的推送也是裸奔。



















