2025-05-12 00:19:35 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
namespace App\Service\common;
|
|
|
|
|
|
|
|
|
|
|
|
use Firebase\JWT\JWT;
|
|
|
|
|
|
use Firebase\JWT\Key;
|
|
|
|
|
|
use Exception;
|
|
|
|
|
|
|
2026-08-10 13:58:14 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* JWT 签发与解析服务
|
|
|
|
|
|
* firebase/php-jwt 对 HS256 要求密钥至少 256 bit(32 字节),过短会抛 Provided key is too short
|
|
|
|
|
|
*/
|
2025-05-12 00:19:35 +08:00
|
|
|
|
class JWTService
|
|
|
|
|
|
{
|
|
|
|
|
|
private static mixed $_instance;
|
2026-08-10 13:58:14 +08:00
|
|
|
|
private string $secretKey;
|
2025-05-12 00:19:35 +08:00
|
|
|
|
private string $token;
|
|
|
|
|
|
|
2026-08-10 13:58:14 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 构造时从配置读取密钥,并兜底保证满足 HS256 最小长度
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function __construct()
|
|
|
|
|
|
{
|
|
|
|
|
|
// 优先读环境变量 JWT_SECRET;长度不足时用 hash 派生,避免库直接报错
|
|
|
|
|
|
$secret = (string) config('nl.jwt.secret', '');
|
|
|
|
|
|
if (strlen($secret) < 32) {
|
|
|
|
|
|
$secret = hash('sha256', $secret !== '' ? $secret : 'nl_admin_jwt_fallback_secret');
|
|
|
|
|
|
}
|
|
|
|
|
|
$this->secretKey = $secret;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-05-12 00:19:35 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 获取实例
|
|
|
|
|
|
* @return null|static
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function getInstance(): null|static
|
|
|
|
|
|
{
|
|
|
|
|
|
$name = get_called_class();
|
|
|
|
|
|
if (!isset(self::$_instance[$name])) {
|
|
|
|
|
|
self::$_instance[$name] = new static();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return self::$_instance[$name];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取 Token
|
|
|
|
|
|
* @return $this|null
|
|
|
|
|
|
* @throws Exception
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function getToken(): null|static
|
|
|
|
|
|
{
|
|
|
|
|
|
$token = request()->bearerToken();
|
|
|
|
|
|
if (empty($token)) return UtilsService::getInstance()->notAuth('请先登录');
|
|
|
|
|
|
$this->token = $token;
|
|
|
|
|
|
return $this;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 生成 JWT Token
|
|
|
|
|
|
* @param array $data 要嵌入到 token 中的数据
|
|
|
|
|
|
* @return string
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function generateToken(array $data): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$issuedAt = time();
|
2025-05-12 16:13:46 +08:00
|
|
|
|
$expirationTime = $issuedAt + config('nl.redis.jwt_ttl', 600000);
|
2025-05-12 00:19:35 +08:00
|
|
|
|
|
|
|
|
|
|
$payload = [
|
|
|
|
|
|
'iat' => $issuedAt,
|
|
|
|
|
|
'exp' => $expirationTime,
|
2026-08-19 08:16:49 +08:00
|
|
|
|
// sub 是标准声明,网关/旧客户端剥掉自定义 data 时还能靠它找回用户
|
|
|
|
|
|
'sub' => (string) ($data['id'] ?? ''),
|
|
|
|
|
|
// 与小程序 token(scope=wx)区分,两边共用 JWT_SECRET 但不能互认
|
|
|
|
|
|
'scope' => 'admin',
|
|
|
|
|
|
'data' => $data,
|
2025-05-12 00:19:35 +08:00
|
|
|
|
];
|
2025-05-12 16:13:46 +08:00
|
|
|
|
RedisService::getInstance()->init(config('nl.redis.jwt'))->set($data['id'], json_encode($data));
|
2025-05-12 00:19:35 +08:00
|
|
|
|
return JWT::encode($payload, $this->secretKey, 'HS256');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 解析 JWT Token
|
|
|
|
|
|
* @return object|null 返回解码后的 payload 或者 null 如果验证失败
|
|
|
|
|
|
* @throws Exception
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function parseToken(): ?object
|
|
|
|
|
|
{
|
|
|
|
|
|
try {
|
2026-08-19 08:16:49 +08:00
|
|
|
|
if (empty($this->token)) {
|
|
|
|
|
|
return UtilsService::getInstance()->notAuth('请先登录');
|
|
|
|
|
|
}
|
2025-05-12 00:19:35 +08:00
|
|
|
|
return JWT::decode($this->token, new Key($this->secretKey, 'HS256'));
|
|
|
|
|
|
} catch (Exception $e) {
|
2026-08-19 08:16:49 +08:00
|
|
|
|
if ((int) $e->getCode() === 401) {
|
|
|
|
|
|
throw $e;
|
|
|
|
|
|
}
|
2025-05-12 00:19:35 +08:00
|
|
|
|
return UtilsService::getInstance()->notAuth('【1】Token解析失败!请重新登录:'. $e->getMessage());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 解析 JWT Token
|
|
|
|
|
|
* @return array|null 返回解码后的 payload 或者 null 如果验证失败
|
|
|
|
|
|
* @throws Exception
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function getUserInfo(): ?array
|
|
|
|
|
|
{
|
|
|
|
|
|
try {
|
|
|
|
|
|
$jwt = $this->parseToken();
|
2026-08-19 08:16:49 +08:00
|
|
|
|
$payload = json_decode(json_encode($jwt), true);
|
|
|
|
|
|
if (is_array($payload) && ($payload['scope'] ?? '') === 'wx') {
|
|
|
|
|
|
return UtilsService::getInstance()->notAuth('请使用后台账号登录');
|
|
|
|
|
|
}
|
|
|
|
|
|
$data = $this->extractUserData($jwt);
|
|
|
|
|
|
$userId = (int) ($data['id'] ?? 0);
|
|
|
|
|
|
if ($userId <= 0) {
|
|
|
|
|
|
return UtilsService::getInstance()->notAuth('Token 无效,请重新登录');
|
|
|
|
|
|
}
|
|
|
|
|
|
$user = RedisService::getInstance()->init(config('nl.redis.jwt'))->get($userId);
|
|
|
|
|
|
if (empty($user)) {
|
|
|
|
|
|
// Redis 会话丢了但签名有效:用 payload 顶住,避免刚登录就被踢
|
|
|
|
|
|
return $data;
|
|
|
|
|
|
}
|
|
|
|
|
|
$decoded = json_decode($user, true);
|
|
|
|
|
|
return is_array($decoded) ? $decoded : $data;
|
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
|
if ((int) $e->getCode() === 401) {
|
|
|
|
|
|
throw $e;
|
|
|
|
|
|
}
|
2025-05-12 00:19:35 +08:00
|
|
|
|
return UtilsService::getInstance()->notAuth('【2】Token解析失败!请重新登录:'. $e->getMessage());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 08:16:49 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 从解码后的 JWT 取出用户数据
|
|
|
|
|
|
*
|
|
|
|
|
|
* 本系统签发的是 { data: { id, ... } };有的网关/旧 token 会把字段摊到顶层,
|
|
|
|
|
|
* 或只留 sub。这里都认,避免再出现 Undefined property::$data。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function extractUserData(?object $jwt): array
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!is_object($jwt)) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
$payload = json_decode(json_encode($jwt), true);
|
|
|
|
|
|
if (!is_array($payload)) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
$data = $payload['data'] ?? null;
|
|
|
|
|
|
if (is_array($data) && $data !== []) {
|
|
|
|
|
|
return $data;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (is_object($data)) {
|
|
|
|
|
|
return (array) $data;
|
|
|
|
|
|
}
|
|
|
|
|
|
$id = $payload['id'] ?? $payload['sub'] ?? 0;
|
|
|
|
|
|
if ((int) $id > 0) {
|
|
|
|
|
|
return array_merge($payload, ['id' => (int) $id]);
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-05-12 00:19:35 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 续签 JWT Token
|
|
|
|
|
|
* @return string|null 新的 JWT 字符串或者 null 如果原 token 已过期或无效
|
|
|
|
|
|
* @throws Exception
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function refreshToken(): ?string
|
|
|
|
|
|
{
|
|
|
|
|
|
$decoded = $this->parseToken();
|
2026-08-19 08:16:49 +08:00
|
|
|
|
$data = $this->extractUserData($decoded);
|
|
|
|
|
|
if (($data['id'] ?? 0) <= 0) {
|
2025-05-12 00:19:35 +08:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 08:16:49 +08:00
|
|
|
|
return $this->generateToken($data);
|
2025-05-12 00:19:35 +08:00
|
|
|
|
}
|
2026-08-14 23:21:21 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 解析已过期但签名有效的 token
|
|
|
|
|
|
*
|
|
|
|
|
|
* 续签场景下 token 必然已经过期,正常 decode 会直接抛 ExpiredException。
|
|
|
|
|
|
* 这里临时放宽 leeway 让 exp 校验通过,签名与 Redis 会话仍然照常校验,
|
|
|
|
|
|
* 所以过期的 token 依旧不能凭空续签——会话被登出或超过宽限期就必须重新登录。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param int $leeway 允许的过期宽限秒数
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function parseExpiringToken(int $leeway): ?object
|
|
|
|
|
|
{
|
|
|
|
|
|
$token = request()->bearerToken();
|
|
|
|
|
|
if (empty($token)) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
$origin = JWT::$leeway;
|
|
|
|
|
|
JWT::$leeway = max(0, $leeway);
|
|
|
|
|
|
try {
|
|
|
|
|
|
return JWT::decode($token, new Key($this->secretKey, 'HS256'));
|
|
|
|
|
|
} catch (Exception $e) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
JWT::$leeway = $origin;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 作废某个用户的登录态:删掉 Redis 会话,手里的 token 立即失效
|
|
|
|
|
|
* (getUserInfo 拿不到会话就会 notAuth,所以不需要维护黑名单)
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function revoke(int $userId): bool
|
|
|
|
|
|
{
|
|
|
|
|
|
if ($userId <= 0) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return RedisService::getInstance()->init(config('nl.redis.jwt'))->del($userId);
|
|
|
|
|
|
}
|
2025-05-12 00:19:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|