跳到主要内容

`auth()->id()` 到底返回谁?Sanctum 多态令牌与双会员系统的踩坑记录

管

管理员

flutter

2026年10月31日 1 次阅读
本页目录

## 引子


先看一段看起来很正常的代码:


```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,是谁的?**

Related

相关文章

同分类文章 →
2026年07月28日 flutter

Dart 3 记录(Records)和密封类(Sealed classes)

Dart 3 引入了几个极其强大的语言特性,其中最耀眼、对我们日常开发改变最大的就是 记录(Records) 和 密封类(Sealed classes),它们配合 模式匹配(Pattern Matching),让 Dart 的表现力达到了现代编程语言(如 Swift、Kotlin、Rust

2026年06月29日 flutter

flutter的 Clean Architecture架构设计

Clean Architecture(清晰架构)是一种将应用程序分层的设计模式,强调关注点分离与依赖规则,通过将业务逻辑与框架、UI、数据源解耦,提升代码的可测试性、可维护性与可扩展性。在 Flutter/Dart 环境下,Clean Architecture 通

2025年12月22日 flutter

Riverpod 实战:如何优雅地实现网络请求的防抖与取消

在开发复杂的应用程序时,管理网络请求的生命周期至关重要。例如,用户在搜索框快速输入时可能会触发大量请求,或者在请求完成前离开页面,导致不必要的资源浪费。本文将介绍如何在 Riverpod 中利用 ref.onDispose 和“

WeChat

扫码加我微信

微信二维码