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。
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
<?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), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user