Files
FamilyBook/app/Auth.php
T

322 lines
12 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
/**
* 登录态与权限。
*
* 一个重要设计:**人(Person)≠ 账号(Account)**。
* 六岁的孩子应该出现在家庭名册、照片、活动参与人里,但不必有密码。
* 所以 person 可以先于 account 存在,将来再「开通账号」。
*/
declare(strict_types=1);
namespace App;
final class Auth
{
public static function check(): bool
{
return !empty($_SESSION['account_id']);
}
public static function id(): ?int
{
return isset($_SESSION['account_id']) ? (int)$_SESSION['account_id'] : null;
}
public static function personId(): ?int
{
return isset($_SESSION['person_id']) ? (int)$_SESSION['person_id'] : null;
}
public static function familyId(): ?int
{
return isset($_SESSION['family_id']) ? (int)$_SESSION['family_id'] : null;
}
public static function name(): string
{
return $_SESSION['name'] ?? '';
}
// ---------------- DAV 专用口令(v24 第 7 条后半 → 第 22 批升成通用) ----------------
/**
* 这个人有没有配过 DAV 口令。
*
* ⚠️ 为什么不用登录口令:DAV 客户端(iOS 日历/通讯录、DAVx⁵、Thunderbird)
* 会把这串凭据**长期存在设备上**并反复自动发送。让它是登录口令,
* 等于把主密码复制到每一台设备、且改密码就要每台重配;
* 更糟的是手机上那个"账号"往往还有别的用途 —— 泄露面比日历本大得多。
* 这正是用户第 22 批的原话:「避免在第三方客户端进行同步时自己的密码泄露」。
*
* ⚠️ **一把口令管两种同步**(用户 2026-10-06 第 22 批确认):
* 日历(CalDAV)与通讯录(CardDAV)共用同一串 —— 手机上填一次就都通了。
* 所以下面这几个方法一律叫 dav* 而不是 caldav*(表也从 caldav_credentials
* 改名成了 dav_credentials,见 Schema 版本 23)。
*/
public static function hasDav(int $personId): bool
{
$st = Database::get()->prepare('SELECT COUNT(*) FROM dav_credentials WHERE person_id = ?');
$st->execute([$personId]);
return (int)$st->fetchColumn() > 0;
}
/** 这个人最后一次用 DAV 同步是什么时候(NULL = 从没用过;UI 上显示出来) */
public static function davLastUsed(int $personId): ?string
{
$st = Database::get()->prepare(
'SELECT last_used_at FROM dav_credentials WHERE person_id = ? LIMIT 1'
);
$st->execute([$personId]);
$v = $st->fetchColumn();
return ($v === false || $v === null || $v === '') ? null : (string)$v;
}
/**
* 生成(或重置)DAV 口令,**返回明文**(只在这一刻能看到,库里只有哈希)。
*
* ⚠️ 重置 = 换一串新的,**设备上的旧口令立刻失效** —— 这是"口令外泄了怎么办"
* 的唯一出路,所以 UI 上按钮就写"重置"、并且要有一句确认。
*/
public static function davIssue(int $personId): string
{
// 去掉容易读错的字符(0/O、1/l/I),因为用户要手打进手机
$alphabet = 'abcdefghjkmnpqrstuvwxyz23456789';
$plain = '';
for ($i = 0; $i < 24; $i++) {
$plain .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
Database::get()->prepare(
'INSERT INTO dav_credentials (person_id, token_hash) VALUES (?,?)
ON DUPLICATE KEY UPDATE token_hash = VALUES(token_hash), created_at = NOW()'
)->execute([$personId, hash('sha256', $plain)]);
return $plain;
}
public static function davRevoke(int $personId): void
{
Database::get()->prepare('DELETE FROM dav_credentials WHERE person_id = ?')
->execute([$personId]);
}
/**
* 用 Basic 凭据换一个「登录者」。成功返回 [personId, familyId, name],失败 null。
*
* 用户名可以是手机号,也可以是姓名 —— 手机上打姓名比打 11 位数字省事得多。
* 两者都走同一套节流(失败计数保存在会话里,与登录共用)。
*
* ⚠️ 这套鉴权**同时服务 CalDAV 与 CardDAV**(dav.php 与 carddav.php 都调它)
* —— 这正是"一把口令管两种同步"的落点:任何一处改了规则,两处一起生效,
* 不会出现"日历能连、通讯录连不上"的裂缝。
*/
public static function davAuth(?string $user, ?string $pass): ?array
{
$user = trim((string)$user);
$pass = (string)$pass;
if ($user === '' || $pass === '') {
return null;
}
$fail = $_SESSION['cd_fail'] ?? ['n' => 0, 't' => 0];
if ($fail['n'] >= 8 && (time() - $fail['t']) < 60) {
usleep(500000);
return null;
}
$db = Database::get();
// 先按手机号(哈希)找,再按姓名找 —— 都限定 status=active
$row = null;
$st = $db->prepare(
'SELECT a.id AS account_id, p.id AS person_id, p.family_id, p.name, cd.token_hash
FROM accounts a
JOIN persons p ON p.id = a.person_id
LEFT JOIN dav_credentials cd ON cd.person_id = p.id
WHERE a.status = "active" AND p.phone_hash = ? LIMIT 1'
);
$st->execute([Crypto::hash($user)]);
$row = $st->fetch();
if (!$row) {
$st = $db->prepare(
'SELECT a.id AS account_id, p.id AS person_id, p.family_id, p.name, cd.token_hash
FROM accounts a
JOIN persons p ON p.id = a.person_id
LEFT JOIN dav_credentials cd ON cd.person_id = p.id
WHERE a.status = "active" AND p.name = ?
ORDER BY p.id LIMIT 1'
);
$st->execute([$user]);
$row = $st->fetch();
}
$ok = $row && !empty($row['token_hash'])
&& hash_equals((string)$row['token_hash'], hash('sha256', $pass));
if (!$ok) {
$_SESSION['cd_fail'] = ['n' => $fail['n'] + 1, 't' => time()];
return null;
}
unset($_SESSION['cd_fail']);
$db->prepare('UPDATE dav_credentials SET last_used_at = NOW() WHERE person_id = ?')
->execute([(int)$row['person_id']]);
return [
'person_id' => (int)$row['person_id'],
'family_id' => (int)$row['family_id'],
'name' => (string)$row['name'],
];
}
public static function isAdmin(): bool
{
return ($_SESSION['role'] ?? '') === 'admin';
}
/** 访客:只能看被授权的信息,不能创建/编辑任何东西 */
public static function isGuest(): bool
{
return ($_SESSION['role'] ?? '') === 'guest';
}
/** 能正常使用系统的人(管理员或家庭成员)—— 判断「能否写」用这个 */
public static function canWrite(): bool
{
$r = $_SESSION['role'] ?? '';
return $r === 'admin' || $r === 'member';
}
public static function role(): string
{
return (string)($_SESSION['role'] ?? '');
}
public static function roleWord(): string
{
return ['admin' => '管理员', 'member' => '家庭成员', 'guest' => '访客'][self::role()] ?? '成员';
}
/**
* 首页标题 —— 「以我为中心」最直接的一处落点。
* 登录后看到的不是品牌名「岁月家」,而是「<我的名字>的家庭岁月」。
*/
public static function homeTitle(): string
{
$name = self::name();
return $name === '' ? '岁月家' : $name . '的家庭岁月';
}
/** 未登录跳登录页 */
public static function requireLogin(): void
{
if (!self::check()) {
redirect('auth/login');
}
}
/** 需要「能写」的权限(访客会被挡下) */
public static function requireWrite(): void
{
self::requireLogin();
if (!self::canWrite()) {
http_response_code(403);
\App\View::render('error', [
'title' => '访客只读',
'message' => '你的身份是访客,只能查看,不能新增或修改内容。',
], 'home');
exit;
}
}
public static function requireAdmin(): void
{
self::requireLogin();
if (!self::isAdmin()) {
http_response_code(403);
\App\View::render('error', [
'title' => '没有权限',
'message' => '这一页只有家庭管理员可以操作。',
], 'home');
exit;
}
}
/**
* 手机号 + 密码登录。
* 返回 true 表示成功并已写入会话。
*/
public static function login(string $phone, string $password): bool
{
// 简单节流:5 次失败后 60 秒内不再尝试。
// 防爆破的关键不在判定多严,而在于「尝试次数有成本」。
$fail = $_SESSION['login_fail'] ?? ['n' => 0, 't' => 0];
if ($fail['n'] >= 5 && (time() - $fail['t']) < 60) {
usleep(500000);
return false;
}
$hash = Crypto::hash($phone);
$stmt = Database::get()->prepare(
'SELECT a.id AS account_id, a.password_hash, a.role, a.status,
p.id AS person_id, p.family_id, p.name, p.avatar
FROM accounts a
JOIN persons p ON p.id = a.person_id
WHERE p.phone_hash = ? LIMIT 1'
);
$stmt->execute([$hash]);
$row = $stmt->fetch();
if (!$row || $row['status'] !== 'active'
|| !password_verify($password, (string)$row['password_hash'])) {
$_SESSION['login_fail'] = ['n' => $fail['n'] + 1, 't' => time()];
usleep(300000);
return false;
}
unset($_SESSION['login_fail']);
session_regenerate_id(true);
$_SESSION['account_id'] = (int)$row['account_id'];
$_SESSION['person_id'] = (int)$row['person_id'];
$_SESSION['family_id'] = (int)$row['family_id'];
$_SESSION['name'] = $row['name'];
$_SESSION['role'] = $row['role'];
$_SESSION['avatar'] = $row['avatar'];
Database::get()->prepare('UPDATE accounts SET last_login_at = NOW() WHERE id = ?')
->execute([$row['account_id']]);
// 进门就把「这个人的默认日历本」补上(幂等)。
//
// ⚠️ 为什么必须在这里兜一道,而不是只靠 Schema::ensure():
// 全新安装时 install.php 是**先** Schema::ensure()、**后**建人 ——
// ensure() 里那段 seedDefaultsForFamily() 遍历的是"已开通账号的人",
// 那时一个人都还没有,于是**一本日历都不建**;紧接着 schema_version
// 被写成最新版,升级路径以后也不会再跑 → 这个人永远没有观影/旅行两本。
// 症状很隐蔽:日程页能用,只是"我的日历本"空着,观影也不上日历。
// 放在登录处最稳:不管人是装的、别人加的、还是老库升级来的,进来就有。
try {
\App\Calendar::seedDefaults((int)$row['family_id'], (int)$row['person_id']);
} catch (\Throwable $ex) {
// 日历本建不出来不该把人挡在门外;下次登录会再试一次
error_log('[familybook] 补建默认日历本失败(已跳过):' . $ex->getMessage()
. ' @ ' . $ex->getFile() . ':' . $ex->getLine());
}
return true;
}
public static function logout(): void
{
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
}
/** 头像改动后刷新会话里的缓存 */
public static function refreshAvatar(string $avatar): void
{
$_SESSION['avatar'] = $avatar;
}
}