## 引子
先看一段看起来很正常的代码:
```php
$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` 表的核心字段:
```sql
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 做的事情非常直接:
```php
$tokenable = $accessToken->tokenable->withAccessToken($accessToken);
return $tokenable;
```
它**不关心 guard 叫什么名字,也不查 provider**,只是把令牌记录里登记的那个「主人」原样返回。
于是同一个 `auth('sanctum')`:
- 管理员签发的令牌 → 返回 `AdminUser` 实例
- 会员签发的令牌 → 返回 `User` 实例
`auth()->id()` 的结果自然也是两种可能。这不是异常分支,而是常态。
## 二、默认配置为什么不管
看到这里你可能会问:Laravel 的 guard 不是有 `provider` 吗?为什么没拦住?
因为 Sanctum 注册 guard 时,**故意**把 provider 设成了 `null`:
```php
config([
'auth.guards.sanctum' => array_merge([
'driver' => 'sanctum',
'provider' => null,
], config('auth.guards.sanctum', [])),
]);
```
而 provider 校验的实现长这样:
```php
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 区分用途:
```php
// 前台会员
$this->createToken($this->username, ['front'])->plainTextToken;
// 后台管理员
$this->createToken($this->username, ['admin'])->plainTextToken;
```
路由上再用 `abilities:front` / `abilities:admin` 挡住,接口层面看起来是隔离的。
问题出在**共享逻辑**上。评论列表的 `UserCommentResource` 同时被三处复用:
- 前台会员的评论列表
- 后台管理员的评论管理列表
- 新增的公开评论接口(游客可访问)
资源类里有这么一段:
```php
$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**。
于是管理员打开后台评论列表时发生了这些事:
1. `auth('sanctum')->id()` 拿到的是**管理员 ID**;
2. 代码把它当作会员 ID,去查 `user_comment_likes.user_id`;
3. 一旦管理员 ID 与某个会员 ID 撞号(两张表各自自增,撞号极其常见),点赞状态就串了;
4. 同理,如果管理员 ID 恰好等于评论作者 ID,`canEdit` 会误判为 `true`;
5. 每条评论还白白多了一次点赞表查询。
这就是「`auth()->id()` 取决于 token 属于谁」最直接的代价。
## 四、更隐蔽的坑:`Auth::check()` 的默认 guard
最初的写法其实不是 `auth('sanctum')`,而是:
```php
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
最小成本的修法是在取值前做一次类型判断:
```php
// 令牌主体可能是管理员,需要区分前台会员 / 管理员
$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`。
```php
// 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`。
## 七、怎么验证
配置写完不要靠猜,直接看应用启动后的最终结果:
```bash
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,是谁的?**