当ThinkPHP 8 遇上 JWT 与管道模式:构建一套防篡改的API认证体系

2026-07-13 0 378

年前接手了一个旧项目——一个给移动端和管理后台提供数据的API系统,用的是ThinkPHP 5.1。每次新增接口都要在构造函数里手动做token校验,跨域处理写在BaseController里,业务逻辑和SQL散落在控制器各处。维护起来非常痛苦,尤其是当token过期需要刷新时,多个控制器的代码都要改。趁着业务升级,我把项目整体迁移到了ThinkPHP 8,并重新设计了认证和架构。这篇文章记录下整个改造过程,重点讲讲中间件管道、JWT 的实际落地以及服务层的剥离。

一、先搭一个干净的起点

用Composer创建TP8项目,指定使用多应用模式会比较灵活。如果只是单一API服务,单应用也可以,但考虑到后面可能会加入后台管理模块,我直接用了多应用结构。

composer create-project topthink/think tp8-api
cd tp8-api
composer require firebase/php-jwt   # 用这个库处理JWT
composer require topthink/think-multi-app  # 多应用扩展

安装完毕后,删除默认的`app/controller`目录,我们在`app`下新建一个`api`应用目录,结构如下:

app/
├─ api/
│  ├─ controller/
│  ├─ middleware/
│  ├─ service/
│  └─ route/
├─ common/
│  └─ model/
└─ ...

其中`common/model`放公共模型,`api/middleware`放自定义中间件,`api/service`放业务逻辑层。路由使用TP8的注解路由,这样控制器和路由绑定在一起,清晰很多。

二、JWT 中间件不是简单的验证逻辑

很多教程里把JWT的签发和验证直接写在控制器或者基类里,这样做虽然能跑,但耦合太重。正确的方式是把JWT验证抽象成一个中间件,并且只负责验证token本身,不处理具体用户信息。用户信息的加载交给另一个独立的进程或者服务。

先定义JWT管理类,放在`app/common/service/JwtService.php`,负责token的生成、验证和刷新。这里我设定了access_token和refresh_token双令牌机制,access_token有效期15分钟,refresh_token有效期7天。

<?php
namespace appcommonservice;

use FirebaseJWTJWT;
use FirebaseJWTKey;
use FirebaseJWTExpiredException;
use thinkfacadeConfig;

class JwtService
{
    protected string $secret;
    protected string $algorithm = 'HS256';
    protected int $accessTtl = 900;      // 15分钟
    protected int $refreshTtl = 604800;  // 7天

    public function __construct()
    {
        $this->secret = Config::get('jwt.secret') ?: 'default_secret_key';
    }

    // 生成access token
    public function createAccessToken(int $userId, array $extra = []): string
    {
        $payload = array_merge([
            'iss' => 'tp8-api',
            'sub' => $userId,
            'iat' => time(),
            'exp' => time() + $this->accessTtl,
            'type' => 'access'
        ], $extra);
        return JWT::encode($payload, $this->secret, $this->algorithm);
    }

    // 生成refresh token(可存储在数据库以便吊销)
    public function createRefreshToken(int $userId): string
    {
        $payload = [
            'iss' => 'tp8-api',
            'sub' => $userId,
            'iat' => time(),
            'exp' => time() + $this->refreshTtl,
            'type' => 'refresh'
        ];
        return JWT::encode($payload, $this->secret, $this->algorithm);
    }

    // 验证token并返回payload
    public function verifyToken(string $token): ?object
    {
        try {
            return JWT::decode($token, new Key($this->secret, $this->algorithm));
        } catch (ExpiredException $e) {
            // 抛出特定异常,让中间件捕获后返回401
            throw new thinkexceptionHttpException(401, 'token已过期');
        } catch (Exception $e) {
            throw new thinkexceptionHttpException(401, 'token无效');
        }
    }

    // 刷新access token
    public function refreshAccessToken(string $refreshToken): string
    {
        $payload = $this->verifyToken($refreshToken);
        if ($payload->type !== 'refresh') {
            throw new thinkexceptionHttpException(400, 'token类型错误');
        }
        return $this->createAccessToken($payload->sub);
    }
}

注意这里我并没有把用户查询逻辑放进去,JwtService只处理令牌本身。接下来写中间件`app/api/middleware/JwtAuth.php`:

<?php
namespace appapimiddleware;

use appcommonserviceJwtService;
use thinkfacadeRequest;
use thinkResponse;

class JwtAuth
{
    public function handle($request, Closure $next)
    {
        $token = Request::header('Authorization', '');
        if (empty($token) || !str_starts_with($token, 'Bearer ')) {
            return json(['code' => 401, 'msg' => '缺少认证令牌'], 401);
        }
        $token = substr($token, 7);
        $jwtService = app(JwtService::class);
        try {
            $payload = $jwtService->verifyToken($token);
        } catch (thinkexceptionHttpException $e) {
            return json(['code' => $e->getCode(), 'msg' => $e->getMessage()], $e->getCode());
        }
        // 将用户ID注入请求上下文,方便后续使用
        Request::macro('userId', function () use ($payload) {
            return $payload->sub;
        });
        return $next($request);
    }
}

