跳到主要内容

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

管

管理员

flutter

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

引子

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

$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。

于是管理员打开后台评论列表时发生了这些事:

  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'),而是:

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


相关

相关文章

同分类文章 →
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 和“

微信

扫码加我微信

微信二维码