Files
FamilyBook/app/ApiToken.php
T
laoyang ebaed21920 v57:令牌升级为「设备会话」(24h 滑动续期 / 每账号 10 台 / 网页端远程退出)
用户 2026-10-17 定的模型:
  · 一次登录 = 一台设备一条会话;同一台设备重登,旧令牌立即作废
  · 24 小时滑动续期(一直联网就不用每天输密码);离线超 24h 只锁界面、不删数据
  · 网页端「个人设置 → 设备管理」能看见都有谁在登录,并能把丢了的那台踢掉
  · 每账号最多 10 台,超了让用户先踢旧的

服务端配套:
  · api_tokens 加 device_id / device_name / platform / app_version / last_ip
    + idx_api_device;历史令牌回填 legacy-<id>(不回填 = 踢不掉的幽灵会话)
  · ApiToken:issueDevice / listDevices / revokeDevice / revokeOthers / revokeCurrent
    + 滑动续期(剩余不足 4h 才写库);verify 同时返回数字键与关联键(三处调用方在用)
  · 新增 /api/v1/logout、/api/v1/devices;ping 报 features.device_session
  · 登录不再读 days(0 = 永不过期会绕过 24h 这条线);超限回 409 且**不动任何一行**
  · 老客户端兜底固定槽位 auto-unknown(随机设备号会撞满 10 台把账号锁死)
  · Schema 34 → 35

顺带:首页近期任务/往年的今天(Home::memories)、名册派生字段口径统一。

验证:设备会话探针 56/0、设备管理渲染 22/0、登录全链路 30/0、反向验证 12/0、
      e2e 1519/0、静态断言本地空跑 357/0、线上 /api/v1/{ping,devices,logout} 复核通过。
2026-10-18 03:34:47 +08:00

419 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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), '+/', '-_'), '=');
}
}