119 lines
5.1 KiB
PHP
119 lines
5.1 KiB
PHP
<?php
|
||||
|
|
/**
|
|||
|
|
* 移动端 / 第三方调用的 **API 令牌**(v44,用户 2026-10-12 第 6 条)。
|
|||
|
|
*
|
|||
|
|
* 用户原话:「我们现在马上要开始移动终端的开发,所以现在要开发所有接口。」
|
|||
|
|
*
|
|||
|
|
* 与既有两套凭据的关系(**三套分开、互不代用**):
|
|||
|
|
* · 登录口令(accounts.password_hash)—— 网页用,走 session;
|
|||
|
|
* · DAV 口令(dav_credentials)—— CalDAV/CardDAV 客户端用,走 Basic;
|
|||
|
|
* · **API 令牌(本类)** —— 移动端 App 用,走 `Authorization: Bearer <token>`。
|
|||
|
|
* 为什么不复用 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), '+/', '-_'), '=');
|
|||
|
|
}
|
|||
|
|
}
|