开放平台接口认证模块

79 阅读3分钟
1./open/auth/gettoken
入参
{
    appkey:"xxx",
    appsecret:"xxxx"
}
返回
{
    "access_token":xxx // 2小时过期
    "refresh_token":xxx // 14天过期
    "expire": // access_token 过期时间
}
2.刷新token接口:access_token过期就拿着refresh_token去获取新的access_token,这样就避免带着appsecret等敏感信息,长期token获取短期token

3.其他请求资源接口:通过access_token去请求资源

基于jwt:

jwt的playload不要存储敏感信息,其内容主要基于base64处理,放置的内容越多,长度也越长,同时是可以解码的jwt.io/

在 JWT 的实践中,引入 Refresh Token,将会话管理流程改进如下。

  • 客户端使用用户名密码进行认证
  • 服务端生成有效时间较短的 Access Token(例如 10 分钟),和有效时间较长的 Refresh Token(例如 7 天)
  • 客户端访问需要认证的接口时,携带 Access Token
  • 如果 Access Token 没有过期,服务端鉴权后返回给客户端需要的数据
  • 如果携带 Access Token 访问需要认证的接口时鉴权失败(例如返回 401 错误),则客户端使用 Refresh Token 向刷新接口申请新的 Access Token
  • 如果 Refresh Token 没有过期,服务端向客户端下发新的 Access Token
  • 客户端使用新的 Access Token 访问需要认证的接口

将生成的 Refresh Token 以及过期时间存储在服务端的数据库中,由于 Refresh Token 不会在客户端请求业务接口时验证,只有在申请新的 Access Token 时才会验证,所以将 Refresh Token 存储在数据库中,不会对业务接口的响应时间造成影响,也不需要像 Session 一样一直保持在内存中以应对大量的请求。

image.png

php版本简单实现没有刷新token


namespace App\Http\Controllers\Open\Auth;

use App\Http\Controllers\Controller;
use App\Http\Response\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Redis;
use Tymon\JWTAuth\Facades\JWTAuth;
use App\Http\Services\Clients\ClientsService;
use App\Enums\SystemErrorEnum;
use Illuminate\Support\Str;
use App\Constants\RedisKey;
use Illuminate\Support\Facades\Log;


class AuthController extends Controller
{
    public function __construct(
        private readonly ClientsService $clientsService
    ) {}

    /**
     * 获取 Token
     *
     * @param Request $request
     * @return JsonResponse
     */
    public function getToken(Request $request): JsonResponse
    {
        // 刷新阈值:过期前 2 天内刷新 Token
        $refreshThreshold = 172800; // 2 天
        $effectiveTime = 1296000; // 15 天有效期
        $request->validate([
            'appkey' => 'required|string',
            'appsecret' => 'required|string',
        ]);
        $appkey = $request->appkey;
        // 验证客户端凭证
        $clientInfo = $this->clientsService->getClientAppSecret($appkey);
        if (!$clientInfo || !hash_equals($clientInfo->appsecret, $request->appsecret)) {
            return ApiResponse::fail(SystemErrorEnum::AUTH_TOKEN_ERR->value, SystemErrorEnum::AUTH_TOKEN_ERR->getMessage());
        }

        $now = time();
        $oldToken = Redis::get(RedisKey::CLIENT_TOKEN_APPKEY . $appkey);
        $shouldRefresh = false;
        $accessToken = $oldToken; // 默认返回旧 Token
        $expireTime = null;

        // 检查是否需要刷新 Token
        if ($oldToken) {
            try {
                $payload = JWTAuth::setToken($oldToken)->getPayload();
                $remainingTime = $payload['exp'] - $now;

                if ($remainingTime <= $refreshThreshold) {
                    $shouldRefresh = true;
                } else {
                    $expireTime = $payload['exp']; // 旧 Token 的过期时间
                }
            } catch (\Tymon\JWTAuth\Exceptions\TokenExpiredException $e) {
                // Token 已过期,强制生成新 Token
                $oldToken = null;
                $shouldRefresh = true;
            } catch (\Tymon\JWTAuth\Exceptions\TokenInvalidException $e) {
                // Token 无效
                return ApiResponse::fail(SystemErrorEnum::AUTH_TOKEN_INVALID->value, SystemErrorEnum::AUTH_TOKEN_INVALID->getMessage());
            } catch (\Tymon\JWTAuth\Exceptions\JWTException $e) {
                // 其他 JWT 异常
                return ApiResponse::fail(SystemErrorEnum::AUTH_TOKEN_EXPIRED->value, SystemErrorEnum::AUTH_TOKEN_EXPIRED->getMessage());
            }
        } else {
            // 如果没有旧 Token,生成新 Token
            $shouldRefresh = true;
        }

        // 生成新 Token
        if ($shouldRefresh) {
            $expireTime = $now + $effectiveTime; // 15 天有效期
            $newPayload = [
                'client_no' => $clientInfo->client_no,
                'ak' => $appkey,
                'iat' => $now,
                'exp' => $expireTime,
                'jti' => Str::uuid(), // 唯一标识
            ];

            $accessToken = JWTAuth::customClaims($newPayload)->fromUser($clientInfo);

            // 原子操作更新 Redis
            Redis::pipeline(function ($pipe) use ($appkey, $accessToken, $oldToken, $expireTime, $now, $effectiveTime) {
                $pipe->setex(RedisKey::CLIENT_TOKEN_APPKEY . $appkey, $effectiveTime, $accessToken);
                if ($oldToken) {
                    $remainingTime = $expireTime - $now;
                    $pipe->setex(RedisKey::CLIENT_TOKEN_BLACKLIST . $oldToken, $remainingTime, 1); // 将旧 Token 加入黑名单
                }
            });
        }

        return ApiResponse::success(
            data: [
                'access_token' => $accessToken,
                'expire' => $expireTime,
            ]
        );
    }

