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} 复核通过。
This commit is contained in:
+321
-22
@@ -15,6 +15,29 @@
|
||||
* ⚠️ **令牌只在签发的这一次返回明文**(库里只存 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);
|
||||
|
||||
@@ -25,29 +48,145 @@ 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;
|
||||
|
||||
/**
|
||||
* 签发一枚新令牌,返回**明文**(调用方负责立刻交给用户,之后再也拿不到)。
|
||||
* 每个账号最多几台设备。
|
||||
*
|
||||
* ⚠️ 用 `random_bytes` 而不是 `random_int` 循环拼短字符集:
|
||||
* 令牌是机器读的(不像 DAV 口令要手打),长度与熵优先。
|
||||
* 32 字节 → base64url 43 字符 → 约 256 bit 熵。
|
||||
* 用户 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));
|
||||
Database::get()->prepare(
|
||||
'INSERT INTO api_tokens (person_id, token_hash, label, expires_at) VALUES (?,?,?,?)'
|
||||
)->execute([
|
||||
self::insert(
|
||||
$personId,
|
||||
hash('sha256', $plain),
|
||||
$label !== '' ? mb_substr($label, 0, 60) : null,
|
||||
$expiresAt !== '' ? $expiresAt : null,
|
||||
]);
|
||||
'manual-' . self::b64url(random_bytes(6)),
|
||||
$label !== '' ? mb_substr($label, 0, 60) : '手动签发的令牌',
|
||||
'manual',
|
||||
'',
|
||||
($expiresAt !== null && $expiresAt !== '') ? $expiresAt : null
|
||||
);
|
||||
return $plain;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验一枚令牌,成功返回 [personId, tokenId],失败 null。
|
||||
* 签发一枚**设备会话令牌**(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 定的,没得选;
|
||||
@@ -55,6 +194,10 @@ final class ApiToken
|
||||
* ⚠️ 逐字节 hash 之后查库(不先解密/比对),配合唯一索引 —— 不走时序分支。
|
||||
* ⚠️ 过期/被吊销一律返回 null(不给"为什么不行"的细粒度错误,
|
||||
* 避免给爆破者提供信息;具体原因网页端能看到)。
|
||||
*
|
||||
* 返回值**同时保留数字键**(`[0]`=人员 id、`[1]`=令牌 id):
|
||||
* `Api::actor()` / `api.php` / `media.php` 三处历史上都用 `$hit[0]`、`$hit[1]`,
|
||||
* 改成纯关联数组会静默把它们打坏。
|
||||
*/
|
||||
public static function verify(string $plain): ?array
|
||||
{
|
||||
@@ -62,8 +205,10 @@ final class ApiToken
|
||||
if ($plain === '' || strpos($plain, self::PREFIX) !== 0) {
|
||||
return null;
|
||||
}
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT t.id, t.person_id, a.status
|
||||
$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 = ?
|
||||
@@ -75,28 +220,158 @@ final class ApiToken
|
||||
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 {
|
||||
Database::get()->prepare('UPDATE api_tokens SET last_used_at = NOW() WHERE id = ?')
|
||||
->execute([(int)$row['id']]);
|
||||
$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 [(int)$row['person_id'], (int)$row['id']];
|
||||
|
||||
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, created_at, last_used_at, expires_at
|
||||
'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 = ?');
|
||||
@@ -104,7 +379,7 @@ final class ApiToken
|
||||
return $st->rowCount() > 0;
|
||||
}
|
||||
|
||||
/** 吊销自己的全部("手机丢了"时的应急动作) */
|
||||
/** 吊销自己的全部("手机丢了"时的应急动作;v44 的老接口) */
|
||||
public static function revokeAll(int $personId): int
|
||||
{
|
||||
$st = Database::get()->prepare('DELETE FROM api_tokens WHERE person_id = ?');
|
||||
@@ -112,8 +387,32 @@ final class ApiToken
|
||||
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), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user