`。 * 为什么不复用 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), '+/', '-_'), '='); } }