    /**
     * 使 Token 失效
     *
     * @param Request $request
     * @return JsonResponse
     */
    public function invalidateToken(Request $request)
    {
        try {
            // 获取当前 Token
            $token = $request->header('Authorization');
            if (!$token) {
                return ApiResponse::fail(SystemErrorEnum::PARAMS_ERROR->value, SystemErrorEnum::PARAMS_ERROR->getMessage());
            }

            // 验证 Token 并获取 Payload
            $payload = JWTAuth::setToken($token)->getPayload();
            $appkey = $payload['ak'] ?? null;

            if (!$appkey) {
                return ApiResponse::fail(SystemErrorEnum::AUTH_TOKEN_INVALID->value, SystemErrorEnum::AUTH_TOKEN_INVALID->getMessage());
            }

            // 将 Token 加入黑名单
            $remainingTime = $payload['exp'] - time(); // 计算剩余有效期
            if ($remainingTime > 0) {
                Redis::setex(RedisKey::CLIENT_TOKEN_BLACKLIST . $token, $remainingTime, 1);
            }

            // 删除 Redis 中的 Token
            Redis::del(RedisKey::CLIENT_TOKEN_APPKEY . $appkey);
            return ApiResponse::success();
        } catch (\Exception $e) {
            Log::error('invalidateToken error', [
                'message' => $e->getMessage(),
                'trace' => $e->getTraceAsString(),
            ]);
            return ApiResponse::fail(SystemErrorEnum::AUTH_TOKEN_ERR->value, SystemErrorEnum::AUTH_TOKEN_ERR->getMessage());
        }
    }
}

middleware 验证token


namespace App\Http\Middleware\OpenPlatform;

use Closure;
use Illuminate\Http\Request;
use Tymon\JWTAuth\Facades\JWTAuth;
use Tymon\JWTAuth\Exceptions\JWTException;
use Illuminate\Support\Facades\Redis;
use App\Enums\SystemErrorEnum;
use App\Http\Response\ApiResponse;
use App\Constants\RedisKey;
use Illuminate\Support\Facades\Log;

class VerifyOpenToken
{
    public function handle(Request $request, Closure $next)
    {
        $token = $request->header('Authorization');
        if (!$token) {
            return ApiResponse::fail(SystemErrorEnum::AUTH_TOKEN_ERR->value, SystemErrorEnum::AUTH_TOKEN_ERR->getMessage());
        }

        try {
            // 验证 Token
            $payload = JWTAuth::setToken($token)->getPayload();
            $appkey = $payload['ak'];
            $clientNo = $payload['client_no'];

            // 检查 Redis 中是否存在该 Token
            $storedToken = Redis::get(RedisKey::CLIENT_TOKEN_APPKEY . $appkey);
            if (!$storedToken || $storedToken !== $token) {
                Log::info('token_no_match', [
                    'appkey' => $appkey,
                    'client_no' => $clientNo,
                    'token' => $token,
                    'storedToken' => $storedToken
                ]);
                return ApiResponse::fail(SystemErrorEnum::AUTH_TOKEN_ERR->value, SystemErrorEnum::AUTH_TOKEN_ERR->getMessage());
            }

            // 检查黑名单
            if (Redis::exists(RedisKey::CLIENT_TOKEN_BLACKLIST . $token)) {
                Log::info('token_blacklist', [
                    'token' => $token,
                    'client_no' => $clientNo
                ]);
                // return response()->json(['message' => 'Token is blacklisted'], 401);
                return ApiResponse::fail(SystemErrorEnum::AUTH_TOKEN_ERR->value, SystemErrorEnum::AUTH_TOKEN_ERR->getMessage());
            }

            // 将 client_no 注入到请求中,方便后续使用
            $request->headers->add([
                'client_no' =>  $clientNo
            ]);

            return $next($request);
        } catch (\Tymon\JWTAuth\Exceptions\TokenExpiredException $e) {
            return ApiResponse::fail(SystemErrorEnum::AUTH_TOKEN_EXPIRED->value, SystemErrorEnum::AUTH_TOKEN_EXPIRED->getMessage());
        } catch (\Tymon\JWTAuth\Exceptions\TokenInvalidException $e) {
            //return response()->json(['message' => 'Token is invalid'], 401);
            return ApiResponse::fail(SystemErrorEnum::AUTH_TOKEN_EXPIRED->value, SystemErrorEnum::AUTH_TOKEN_EXPIRED->getMessage());
        } catch (JWTException $e) {
            //return response()->json(['message' => 'Token error'], 401);
            return ApiResponse::fail(SystemErrorEnum::AUTH_TOKEN_ERR->value, SystemErrorEnum::AUTH_ERROR->getMessage());
        }
    }
}