这里用到了TP8的`Request::macro`动态宏,可以把userId临时绑定到请求对象上,后面在控制器里直接`request()->userId()`就能拿到,不用再额外查询。中间件注册在`app/api/middleware.php`(应用中间件配置)里,或者直接用注解路由的`middleware`参数绑定。

三、管道模式不止一个中间件

API通常还需要处理跨域、请求日志记录、参数过滤等。TP8的中间件管道可以像剥洋葱一样层层处理。我习惯把跨域中间件放在最外层,JWT验证放在次外层,最后是参数过滤。

跨域中间件很简单,用TP自带的`thinkmiddlewareAllowCrossDomain`就行,但需要结合自己的域名做限制,所以稍微改一下:

<?php
namespace appapimiddleware;

class Cors
{
    public function handle($request, Closure $next)
    {
        $origin = $request->header('Origin', '');
        $allowedOrigins = ['https://myapp.com', 'http://localhost:8080'];
        if (in_array($origin, $allowedOrigins)) {
            header("Access-Control-Allow-Origin: $origin");
            header('Access-Control-Allow-Headers: Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since');
            header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
            header('Access-Control-Max-Age: 86400');
        }
        if ($request->method() === 'OPTIONS') {
            return response('', 204);
        }
        return $next($request);
    }
}

请求日志中间件记录每个请求的耗时和返回状态码,方便排查问题:

<?php
namespace appapimiddleware;

use thinkfacadeLog;
use thinkfacadeRequest;

class RequestLog
{
    public function handle($request, Closure $next)
    {
        $start = microtime(true);
        $response = $next($request);
        $cost = round((microtime(true) - $start) * 1000, 2);
        $logData = [
            'method' => $request->method(),
            'path' => $request->pathinfo(),
            'ip' => $request->ip(),
            'status' => $response->getCode(),
            'time_ms' => $cost,
        ];
        Log::info(json_encode($logData, JSON_UNESCAPED_UNICODE));
        return $response;
    }
}

在路由定义里把这些中间件组合起来。TP8支持在路由文件或注解中给路由组或单个路由指定中间件。比如在`app/api/route/route.php`里:

<?php
use thinkfacadeRoute;

Route::group('v1', function () {
    // 无需认证的接口
    Route::post('login', 'api/Login/login')->middleware([appapimiddlewareCors::class, appapimiddlewareRequestLog::class]);
    Route::post('refresh', 'api/Login/refresh')->middleware([appapimiddlewareCors::class, appapimiddlewareRequestLog::class]);

    // 需要认证的接口
    Route::group(function () {
        Route::get('user/profile', 'api/User/profile');
        Route::put('user/update', 'api/User/update');
    })->middleware([appapimiddlewareCors::class, appapimiddlewareJwtAuth::class, appapimiddlewareRequestLog::class]);
});

这样所有需要认证的接口自动就在JWT保护之下,而且增加了请求日志和跨域处理,层级清晰。

四、控制器只做两件事:接收请求、调用服务

老项目里控制器经常直接操作模型,甚至拼接复杂的查询条件。迁移时我定了一条死规矩:控制器代码不超过20行,所有业务逻辑下沉到Service层。Service层可以互相调用,并且通过依赖注入和容器管理。

例如用户资料接口,控制器`app/api/controller/User.php`:

<?php
namespace appapicontroller;

use appapiserviceUserService;
use thinkresponseJson;

class User
{
    protected UserService $userService;

    public function __construct(UserService $userService)
    {
        $this->userService = $userService;
    }

    public function profile(): Json
    {
        $userId = request()->userId(); // 来自JWT中间件注入
        $data = $this->userService->getProfile($userId);
        return json(['code' => 200, 'data' => $data]);
    }

    public function update(): Json
    {
        $userId = request()->userId();
        $params = request()->only(['nickname', 'avatar', 'email']);
        $this->userService->updateProfile($userId, $params);
        return json(['code' => 200, 'msg' => '更新成功']);
    }
}

服务层`app/api/service/UserService.php`负责具体的查询和业务规则:

<?php
namespace appapiservice;

use appcommonmodelUser as UserModel;
use thinkexceptionValidateException;

class UserService
{
    public function getProfile(int $userId): array
    {
        $user = UserModel::field('id,nickname,avatar,email,created_at')
            ->where('status', 1)
            ->findOrEmpty($userId);
        if ($user->isEmpty()) {
            throw new ValidateException('用户不存在');
        }
        return $user->toArray();
    }

    public function updateProfile(int $userId, array $data): void
    {
        // 简单验证示例
        if (!empty($data['email']) && !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
            throw new ValidateException('邮箱格式不正确');
        }
        UserModel::where('id', $userId)->update($data);
    }
}

这样做有两个明显好处:一是服务层可以独立进行单元测试,不依赖HTTP请求;二是当业务需要复用(比如定时任务也要更新用户资料)时,直接调用`UserService`即可,不用复制代码。

五、统一响应和异常兜底

API需要一致的响应格式,并且所有异常都要被捕获成JSON返回。TP8的全局异常处理在`app/ExceptionHandle.php`里,我把它改成下面这样:

