Skip to content

使用 NENE2 构建访问令牌管理

本指南介绍如何构建个人访问令牌(PAT)系统——用户可以签发、列出和撤销自己的 API 令牌,每个令牌具有一个权限范围(read/write/admin)。令牌不以明文存储,只保存其 SHA-256 哈希值。

Field Trial:FT136
NENE2 版本:^1.5
涵盖主题:令牌哈希、权限范围枚举、所有权验证、撤销幂等性、验证端点


我们要构建的内容

  • POST /users/{id}/tokens — 签发令牌(仅限所有者,原始令牌只返回一次)
  • GET /users/{id}/tokens — 列出令牌(仅限所有者,响应中不包含原始令牌)
  • DELETE /users/{id}/tokens/{tokenId} — 撤销令牌(仅限所有者,已撤销时返回 409)
  • POST /tokens/verify — 验证原始令牌(返回有效/无效及权限范围)

数据库结构

sql
CREATE TABLE tokens (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL,
    token_hash TEXT    NOT NULL UNIQUE,
    scope      TEXT    NOT NULL DEFAULT 'read',
    label      TEXT    NOT NULL DEFAULT '',
    created_at TEXT    NOT NULL,
    revoked_at TEXT,
    FOREIGN KEY (user_id) REFERENCES users(id),
    CHECK (scope IN ('read', 'write', 'admin'))
);
  • token_hash — 原始令牌的 SHA-256 哈希值;不存储原始令牌
  • revoked_at — 可为空的时间戳;NULL 表示有效,非空表示已撤销
  • CHECK (scope IN (...)) — 数据库层面的权限范围约束,作为纵深防御

令牌权限范围枚举

php
enum TokenScope: string
{
    case Read  = 'read';
    case Write = 'write';
    case Admin = 'admin';
}

TokenScope::tryFrom($value) 对未知权限范围返回 null——在存储前使用此方法验证输入。


签发令牌

php
public function issueToken(int $userId, TokenScope $scope, string $label, string $now): string
{
    $raw  = bin2hex(random_bytes(32)); // 64 字符十六进制字符串
    $hash = hash('sha256', $raw);

    $this->executor->execute(
        'INSERT INTO tokens (user_id, token_hash, scope, label, created_at) VALUES (?, ?, ?, ?, ?)',
        [$userId, $hash, $scope->value, $label, $now],
    );

    return $raw; // 只返回一次,不存储
}

原始令牌只返回给调用方一次。此后数据库中只保存哈希值——无法恢复原始令牌。


验证令牌

php
public function verifyToken(string $rawToken): ?array
{
    $hash = hash('sha256', $rawToken);
    $row  = $this->executor->fetchOne(
        'SELECT id, user_id, scope, revoked_at FROM tokens WHERE token_hash = ?',
        [$hash],
    );

    if ($row === null) {
        return null;
    }

    $arr = (array) $row;

    return [
        'valid'   => !isset($arr['revoked_at']),
        'user_id' => isset($arr['user_id']) ? (int) $arr['user_id'] : 0,
        'scope'   => isset($arr['scope']) && is_string($arr['scope']) ? $arr['scope'] : 'read',
    ];
}

为什么使用 !isset($arr['revoked_at']) 而不是 === nullisset() 返回 true 后,PHPStan 会从类型中排除 null——与 null 比较会被报告为 identical.alwaysFalse。单独使用 isset() 来检查 null。

验证端点对未知或已撤销的令牌始终返回 200 并附带 { "valid": false }——绝不返回 404。这可以防止令牌枚举攻击。


所有权验证

每个变更端点都会验证经过认证的操作者与资源所有者是否匹配:

php
$actorId = $this->resolveActorId($request); // 来自 X-User-Id 请求头

if ($actorId !== $userId) {
    return $this->responseFactory->create(['error' => 'forbidden'], 403);
}

对于撤销操作,还需对令牌本身进行第二次所有权验证:

php
$token = $this->repo->findTokenById($tokenId);

if ($token['user_id'] !== $userId) {
    return $this->responseFactory->create(['error' => 'forbidden'], 403);
}

这可以防止 ATK-04——Bob 使用自己的用户路径但撤销 Alice 的令牌 ID。


撤销——已撤销时返回 409

php
public function revokeToken(int $tokenId, string $now): bool
{
    $count = $this->executor->execute(
        'UPDATE tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL',
        [$now, $tokenId],
    );

    return $count > 0;
}

WHERE revoked_at IS NULL 条件确保如果令牌已被撤销,UPDATE 不会产生任何效果。处理器将 $count === 0 映射为 409 Conflict。


列出令牌——不包含原始令牌

列表响应包含 idscopelabelcreated_atrevoked(布尔值)。原始令牌在初始签发调用后不再返回。


PHPStan level 8 陷阱:isset 与 null 比较

php
// 错误——PHPStan 报告 `notIdentical.alwaysTrue`
'revoked' => isset($arr['revoked_at']) && $arr['revoked_at'] !== null,

// 正确——isset() 已经隐含非空
'revoked' => isset($arr['revoked_at']),

// 错误——PHPStan 报告 `identical.alwaysFalse`
'valid' => !isset($arr['revoked_at']) || $arr['revoked_at'] === null,

// 正确
'valid' => !isset($arr['revoked_at']),

破解攻击测试结果(FT136)

攻击预期结果实际结果
ATK-01:为其他用户签发令牌(IDOR)403通过
ATK-02:列出其他用户的令牌(IDOR)403通过
ATK-03:通过对方路径撤销其令牌403通过
ATK-04:通过自己路径撤销对方令牌403通过
ATK-05:无效权限范围(superuser422通过
ATK-06:使用已撤销令牌进行验证valid=false通过
ATK-07:暴力破解随机令牌valid=false通过
ATK-08:验证请求体中的 SQL 注入valid=false通过
ATK-09:非数字 X-User-Id(admin非 201通过
ATK-10:负数用户 ID404通过
ATK-11:10KB 的权限范围字符串422通过
ATK-12:空或仅含空白字符的令牌422通过

全部 12 项攻击测试通过。


常见陷阱

陷阱修复方法
isset($x) && $x !== null单独使用 isset($x)——PHPStan level 8 会拒绝冗余检查
在数据库中存储原始令牌只存储 hash('sha256', $raw)
在列表响应中返回原始令牌只在签发响应中返回原始令牌
撤销时不检查令牌所有权找到令牌后检查 token['user_id'] === userId
验证时对无效令牌返回 404始终返回 200 并附带 valid: false——防止枚举攻击

基于 MIT 许可证发布。