返回博客

FastAdmin noNeedLogin 配了 ['*'] 还是 401,元凶是 new 了另一个 Controller

一个 FastAdmin(ThinkPHP 5)接口,$noNeedLogin = ['*'] 写得清清楚楚,PHP-FPM 也重载了,Nginx 没有 auth_basic,在同目录建了一个空白探针 Controller 返回 200——但这个接口就是稳定吐 401。

找了半小时,所有"显而易见"的排查点都是正常的,只有这一个 action 例外。

现象

HTTP 401
{"code":401,"msg":"请登录后操作","time":"...","data":null}

对比实验:把 action 里的业务逻辑全注释掉,直接 $this->success('ok'),立刻返回 200。业务逻辑一恢复,又 401。

根因

FastAdmin 的基类 application/common/controller/Api.php 的构造函数无条件调用 _initialize()

public function __construct(Request $request = null)
{
    $this->request = is_null($request) ? Request::instance() : $request;
    $this->_initialize();   // ← 无论什么情况都会跑
}

_initialize() 里会做 token 鉴权:

if (!$this->auth->match($this->noNeedLogin)) {
    $this->auth->init($token);
    if (!$this->auth->isLogin()) {
        $this->error(__('Please login first'), null, 401);  // → throw HttpResponseException
    }
}

关键在于:$this->noNeedLogin 解析的是new 出来的那个对象的类,而不是外层调用方的类。

问题代码长这样:

class DebugTask extends Api
{
    protected $noNeedLogin = ['*'];   // 外层免登录
 
    public function run()
    {
        $sc = new BusinessController();   // ← 触发器
        $result = $sc->someMethod(...);
        $this->success('ok', $result);
    }
}

BusinessController 继承 Api,默认 $noNeedLogin = [](需要登录)。new BusinessController() 一执行,Api::__construct 就跑了,_initialize 拿着 [] 做鉴权,发现当前请求没有 token,于是 throw new HttpResponseException

这个异常直接被 ThinkPHP 框架层捕获并转成 Response 发出去——外层的 DebugTask::run() 根本没有机会继续执行,仿佛外层的 ['*'] 被"绕过"了。

坑:每次 new 一个 Api 子类,等于过了一道完整的鉴权门

FastAdmin 里所有继承 Api 的 Controller,实例化时都会在 __construct 里重新走一遍 _initialize 鉴权。你的外层 Controller 放了多少白名单都没用——只要 new 了一个要求登录的 Api 子类,请求就会在那里被截断,抛出 HttpResponseException,并由框架直接吐 401 给客户端。

这个行为不是 FastAdmin 的 bug,而是它的设计——但几乎所有人第一次遇到时都不会往这里想。

正解

方案一:把可复用逻辑提取成 Service(推荐)

Controller 不是代码复用单元,Service 才是。把业务方法移到一个普通 PHP 类里,没有继承 Api,也就没有 _initialize

// application/service/BusinessService.php
namespace app\service;
 
class BusinessService
{
    public static function compute(string $input): array
    {
        // 纯逻辑,不依赖 Api、不依赖 $this->auth
        return [...];
    }
}
 
// DebugTask.php
public function run()
{
    $result = \app\service\BusinessService::compute($input);
    $this->success('ok', $result);
}

方案二:把目标方法改成 static(快速修复)

如果方法不依赖实例状态,加一个 static 关键字,直接类方法调用,不 new,不触发构造函数:

// BusinessController.php
public static function someMethod(string $input): array { ... }
 
// DebugTask.php
$result = \app\api\controller\BusinessController::someMethod($input);

方案三:ReflectionClass::newInstanceWithoutConstructor(应急)

来不及重构时用反射跳过构造函数。注意目标方法里如果依赖 $this->auth / $this->request 会是 null:

$ref = new \ReflectionClass(\app\api\controller\BusinessController::class);
$obj = $ref->newInstanceWithoutConstructor();   // 跳过 __construct,跳过 _initialize
$result = $obj->someMethod($input);
正解:Controller 不是服务层,不要 new Controller 来复用逻辑

在 FastAdmin / ThinkPHP 5 里,复用业务逻辑的正确位置是 application/service/ 下的 Service 类,或把方法标为 static 直接调用。绝对不要 new 另一个 Api 子类来"借用"它的方法——那等于在你的 action 里埋了一个隐式的鉴权检查点。

排查捷径

怀疑某个接口有这个问题时,30 秒可以复现:临时注释掉 action 里所有的 new XxxController(),如果立刻从 401 变 200,根因确认。

批量排查存量代码:

grep -rn 'new.*Controller(' application/api/controller/

找到的结果如果所在文件里有 $noNeedLogin = ['*'],就是潜在的 401 炸弹。

一句话外卖

FastAdmin 里每一次 new Api子类() 都是一道独立的鉴权门——它检查的是被实例化那个类自己的 $noNeedLogin,而不是调用方的。