<?php
namespace app;

use thinkdbexceptionDataNotFoundException;
use thinkdbexceptionModelNotFoundException;
use thinkexceptionHandle;
use thinkexceptionHttpException;
use thinkexceptionValidateException;
use thinkResponse;
use Throwable;

class ExceptionHandle extends Handle
{
    public function render($request, Throwable $e): Response
    {
        // 验证器异常
        if ($e instanceof ValidateException) {
            return json(['code' => 422, 'msg' => $e->getMessage()], 422);
        }
        // 模型未找到
        if ($e instanceof ModelNotFoundException) {
            return json(['code' => 404, 'msg' => '资源不存在'], 404);
        }
        // HTTP异常
        if ($e instanceof HttpException) {
            return json(['code' => $e->getStatusCode(), 'msg' => $e->getMessage()], $e->getStatusCode());
        }
        // 其他异常
        return json(['code' => 500, 'msg' => '服务器内部错误'], 500);
    }
}

这样无论哪里抛出异常,客户端都能收到统一结构的JSON,再也不会有HTML错误页面混进来。

六、模型关联别忘了用预加载

模型全部放在`app/common/model`下,因为多个应用可能共享。拿订单和订单明细举例:

<?php
namespace appcommonmodel;

use thinkModel;

class Order extends Model
{
    protected $name = 'orders';
    
    public function items()
    {
        return $this->hasMany(OrderItem::class, 'order_id', 'id');
    }
    
    public function user()
    {
        return $this->belongsTo(User::class, 'user_id', 'id');
    }
}

在服务层查询订单列表时,用`with`预加载避免N+1问题:

public function getUserOrders(int $userId): array
{
    return Order::with(['items', 'user'])
        ->where('user_id', $userId)
        ->order('created_at', 'desc')
        ->select()
        ->toArray();
}

TP8的模型查询非常直观,关联模型也可以加条件约束,比如只查已支付的明细等等。

七、刷新令牌的痛点和解决方案

双令牌机制里,refresh_token一般只在登录和刷新接口使用。我把刷新逻辑也放在JwtService里,但刷新接口本身应该只能由refresh_token调用。这里有一个容易被忽略的安全点:必须验证refresh_token的类型,防止攻击者拿access_token来刷新,导致无限延期。

登录控制器`app/api/controller/Login.php`:

<?php
namespace appapicontroller;

use appcommonserviceJwtService;
use thinkRequest;
use thinkresponseJson;

class Login
{
    public function login(Request $request, JwtService $jwtService): Json
    {
        $username = $request->param('username');
        $password = $request->param('password');
        // 实际应该从数据库验证用户
        if ($username !== 'admin' || $password !== '123456') {
            return json(['code' => 401, 'msg' => '账号或密码错误'], 401);
        }
        $userId = 1; // 假设验证通过后获得用户ID
        $accessToken = $jwtService->createAccessToken($userId);
        $refreshToken = $jwtService->createRefreshToken($userId);
        return json([
            'code' => 200,
            'data' => [
                'access_token' => $accessToken,
                'refresh_token' => $refreshToken,
                'expires_in' => 900
            ]
        ]);
    }

    public function refresh(Request $request, JwtService $jwtService): Json
    {
        $refreshToken = $request->param('refresh_token', '');
        if (empty($refreshToken)) {
            return json(['code' => 400, 'msg' => 'refresh_token不能为空'], 400);
        }
        try {
            $newAccessToken = $jwtService->refreshAccessToken($refreshToken);
        } catch (thinkexceptionHttpException $e) {
            return json(['code' => $e->getCode(), 'msg' => $e->getMessage()], $e->getCode());
        }
        return json(['code' => 200, 'data' => ['access_token' => $newAccessToken, 'expires_in' => 900]]);
    }
}

刷新接口仅需要`refresh_token`,不经过JwtAuth中间件,但要经过Cors和RequestLog。这样整个认证流程就完整闭环了。

八、写在最后

整个迁移过程花了大约三个星期,但带来的收益立竿见影。中间件管道让认证、日志、跨域这些横切关注点彻底分离,控制器变得像乐高说明书一样简单。服务层的加入让代码复用率大幅提升,而且因为依赖注入的关系,单元测试覆盖率终于上了70%。

ThinkPHP 8 相比旧版本在架构灵活性和性能上都有明显进步,特别是容器的自动绑定和注解路由,非常适合中型API项目的快速开发。如果你也在做类似的升级,建议不要一次性推翻,可以选一个小模块按照这个结构先试点,跑通后再逐步替换。安全无小事,JWT的使用一定要结合业务做细颗粒度的控制,别怕多写几行校验代码。

完整的代码我已经整理在本地仓库里,读者可以按照上面的步骤自己搭建,遇到问题欢迎在技术社区交流。希望这套中间件管道+服务层的架子能帮你少踩几个坑。

当ThinkPHP 8 遇上 JWT 与管道模式:构建一套防篡改的API认证体系
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp 当ThinkPHP 8 遇上 JWT 与管道模式:构建一套防篡改的API认证体系 https://www.taomawang.com/server/thinkphp/2359.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务