引子
先看一段看起来很正常的代码:
$userId = auth('sanctum')->id();
if ($userId) {
$res['isLiked'] = $this->likes()
->where('user_id', $userId)
->exists();
}
语义清晰:拿到当前登录用户,判断他是否给这条评论点过赞。
但如果你的系统里同时存在「前台会员」和「后台管理员」两套认证主体,这一行就是一个定时炸弹。因为——
auth()->id() 返回的不是「会员 ID」,而是「令牌主人」的 ID。令牌可能是管理员的,也可能是会员的,两者都可能。这篇文章记录一次真实的排查过程:问题怎么产生的、Sanctum 为什么这么设计、以及如何从治标到治本。
一、Sanctum 的令牌不属于 guard,属于某个模型
先看 personal_access_tokens 表的核心字段:
create table personal_access_tokens (
id bigint unsigned not null auto_increment primary key,
tokenable_type varchar(255) not null,
tokenable_id bigint unsigned not null,
name varchar(255) not null,
token varchar(64) not null,
abilities text null,
...
);
tokenable_type + tokenable_id 是一组多态关联。也就是说,Sanctum 从一开始就假设「令牌可以属于任意模型」。
认证时,Guard 做的事情非常直接:
$tokenable = $accessToken->tokenable->withAccessToken($accessToken);
return $tokenable;
它不关心 guard 叫什么名字,也不查 provider,只是把令牌记录里登记的那个「主人」原样返回。
于是同一个 auth('sanctum'):
- 管理员签发的令牌 → 返回
AdminUser实例 - 会员签发的令牌 → 返回
User实例
auth()->id() 的结果自然也是两种可能。这不是异常分支,而是常态。
二、默认配置为什么不管
看到这里你可能会问:Laravel 的 guard 不是有 provider 吗?为什么没拦住?
因为 Sanctum 注册 guard 时,故意把 provider 设成了 null:
config([
'auth.guards.sanctum' => array_merge([
'driver' => 'sanctum',
'provider' => null,
], config('auth.guards.sanctum', [])),
]);
而 provider 校验的实现长这样:
protected function hasValidProvider($tokenable)
{
if (is_null($this->provider)) {
return true;
}
$model = config("auth.providers.{$this->provider}.model");
return $tokenable instanceof $model;
}
provider 为 null 时直接放行。
换句话说:「一个 guard 认证出多种用户类型」是 Sanctum 的刻意设计,不是 bug。 它认为 token 表已经用多态区分了主体,没必要再用 provider 过滤一遍。
这个设计在「只有一套用户体系」的应用里毫无问题,但一旦有多套,隐患就埋下了。
三、踩坑现场
项目背景很简单:
Modules\Ucenter\Models\User—— 前台会员Modules\Admin\Models\AdminUser—— 后台管理员
两者各自签发令牌,用 abilities 区分用途:
// 前台会员
$this->createToken($this->username, ['front'])->plainTextToken;
// 后台管理员
$this->createToken($this->username, ['admin'])->plainTextToken;
路由上再用 abilities:front / abilities:admin 挡住,接口层面看起来是隔离的。
问题出在共享逻辑上。评论列表的 UserCommentResource 同时被三处复用:
- 前台会员的评论列表
- 后台管理员的评论管理列表
- 新增的公开评论接口(游客可访问)
资源类里有这么一段:
$userId = auth('sanctum')->id();
if ($userId) {
$res['isLiked'] = $this->likes()
->where('user_id', $userId)
->exists();
}
$res['canEdit'] = $userId && $userId === $this->user_id;
而 user_comment_likes.user_id、user_comments.user_id 存的都是会员 ID。
于是管理员打开后台评论列表时发生了这些事:
auth('sanctum')->id()拿到的是管理员 ID;- 代码把它当作会员 ID,去查
user_comment_likes.user_id; - 一旦管理员 ID 与某个会员 ID 撞号(两张表各自自增,撞号极其常见),点赞状态就串了;
- 同理,如果管理员 ID 恰好等于评论作者 ID,
canEdit会误判为true; - 每条评论还白白多了一次点赞表查询。
这就是「auth()->id() 取决于 token 属于谁」最直接的代价。
四、更隐蔽的坑:Auth::check() 的默认 guard
最初的写法其实不是 auth('sanctum'),而是:
if (Auth::check()) {
$res['isLiked'] = $this->likes()
->where('user_id', Auth::id())
->exists();
}
Auth::check() 使用的是默认 guard。项目里默认 guard 是 front(session driver),而 API 请求带的是 Bearer token,默认 guard 根本认不出来。
那它为什么在某些场景「看起来正常」?因为 auth:sanctum 中间件认证成功后会调用 shouldUse('sanctum'),把默认 guard 临时切换过去。一旦路由没有挂这个中间件——比如游客可访问的公开接口——它立刻失效,登录用户也拿不到点赞状态。
结论:不要依赖 Auth::xxx() 的隐式 guard,显式写出你要用的 guard。
五、治标:先问类型,再取 ID
最小成本的修法是在取值前做一次类型判断:
// 令牌主体可能是管理员,需要区分前台会员 / 管理员
$authUser = auth('sanctum')->user();
$userId = $authUser instanceof User ? $authUser->id : null;
if ($userId) {
$res['isLiked'] = $res['isLiked'] ?? $this->likes()
->where('user_id', $userId)
->exists();
} else {
// 非会员令牌不得返回点赞状态,避免管理员 ID 与会员 ID 撞号造成误判
unset($res['isLiked']);
}
$res['canEdit'] = $userId !== null && $userId === $this->user_id;
几个细节值得注意:
User与AdminUser都直接继承Authenticatable,没有继承关系,instanceof User能准确区分;- 非会员一律得到
null,后续查询自然跳过; - 末尾的
unset是兜底——即使上游错误地把管理员 ID 传进了预加载,也不会把错误状态透给前端; - 不要用「未登录就抛异常」的辅助函数(比如项目里的
frontUser()),公开接口不能接受。
这个修法足够正确,但它是防御性的,依赖每个调用点都记得判断。
六、治本:给 guard 绑定 provider
Sanctum 本身就提供了隔离机制,只是默认没启用:给每个 guard 绑定 provider。
// config/auth.php
'guards' => [
'front' => [
'driver' => 'session',
'provider' => 'users',
],
'admin' => [
'driver' => 'session',
'provider' => 'admins',
],
// API 令牌守卫:绑定 provider 后,Sanctum 会校验令牌主体类型
'sanctum' => [
'driver' => 'sanctum',
'provider' => 'users',
],
'sanctum_admin' => [
'driver' => 'sanctum',
'provider' => 'admins',
],
],
配置后:
auth('sanctum')只接受User,管理员令牌直接返回null;auth('sanctum_admin')只接受AdminUser;- 类型不符时,认证阶段就失败,返回 401。
很多人担心「再定义一个 sanctum 会不会和 Sanctum 自动注入的冲突」。不会。前面那行 array_merge(默认值, config('auth.guards.sanctum', [])) 里,你的配置在后,会覆盖默认的 provider => null。而两个 guard 共用 driver => 'sanctum' 也完全没问题——就像 front / admin 两个 guard 本来就共用 session driver 一样,各自是独立的 guard 实例。
改造时容易漏的地方:
- 后台路由的
auth:sanctum全部换成auth:sanctum_admin; - 登录页守卫
guest:sanctum换成guest:sanctum_admin; - 广播鉴权
Broadcast::routes(['middleware' => ['auth:sanctum', ...]]); - 后台代码里硬编码的
auth('sanctum')->id(); - 测试里的
Sanctum::actingAs($user, ['admin'])——第三个参数是 guard 名,默认值是sanctum,后台用例必须显式传sanctum_admin。
七、怎么验证
配置写完不要靠猜,直接看应用启动后的最终结果:
php artisan config:show auth.guards
只要输出里 sanctum.provider 是 users、sanctum_admin.provider 是 admins,合并就成功了。
再补一条安全边界测试,用真实令牌(而不是 actingAs,因为它会绕过 provider 校验):
- 会员令牌请求后台接口 → 401
- 管理员令牌请求前台接口 → 401
八、小结
- Sanctum 的令牌是多态的,
auth()->id()返回的是「令牌主人」的 ID,跟 guard 名字无关;令牌是谁签发的,它就可能返回谁。 - 当系统里存在多套认证模型时,任何共享的用户相关逻辑,都必须先确认主体类型再使用 ID。
Auth::check()/Auth::id()依赖默认 guard,在纯 token 场景下不可靠,应显式指定 guard。- 治标:
instanceof判断类型;治本:给 guard 绑定provider做认证层隔离。 abilities管的是「你能做什么」,provider管的是「你是谁」。两者不能互相替代。
最后一句留给未来的自己:在看到 auth()->id() 的时候,先问一句——这个 token,是谁的?