Files
FamilyBook/app/ApiToken.php
T
laoyang 075ac6bbeb v44 第 44 批:界面 5 条 + 移动端 API v1 全量
界面(用户 2026-10-12 第 1~5 条)
1. 新建日程「日历本」并入 .cs 自绘下拉,颜色画进候选项,三字与下拉同行。
2. 新建日程「开始/结束」改「日期范围:」,删旧日期控件;
   重复相关三行仅选重复时出现;删「(含第一次)」与「这段时间算忙碌」。
3. 条目浮层加「地点」、去掉「状态」行;首页红线按分钟定位(原只取整小时);
   去掉左侧红点只留红线。
4. 任务页删掉状态说明提示(STATUS_HINT 三值清空)。
5. 新建任务:标题在前树框在后;截止时间改最新日历控件;负责人默认自己;
   删冗余提示;「上面要做什么」→「任务名称」;清理括号说明。

移动端 API(用户第 6 条:导航模块除外,其余全量)
- 新增 Bearer 令牌鉴权(api_tokens 表,Schema v32),与登录口令 / DAV 口令三套分立。
- Api.php 内核(鉴权/取参/信封/分页/分发 + _method 覆写)+
  ApiHandler.php 17 条资源 + ApiToken.php + api.php 入口。
- 路由:router.php / .htaccess / index.php 三处 /api/ 兜底(部署脚本自动追加线上规则)。
- 个人设置新增「App 接口令牌」卡;docs/API.md 接口文档;
  docs/API-QUESTION-LIST.md 自主决策与待确认缺口清单。

验证:本地 e2e 1510/0、v43 真机 33/0、v43 反向 9/0、
v44 真机 41/0、v44 API 63/0、v44 反向 19/0;全流水线 PUSH_RC=0。
2026-10-12 15:31:03 +08:00

119 lines
5.1 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。
*/
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), '+/', '-_'), '=');
}
}