Files
lgp-admin-plus-api/app/Service/common/JWTService.php
2026-08-19 08:16:49 +08:00

222 lines
6.9 KiB
PHP
Executable File
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
namespace App\Service\common;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Exception;
/**
* JWT 签发与解析服务
* firebase/php-jwt 对 HS256 要求密钥至少 256 bit32 字节),过短会抛 Provided key is too short
*/
class JWTService
{
private static mixed $_instance;
private string $secretKey;
private string $token;
/**
* 构造时从配置读取密钥,并兜底保证满足 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;
}
/**
* 获取实例
* @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();
$expirationTime = $issuedAt + config('nl.redis.jwt_ttl', 600000);
$payload = [
'iat' => $issuedAt,
'exp' => $expirationTime,
// sub 是标准声明,网关/旧客户端剥掉自定义 data 时还能靠它找回用户
'sub' => (string) ($data['id'] ?? ''),
// 与小程序 tokenscope=wx区分两边共用 JWT_SECRET 但不能互认
'scope' => 'admin',
'data' => $data,
];
RedisService::getInstance()->init(config('nl.redis.jwt'))->set($data['id'], json_encode($data));
return JWT::encode($payload, $this->secretKey, 'HS256');
}
/**
* 解析 JWT Token
* @return object|null 返回解码后的 payload 或者 null 如果验证失败
* @throws Exception
*/
public function parseToken(): ?object
{
try {
if (empty($this->token)) {
return UtilsService::getInstance()->notAuth('请先登录');
}
return JWT::decode($this->token, new Key($this->secretKey, 'HS256'));
} catch (Exception $e) {
if ((int) $e->getCode() === 401) {
throw $e;
}
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();
$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;
}
return UtilsService::getInstance()->notAuth('【2】Token解析失败请重新登录'. $e->getMessage());
}
}
/**
* 从解码后的 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 [];
}
/**
* 续签 JWT Token
* @return string|null 新的 JWT 字符串或者 null 如果原 token 已过期或无效
* @throws Exception
*/
public function refreshToken(): ?string
{
$decoded = $this->parseToken();
$data = $this->extractUserData($decoded);
if (($data['id'] ?? 0) <= 0) {
return null;
}
return $this->generateToken($data);
}
/**
* 解析已过期但签名有效的 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);
}
}