`。 * 为什么不复用 DAV 那套口令:那套是**给第三方同步客户端**的、明文要手打进手机; * API 令牌是给自家 App 的,需要**设备名 + 有效期 + 可逐个吊销**, * 生命周期与用途都不同(DAV 那把一吊销,日历同步也跟着断)。 * * ⚠️ **令牌只在签发的这一次返回明文**(库里只存 sha256)。 * 与 DAV 口令同一取舍:明文落库一旦泄露就全暴露;不返回用户又永远看不到。 * ⇒ 网页端把明文塞进一次性 session 槽位、显示一次;App 端它自己存 Keychain。 */ declare(strict_types=1); namespace App; final class ApiToken { /** 令牌前缀:让日志/配置里一眼能认出"这是家书的 API 令牌"(GitHub 的 ghp_ 同理) */ public const PREFIX = 'fb_'; /** * 签发一枚新令牌,返回**明文**(调用方负责立刻交给用户,之后再也拿不到)。 * * ⚠️ 用 `random_bytes` 而不是 `random_int` 循环拼短字符集: * 令牌是机器读的(不像 DAV 口令要手打),长度与熵优先。 * 32 字节 → base64url 43 字符 → 约 256 bit 熵。 */ public static function issue(int $personId, string $label = '', ?string $expiresAt = null): string { $plain = self::PREFIX . self::b64url(random_bytes(32)); Database::get()->prepare( 'INSERT INTO api_tokens (person_id, token_hash, label, expires_at) VALUES (?,?,?,?)' )->execute([ $personId, hash('sha256', $plain), $label !== '' ? mb_substr($label, 0, 60) : null, $expiresAt !== '' ? $expiresAt : null, ]); return $plain; } /** * 校验一枚令牌,成功返回 [personId, tokenId],失败 null。 * * ⚠️ 只认 `Bearer` 头 —— 不接 query string(URL 会进日志/浏览器历史, * 令牌出现在 URL 里等于半公开)。DAV 那套用 Basic 是 RFC 定的,没得选; * API 是我们自己定的,就选更安全的那个。 * ⚠️ 逐字节 hash 之后查库(不先解密/比对),配合唯一索引 —— 不走时序分支。 * ⚠️ 过期/被吊销一律返回 null(不给"为什么不行"的细粒度错误, * 避免给爆破者提供信息;具体原因网页端能看到)。 */ public static function verify(string $plain): ?array { $plain = trim($plain); if ($plain === '' || strpos($plain, self::PREFIX) !== 0) { return null; } $st = Database::get()->prepare( 'SELECT t.id, t.person_id, a.status FROM api_tokens t JOIN accounts a ON a.person_id = t.person_id WHERE t.token_hash = ? AND (t.expires_at IS NULL OR t.expires_at > NOW()) LIMIT 1' ); $st->execute([hash('sha256', $plain)]); $row = $st->fetch(); if (!$row || (string)$row['status'] !== 'active') { return null; } // 记一次使用时间(不给它加索引/不加事务:这是"顺带的统计",不值得拖慢鉴权) try { Database::get()->prepare('UPDATE api_tokens SET last_used_at = NOW() WHERE id = ?') ->execute([(int)$row['id']]); } catch (\Throwable $e) { // 记不上不该拦住这次请求 } return [(int)$row['person_id'], (int)$row['id']]; } /** 某个人的令牌清单(**不含明文**,只给管理界面看) */ public static function listFor(int $personId): array { $st = Database::get()->prepare( 'SELECT id, label, created_at, last_used_at, expires_at FROM api_tokens WHERE person_id = ? ORDER BY id DESC' ); $st->execute([$personId]); return $st->fetchAll(); } /** 吊销一枚(限自己的) */ public static function revoke(int $personId, int $id): bool { $st = Database::get()->prepare('DELETE FROM api_tokens WHERE id = ? AND person_id = ?'); $st->execute([$id, $personId]); return $st->rowCount() > 0; } /** 吊销自己的全部("手机丢了"时的应急动作) */ public static function revokeAll(int $personId): int { $st = Database::get()->prepare('DELETE FROM api_tokens WHERE person_id = ?'); $st->execute([$personId]); return $st->rowCount(); } private static function b64url(string $raw): string { return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); } }