您的当前位置:首页>全部文章>文章详情

【Webman+MySQL答题系统九】登录鉴权:HMAC Token 与中间件

果子发表于:2026-10-05 20:55:47浏览:1次TAG: #PHP #MySql #答题系统

本篇目标

实现登录与接口鉴权:password_verify 校验、HMAC 签名 token 的签发与验证、中间件统一拦截,替换掉前面写死的 user_id=1。

第九篇封面

一、鉴权流程

登录与鉴权流程图

不引入第三方 JWT 库,手写一个"载荷 + HMAC 签名"的两段式 token,原理与 JWT 完全同构,看得见摸得着:

二、登录接口

<?php
namespace app\controller;

use support\Request;
use support\Db;
use app\service\Auth;

class AuthController
{
    public function login(Request $request)
    {
        $username = trim((string)$request->post('username'));
        $password = (string)$request->post('password');

        $user = Db::table('user')->where('username', $username)->first();
        if (!$user || !password_verify($password, $user->password)) {
            return error('用户名或密码错误', 401);
        }

        return success([
            'token'    => Auth::issue((int)$user->id),
            'nickname' => $user->nickname,
        ]);
    }
}

三、token 服务

app/service/Auth.php:

<?php
namespace app\service;

class Auth
{
    // 签发:7 天有效
    public static function issue(int $uid): string
    {
        $payload = self::b64(json_encode([
            'uid' => $uid,
            'exp' => time() + 86400 * 7,
        ]));
        return $payload . '.' . self::sign($payload);
    }

    // 校验:通过返回 uid,失败返回 null
    public static function verify(string $token): ?int
    {
        $parts = explode('.', $token);
        if (count($parts) !== 2) {
            return null;
        }
        [$payload, $sign] = $parts;
        if (self::sign($payload) !== $sign) {        // 签名防篡改
            return null;
        }
        $data = json_decode(self::b64($payload, true), true);
        if (!$data || ($data['exp'] ?? 0) < time()) { // 过期检查
            return null;
        }
        return (int)$data['uid'];
    }

    private static function sign(string $payload): string
    {
        return hash_hmac('sha256', $payload, env('APP_SECRET', 'quiz-secret'));
    }

    private static function b64(string $s, bool $decode = false)
    {
        return $decode ? base64_decode(strtr($s, '-_', '+/')) 
                       : rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
    }
}

安全要点:

  • 密钥从 .env 读(APP_SECRET=换成随机长串),绝不硬编码进 Git;
  • hash_equals 替换 !== 可进一步防时序攻击,安全要求高的项目建议替换;
  • stateless token 无法主动吊销,需要"强制下线"能力就加 redis 黑名单,按需取舍。

四、鉴权中间件

app/middleware/AuthCheck.php:

<?php
namespace app\middleware;

use support\Request;
use support\Response;
use Webman\MiddlewareInterface;
use app\service\Auth;

class AuthCheck implements MiddlewareInterface
{
    public function process(Request $request, callable $handler): Response
    {
        $token = preg_replace('/^Bearer\s+/i', '', $request->header('authorization', ''));
        $uid   = Auth::verify($token);
        if (!$uid) {
            return json(['code' => 401, 'msg' => '请先登录']);
        }
        $request->uid = $uid;          // 后续控制器直接 $request->uid
        return $handler($request);
    }
}

五、挂载到路由分组

use app\middleware\AuthCheck;
use app\controller\AuthController;

Route::post('/api/auth/login', [AuthController::class, 'login']);

Route::group('/api', function () {
    // 答题、统计等全部业务路由……
})->middleware([AuthCheck::class]);

然后把各控制器里的写死值替换掉:

// AnswerController::submit / StatController 等
$userId = (int)($request->uid ?? 1);

六、验证

// 1. 登录拿 token
curl -X POST http://127.0.0.1:8787/api/auth/login -d "username=zhangsan" -d "password=123456"
// {"code":0,"data":{"token":"eyJ...xxx","nickname":"张三"}}

// 2. 不带 token 访问受保护接口
curl "http://127.0.0.1:8787/api/stat/my_records"
// {"code":401,"msg":"请先登录"}

// 3. 带 token 访问
curl "http://127.0.0.1:8787/api/stat/my_records" -H "Authorization: Bearer eyJ...xxx"
// {"code":0,...}

小结

登录、签发、校验、中间件拦截全部就位,user_id 不再写死。最后一篇做前后端联调,把整套系统跑起来。