Files
FamilyBook/app/ApiToken.php
T

419 lines
19 KiB
PHP
Raw Normal View History

<?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。
*
* ===================== v57:一把令牌 = 一次「设备会话」(2026-10-17)=====================
*
* 用户原话:「在服务器端的个人设置那里面,就有一个这个设备管理,就是说我都有哪些
* 设备已经在登录这个东西。」「同一个设备退出登录,它这个令牌马上就失效;
* 一旦重新登录之后,再给它分配一个新的令牌。」
*
* ⇒ 令牌从此**必须挂在 `device_id` 上**(App 首次启动自生成的 UUID,不是硬件号 ——
* HarmonyOS 三方应用拿不到 UDID)。同 `device_id` 重新登录 = 旧令牌立即删、发新的。
*
* **24 小时是「滑动窗口」而不是「硬过期」**(用户 2026-10-17 明确):
* 每次带着有效令牌的请求都会把 `expires_at` 往后推到「现在 + 24h」。
* ⇒ 天天开 App 的人**永远不会被要求重新登录**;
* 而丢了的手机只要被网页端踢掉,它的下一次请求就是 401。
* ⚠️ 为什么续期要有阈值(`RENEW_BELOW_HOURS`):否则**每个请求都要写一次库**
* (同步时几秒内几十个请求),纯属浪费;剩余寿命还够时只更新活跃时间。
* ⚠️ 为什么"离线超过 24h 就锁"这条判定不在服务端做:
* 离线时根本没有请求,服务端无从判断。它由**端上**按"上次校验成功的时间"判,
* 而那个时间用的是**服务端返回的时间**,不是本机时钟(本机时钟可以改)。
*
* **踢设备 = 硬删**(不是打个吊销标记):
* 令牌哈希一并抹掉,库里不留可用的凭据;设备列表里也立刻消失。
* 审计需求不存在(用户要的是"这台机器再也进不来",不是"记录它曾经来过")。
*/
declare(strict_types=1);
namespace App;
final class ApiToken
{
/** 令牌前缀:让日志/配置里一眼能认出"这是家书的 API 令牌"(GitHub 的 ghp_ 同理) */
public const PREFIX = 'fb_';
/** 设备会话的有效期(小时)。用户 2026-10-17:24 小时,滑动续期 */
public const TTL_HOURS = 24;
/** 剩余寿命低于这个小时数才续期(避免每个请求都写库,见文件头说明) */
private const RENEW_BELOW_HOURS = 4;
/**
* 每个账号最多几台设备。
*
* 用户 2026-10-17 同意:「每账号最多 10 台,超了让用户先踢旧的。」
* ⚠️ 不设上限的话,一个脚本反复登录就能把 `api_tokens` 灌满,
* 而且设备列表会变得没法看。
*/
public const MAX_DEVICES = 10;
/**
* 签发一枚**手动令牌**(网页端「签发 API 令牌」入口,v44 的老路径)。
*
* ⚠️ 这条路径**不挂设备**语义上也要有个归属:网页上生成了令牌,用户可能
* 粘贴到任何地方(第三方脚本、curl)。它仍然进设备列表(叫 label,
* 没有就显示"手动签发的令牌"),也照样能被踢掉 —— 否则它就是一个
* **用户看不见、也踢不掉的幽灵会话**(v35 迁移回填的就是这类东西)。
*
* @param string $label 设备名(用户自己填)
* @param string|null $expiresAt 到期时间;NULL = 不过期(与老行为一致)
*/
public static function issue(int $personId, string $label = '', ?string $expiresAt = null): string
{
$plain = self::PREFIX . self::b64url(random_bytes(32));
self::insert(
$personId,
hash('sha256', $plain),
$label !== '' ? mb_substr($label, 0, 60) : null,
'manual-' . self::b64url(random_bytes(6)),
$label !== '' ? mb_substr($label, 0, 60) : '手动签发的令牌',
'manual',
'',
($expiresAt !== null && $expiresAt !== '') ? $expiresAt : null
);
return $plain;
}
/**
* 签发一枚**设备会话令牌**(App 登录用)。
*
* 用户 2026-10-17:「同一个设备退出登录,它这个令牌马上就失效;它一旦登录、
* 重新登录之后,再给它分配一个新的令牌。」
* ⇒ 先按 `$deviceId` 删掉同一台设备的旧令牌,再签新的。**同一台设备永远只有一把**。
*
* @param string $deviceId App 自生成的 UUID(空串会被兜底成一个随机设备号)
* @param string $deviceName 设备名(机型,用户可改)
* @param string $platform `harmonyos 5.0.1` 这种
* @param string $appVersion App 版本号
* @param int $ttlHours 有效期(小时);0 = 不过期(仅测试/特殊场景用)
*
* @return string|null 明文令牌;**null = 设备数量已达上限**(调用方回 409)
*/
public static function issueDevice(
int $personId,
string $deviceId,
string $deviceName = '',
string $platform = '',
string $appVersion = '',
int $ttlHours = self::TTL_HOURS
): ?string {
$db = Database::get();
$deviceId = trim($deviceId);
if ($deviceId === '') {
/* 老客户端(v57 之前)不带设备号。
⚠️ **不能生成随机设备号**:那样它每登录一次就等于"一台新设备",
十几次就把 `MAX_DEVICES` 撞满,账号被自己锁死。
⇒ 固定成一个共享槽位 "auto-unknown":老客户端共用一条会话,
互相顶掉(它们本来也不区分设备),但永远不会撑爆上限。
设备列表里它显示成「未知设备」,照样能踢。 */
$deviceId = 'auto-unknown';
}
$deviceId = mb_substr($deviceId, 0, 64);
// ① 先算"这台设备在不在这台账号的设备表里",以及一共几台。
// ⚠️ 顺序很重要:**必须先判上限、再删旧令牌**。
// 反过来的话,第 11 台设备登录 → 旧令牌已被删 → 才发现超限 →
// 用户手里这台机器直接变未登录,而服务端什么都没给他。
$st = $db->prepare(
"SELECT COUNT(DISTINCT COALESCE(NULLIF(device_id, ''), CONCAT('legacy-', id)))
FROM api_tokens WHERE person_id = ?"
);
$st->execute([$personId]);
$deviceCount = (int)$st->fetchColumn();
$st = $db->prepare('SELECT COUNT(*) FROM api_tokens WHERE person_id = ? AND device_id = ?');
$st->execute([$personId, $deviceId]);
$isKnownDevice = (int)$st->fetchColumn() > 0;
// ② 新设备且已达上限 → 拒绝,**不动任何一行**
if (!$isKnownDevice && $deviceCount >= self::MAX_DEVICES) {
return null;
}
// ③ 同一台设备重登:旧令牌立即作废(用户明确要求)
$db->prepare('DELETE FROM api_tokens WHERE person_id = ? AND device_id = ?')
->execute([$personId, $deviceId]);
$plain = self::PREFIX . self::b64url(random_bytes(32));
self::insert(
$personId,
hash('sha256', $plain),
$deviceName !== '' ? mb_substr($deviceName, 0, 60) : null,
$deviceId,
$deviceName !== '' ? mb_substr($deviceName, 0, 60) : '未命名设备',
$platform !== '' ? mb_substr($platform, 0, 30) : '',
$appVersion !== '' ? mb_substr($appVersion, 0, 30) : '',
$ttlHours > 0 ? date('Y-m-d H:i:s', time() + $ttlHours * 3600) : null
);
return $plain;
}
/** 落一行令牌(`issue` / `issueDevice` 共用) */
private static function insert(
int $personId,
string $tokenHash,
?string $label,
string $deviceId,
string $deviceName,
string $platform,
string $appVersion,
?string $expiresAt
): void {
Database::get()->prepare(
'INSERT INTO api_tokens
(person_id, token_hash, label, device_id, device_name, platform, app_version,
last_used_at, expires_at)
VALUES (?,?,?,?,?,?,?,NOW(),?)'
)->execute([
$personId, $tokenHash, $label, $deviceId, $deviceName, $platform, $appVersion, $expiresAt,
]);
}
/**
* 校验一枚令牌,成功返回人员/设备信息,失败 null。
*
* ⚠️ 只认 `Bearer` 头 —— 不接 query string(URL 会进日志/浏览器历史,
* 令牌出现在 URL 里等于半公开)。DAV 那套用 Basic 是 RFC 定的,没得选;
* API 是我们自己定的,就选更安全的那个。
* ⚠️ 逐字节 hash 之后查库(不先解密/比对),配合唯一索引 —— 不走时序分支。
* ⚠️ 过期/被吊销一律返回 null(不给"为什么不行"的细粒度错误,
* 避免给爆破者提供信息;具体原因网页端能看到)。
*
* 返回值**同时保留数字键**(`[0]`=人员 id、`[1]`=令牌 id):
* `Api::actor()` / `api.php` / `media.php` 三处历史上都用 `$hit[0]`、`$hit[1]`,
* 改成纯关联数组会静默把它们打坏。
*/
public static function verify(string $plain): ?array
{
$plain = trim($plain);
if ($plain === '' || strpos($plain, self::PREFIX) !== 0) {
return null;
}
$db = Database::get();
$st = $db->prepare(
'SELECT t.id, t.person_id, t.device_id, t.device_name, t.platform, t.app_version,
t.expires_at, 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;
}
$tokenId = (int)$row['id'];
$personId = (int)$row['person_id'];
$expiresAt = $row['expires_at'] !== null ? (string)$row['expires_at'] : null;
$newExpiresAt = $expiresAt;
/* 记活跃 + 滑动续期。
⚠️ 整段包在 try 里:**记录失败不该拦住这次请求**(这条是"顺带的统计",
与鉴权本身无关;写不进去最多是设备列表里"最近活跃"停住)。 */
try {
$ip = self::clientIp();
if ($expiresAt !== null && !self::renewDue($expiresAt)) {
$db->prepare(
'UPDATE api_tokens SET last_used_at = NOW(), last_ip = ? WHERE id = ?'
)->execute([$ip, $tokenId]);
} else {
// 剩余不到 4 小时 → 续到"现在 + 24h"(滑动窗口)
$newExpiresAt = date('Y-m-d H:i:s', time() + self::TTL_HOURS * 3600);
$db->prepare(
'UPDATE api_tokens
SET last_used_at = NOW(), last_ip = ?, expires_at = ?
WHERE id = ?'
)->execute([$ip, $newExpiresAt, $tokenId]);
}
} catch (\Throwable $e) {
// 见上:记不上就记不上
}
return [
0 => $personId,
1 => $tokenId,
'person_id' => $personId,
'token_id' => $tokenId,
'device_id' => (string)($row['device_id'] ?? ''),
'device_name' => (string)($row['device_name'] ?? ''),
'platform' => (string)($row['platform'] ?? ''),
'app_version' => (string)($row['app_version'] ?? ''),
'expires_at' => $newExpiresAt,
];
}
/**
* 该账号的**设备列表**(按设备聚合,每台只出最新的一行)。
*
* 用户 2026-10-17:「我都有哪些设备已经在登录这个东西」——
* 所以要的是"设备",不是"令牌":同一台设备重登过 5 次,列表里也只该有 1 行。
*
* @param int $currentTokenId 当前请求所用令牌的 id(用来标「这台设备」)
* @param string $currentDeviceId 当前请求的设备号
*/
public static function listDevices(int $personId, int $currentTokenId = 0, string $currentDeviceId = ''): array
{
$st = Database::get()->prepare(
"SELECT t.id, t.device_id, t.device_name, t.label, t.platform, t.app_version,
t.created_at, t.last_used_at, t.last_ip, t.expires_at
FROM api_tokens t
JOIN (
SELECT COALESCE(NULLIF(device_id, ''), CONCAT('legacy-', id)) AS dk,
MAX(id) AS mid
FROM api_tokens
WHERE person_id = ?
GROUP BY dk
) x ON x.mid = t.id
WHERE t.person_id = ?
ORDER BY COALESCE(t.last_used_at, t.created_at) DESC, t.id DESC"
);
$st->execute([$personId, $personId]);
$rows = $st->fetchAll();
$out = [];
foreach ($rows as $r) {
$deviceId = (string)($r['device_id'] ?? '');
$name = (string)($r['device_name'] ?? '');
if ($name === '') {
$name = (string)($r['label'] ?? '');
}
$out[] = [
'id' => (int)$r['id'],
'device_id' => $deviceId,
'device_name' => $name !== '' ? $name : '未知设备',
'platform' => (string)($r['platform'] ?? ''),
'app_version' => (string)($r['app_version'] ?? ''),
'created_at' => (string)($r['created_at'] ?? ''),
'last_seen_at'=> (string)($r['last_used_at'] ?? ''),
'last_ip' => (string)($r['last_ip'] ?? ''),
'expires_at' => $r['expires_at'] !== null ? (string)$r['expires_at'] : null,
// 当前设备:优先比 device_id,拿不到就退回比令牌 id
'is_current' => ($currentDeviceId !== '' && $deviceId === $currentDeviceId)
|| ($currentTokenId > 0 && (int)$r['id'] === $currentTokenId),
];
}
return $out;
}
/**
* 踢掉一台设备(硬删,见文件头)。
*
* @return int 实际删掉的行数(一台设备可能连着多行,正常是 1)
*/
public static function revokeDevice(int $personId, string $deviceId): int
{
$deviceId = trim($deviceId);
if ($deviceId === '') {
return 0;
}
$st = Database::get()->prepare('DELETE FROM api_tokens WHERE person_id = ? AND device_id = ?');
$st->execute([$personId, $deviceId]);
return $st->rowCount();
}
/**
* 踢掉**除当前设备以外**的全部设备("我的其它设备都退掉")。
*
* @param string $keepDeviceId 保留哪台(当前设备的 device_id)
*/
public static function revokeOthers(int $personId, string $keepDeviceId, int $keepTokenId = 0): int
{
$db = Database::get();
$keepDeviceId = trim($keepDeviceId);
if ($keepDeviceId !== '') {
$st = $db->prepare('DELETE FROM api_tokens WHERE person_id = ? AND device_id <> ?');
$st->execute([$personId, $keepDeviceId]);
return $st->rowCount();
}
// 拿不到设备号(老客户端)→ 退回按令牌 id 保留
if ($keepTokenId > 0) {
$st = $db->prepare('DELETE FROM api_tokens WHERE person_id = ? AND id <> ?');
$st->execute([$personId, $keepTokenId]);
return $st->rowCount();
}
return 0;
}
/** 吊销当前这一枚(App「退出登录」用;服务端尽力而为,见 ApiHandler::logout 的说明) */
public static function revokeCurrent(int $personId, int $tokenId): bool
{
$st = Database::get()->prepare('DELETE FROM api_tokens WHERE id = ? AND person_id = ?');
$st->execute([$tokenId, $personId]);
return $st->rowCount() > 0;
}
/** 某个人的令牌清单(**不含明文**,只给管理界面看;v44 的老接口) */
public static function listFor(int $personId): array
{
$st = Database::get()->prepare(
'SELECT id, label, device_id, device_name, created_at, last_used_at, expires_at
FROM api_tokens WHERE person_id = ? ORDER BY id DESC'
);
$st->execute([$personId]);
return $st->fetchAll();
}
/** 吊销一枚(限自己的;v44 的老接口) */
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;
}
/** 吊销自己的全部("手机丢了"时的应急动作;v44 的老接口) */
public static function revokeAll(int $personId): int
{
$st = Database::get()->prepare('DELETE FROM api_tokens WHERE person_id = ?');
$st->execute([$personId]);
return $st->rowCount();
}
/** 剩余寿命是否已经短到该续期了(见文件头 RENEW_BELOW_HOURS 的说明) */
private static function renewDue(string $expiresAt): bool
{
$ts = strtotime($expiresAt);
if ($ts === false) {
return false;
}
return ($ts - time()) < self::RENEW_BELOW_HOURS * 3600;
}
/**
* 客户端 IP。
*
* ⚠️ **只认 `REMOTE_ADDR`**,不解析 `X-Forwarded-For`:
* 本站是 Apache 直接对外(没有反代/CDN),XFF 是客户端可以随便写的 ——
* 拿它当真实 IP 等于让踢人的界面显示攻击者编的地址。
* 将来真加了 CDN,再在这里显式白名单代理 IP 之后才可信。
*/
private static function clientIp(): string
{
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? '');
return mb_substr($ip, 0, 45);
}
private static function b64url(string $raw): string
{
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
}
}