添加authToken和userToken

This commit is contained in:
2026-03-05 12:17:20 +08:00
parent a10afa5add
commit 13d8adbfe0
11 changed files with 290 additions and 210 deletions

View File

@@ -17,10 +17,14 @@ REDIS_PASSWORD = ''
REDIS_DB = 0
# API 鉴权与用户(可选,不填则用默认值)
# API_AUTH_TOKEN_EXP = 86400
# authToken 签名密钥(必填,与客户端约定,用于 signature 校验)
API_AUTH_TOKEN_SECRET = xF75oK91TQj13s0UmNIr1NBWMWGfflNO
# authToken 时间戳允许误差秒数,防重放,默认 300
API_AUTH_TOKEN_TIME_TOLERANCE = 300
API_AUTH_TOKEN_EXP = 86400
# API_USER_TOKEN_EXP = 604800
# API_USER_CACHE_EXPIRE = 604800
# API_USER_ENCRYPT_KEY = dafuweng_api_user_cache_key_32
API_USER_CACHE_EXPIRE = 86400
API_USER_ENCRYPT_KEY = Wj818SK8dhKBKNOY3PUTmZfhQDMCXEZi
# 验证码配置,支持cache|session
CAPTCHA_MODE = cache

View File

@@ -7,23 +7,61 @@ use support\Request;
use support\Response;
use Tinywan\Jwt\JwtToken;
use plugin\saiadmin\basic\OpenController;
use app\api\util\ReturnCode;
/**
* API 鉴权 Token 接口
* 仅支持 GET必传参数signature、secret、device、time签名规则signature = md5(device . secret . time)
* 后续所有 /api 接口调用均需在请求头携带此接口返回的 auth-token
*/
class AuthTokenController extends OpenController
{
/**
* 获取 auth-token
* GET 或 POST /api/authToken
* GET /api/authToken
* 参数signature签名、secret密钥、device设备标识、time时间戳四者均为必传且非空
*/
public function index(Request $request): Response
{
if (strtoupper($request->method()) !== 'GET') {
return $this->fail('仅支持 GET 请求', ReturnCode::EMPTY_PARAMS);
}
$param = $request->get();
$signature = trim((string) ($param['signature'] ?? ''));
$secret = trim((string) ($param['secret'] ?? ''));
$device = trim((string) ($param['device'] ?? ''));
$time = trim((string) ($param['time'] ?? ''));
if ($signature === '' || $secret === '' || $device === '' || $time === '') {
return $this->fail('signature、secret、device、time 均为必传且不能为空', ReturnCode::EMPTY_PARAMS);
}
$serverSecret = trim((string) config('api.auth_token_secret', ''));
if ($serverSecret === '') {
return $this->fail('服务未配置 API_AUTH_TOKEN_SECRET', ReturnCode::EMPTY_PARAMS);
}
if ($secret !== $serverSecret) {
return $this->fail('密钥错误', ReturnCode::EMPTY_PARAMS);
}
$tolerance = (int) config('api.auth_token_time_tolerance', 300);
$now = time();
$ts = is_numeric($time) ? (int) $time : 0;
if ($ts <= 0 || abs($now - $ts) > $tolerance) {
return $this->fail('时间戳无效或已过期', ReturnCode::EMPTY_PARAMS);
}
$sign = $this->getAuthToken($device, $serverSecret, $time);
if ($sign !== $signature) {
return $this->fail('签名验证失败', ReturnCode::EMPTY_PARAMS);
}
$exp = config('api.auth_token_exp', 86400);
$tokenResult = JwtToken::generateToken([
'id' => 0,
'plat' => 'api',
'device' => $device,
'access_exp' => $exp,
]);
@@ -32,4 +70,17 @@ class AuthTokenController extends OpenController
'expires_in' => $tokenResult['expires_in'],
]);
}
/**
* 生成签名signature = md5(device . secret . time)
*
* @param string $device 设备标识
* @param string $secret 密钥(来自配置)
* @param string $time 时间戳
* @return string
*/
private function getAuthToken(string $device, string $secret, string $time): string
{
return md5($device . $secret . $time);
}
}

View File

@@ -5,9 +5,9 @@ namespace app\api\controller;
use support\Request;
use support\Response;
use app\api\logic\UserLogic;
use app\api\logic\GameLogic;
use app\api\logic\PlayStartLogic;
use app\api\logic\UserLogic;
use app\api\util\ReturnCode;
use app\dice\model\play_record\DicePlayRecord;
use app\dice\model\player\DicePlayer;
@@ -23,27 +23,12 @@ class GameController extends OpenController
/**
* 购买抽奖券
* POST /api/game/buyLotteryTickets
* header: auth-token, user-token
* header: auth-token, user-token(由 CheckUserTokenMiddleware 注入 request->user_id
* body: count = 1 | 5 | 101次/100coin, 5次/500coin, 10次/1000coin
* 记录钱包流水,并更新缓存中玩家的 total_draw_count、paid_draw_count、free_draw_count、coin
*/
public function buyLotteryTickets(Request $request): Response
{
$token = $request->header('user-token');
if (empty($token)) {
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
$token = trim(substr($auth, 7));
}
}
if (empty($token)) {
return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN);
}
$userId = UserLogic::getUserIdFromToken($token);
if ($userId === null) {
return $this->fail('user-token 无效或已过期', ReturnCode::TOKEN_TIMEOUT);
}
$userId = UserLogic::getUserIdFromRequest($request) ?? 0;
$count = (int) $request->post('count', 0);
if (!in_array($count, [1, 5, 10], true)) {
return $this->fail('购买抽奖券错误', ReturnCode::EMPTY_PARAMS);
@@ -79,27 +64,12 @@ class GameController extends OpenController
/**
* 开始游戏(抽奖一局)
* POST /api/game/playStart
* header: auth-token, user-token
* header: auth-token, user-token(由 CheckUserTokenMiddleware 注入 request->user_id
* body: rediction 必传0=无 1=中奖
* 余额不足时返回 code=200、message=玩家当前余额不足无法开启对局;超时返回 code=200、message=服务超时,并记录 status=0
*/
public function playStart(Request $request): Response
{
$token = $request->header('user-token');
if (empty($token)) {
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
$token = trim(substr($auth, 7));
}
}
if (empty($token)) {
return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN);
}
$userId = UserLogic::getUserIdFromToken($token);
if ($userId === null) {
return $this->fail('user-token 无效或已过期', ReturnCode::TOKEN_TIMEOUT);
}
$userId = UserLogic::getUserIdFromRequest($request) ?? 0;
$rediction = $request->post('rediction');
if ($rediction === '' || $rediction === null) {
return $this->fail('请传递 rediction 参数', ReturnCode::EMPTY_PARAMS);

View File

@@ -64,49 +64,26 @@ class UserController extends OpenController
/**
* 退出登录
* POST /api/user/logout
* header: user-token或 Authorization: Bearer <user-token>
* 将当前 user-token 加入黑名单,之后该 token 无法再用于获取 user_id
* header: user-token由 CheckUserTokenMiddleware 校验并注入 request->userToken
*/
public function logout(Request $request): Response
{
$token = $request->header('user-token');
if (empty($token)) {
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
$token = trim(substr($auth, 7));
}
}
if (empty($token)) {
return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN);
}
if (UserLogic::logout($token)) {
return $this->success('已退出登录');
}
$token = $request->userToken ?? UserLogic::getTokenFromRequest($request);
if ($token === '' || !UserLogic::logout($token)) {
return $this->fail('退出失败或 token 已失效', ReturnCode::TOKEN_TIMEOUT);
}
return $this->success('已退出登录');
}
/**
* 获取当前用户信息
* GET /api/user/info
* header: user-token或 Authorization: Bearer <user-token>
* header: user-token由 CheckUserTokenMiddleware 校验并注入 request->user_id
* 返回id, username, phone, uid, name, coin, total_draw_count
*/
public function info(Request $request): Response
{
$token = $request->header('user-token');
if (empty($token)) {
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
$token = trim(substr($auth, 7));
}
}
if (empty($token)) {
return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN);
}
$userId = UserLogic::getUserIdFromToken($token);
if ($userId === null) {
return $this->fail('user-token 无效或已过期', ReturnCode::TOKEN_TIMEOUT);
}
$userId = UserLogic::getUserIdFromRequest($request) ?? 0;
$user = UserLogic::getCachedUser($userId);
if (empty($user)) {
return $this->fail('用户不存在', ReturnCode::EMPTY_PARAMS);
@@ -122,30 +99,16 @@ class UserController extends OpenController
}
/**
* 获取钱包余额(读缓存,不查库,低延迟
* 获取钱包余额(优先读缓存,缓存未命中时从库拉取并回写缓存
* GET /api/user/balance
* header: user-token或 Authorization: Bearer <user-token>
* 返回coin, phone, username登录时已写入缓存本接口只从缓存读取
* header: user-token由 CheckUserTokenMiddleware 校验并注入 request->user_id
*/
public function balance(Request $request): Response
{
$token = $request->header('user-token');
if (empty($token)) {
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
$token = trim(substr($auth, 7));
}
}
if (empty($token)) {
return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN);
}
$userId = UserLogic::getUserIdFromToken($token);
if ($userId === null) {
return $this->fail('user-token 无效或已过期', ReturnCode::TOKEN_TIMEOUT);
}
$user = UserCache::getUser($userId);
$userId = UserLogic::getUserIdFromRequest($request) ?? 0;
$user = UserLogic::getCachedUser($userId);
if (empty($user)) {
return $this->fail('缓存已过期,请重新登录', ReturnCode::TOKEN_TIMEOUT);
return $this->fail('用户不存在', ReturnCode::EMPTY_PARAMS);
}
$coin = $user['coin'] ?? 0;
if (is_string($coin) && is_numeric($coin)) {
@@ -161,26 +124,12 @@ class UserController extends OpenController
/**
* 玩家钱包流水
* GET /api/user/walletRecord
* header: user-token或 Authorization: Bearer <user-token>
* header: user-token由 CheckUserTokenMiddleware 校验并注入 request->user_id
* 参数: page 页码默认1, limit 每页条数默认10, create_time_min/create_time_max 创建时间范围(可选)
*/
public function walletRecord(Request $request): Response
{
$token = $request->header('user-token');
if (empty($token)) {
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
$token = trim(substr($auth, 7));
}
}
if (empty($token)) {
return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN);
}
$userId = UserLogic::getUserIdFromToken($token);
if ($userId === null) {
return $this->fail('user-token 无效或已过期', ReturnCode::TOKEN_TIMEOUT);
}
$userId = UserLogic::getUserIdFromRequest($request) ?? 0;
$page = (int) $request->post('page', 1);
$limit = (int) $request->post('limit', 10);
if ($page < 1) {
@@ -217,26 +166,12 @@ class UserController extends OpenController
/**
* 游玩记录
* GET /api/user/playGameRecord
* header: user-token或 Authorization: Bearer <user-token>
* header: user-token由 CheckUserTokenMiddleware 校验并注入 request->user_id
* 参数: page 页码默认1, limit 每页条数默认10, create_time_min/create_time_max 创建时间范围(可选)
*/
public function playGameRecord(Request $request): Response
{
$token = $request->header('user-token');
if (empty($token)) {
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
$token = trim(substr($auth, 7));
}
}
if (empty($token)) {
return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN);
}
$userId = UserLogic::getUserIdFromToken($token);
if ($userId === null) {
return $this->fail('user-token 无效或已过期', ReturnCode::TOKEN_TIMEOUT);
}
$userId = UserLogic::getUserIdFromRequest($request) ?? 0;
$page = (int) $request->post('page', 1);
$limit = (int) $request->post('limit', 10);
if ($page < 1) {

View File

@@ -123,6 +123,41 @@ class UserLogic
return $result['access_token'];
}
/**
* 从请求中解析 user-tokenheader: user-token 或 Authorization: Bearer
* @param object $request 需有 header(string $name) 方法
*/
public static function getTokenFromRequest(object $request): string
{
$token = $request->header('user-token') ?? '';
if ($token !== '') {
return trim((string) $token);
}
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
return trim(substr($auth, 7));
}
return '';
}
/**
* 从请求获取当前用户 ID优先 request->user_id否则从 header 的 user-token 解析
* 中间件未正确注入时仍可兜底解析
* @param object $request 需有 user_id 属性及 header() 方法
*/
public static function getUserIdFromRequest(object $request): ?int
{
$id = $request->user_id ?? null;
if ($id !== null && (int) $id > 0) {
return (int) $id;
}
$token = self::getTokenFromRequest($request);
if ($token === '') {
return null;
}
return self::getUserIdFromToken($token);
}
/**
* 根据 user-token 获取 user_id不写缓存仅解析 JWT
* 若 token 已通过退出接口加入黑名单,返回 null

View File

@@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace app\api\middleware;
use support\Log;
use Webman\Http\Request;
use Webman\Http\Response;
use Webman\MiddlewareInterface;
use Tinywan\Jwt\JwtToken;
use Tinywan\Jwt\Exception\JwtTokenException;
use Tinywan\Jwt\Exception\JwtTokenExpiredException;
use app\api\util\ReturnCode;
use plugin\saiadmin\exception\ApiException;
/**
* API 鉴权中间件
* 校验请求头 auth-token或 Authorization: Bearer xxx白名单路径不校验
*/
class CheckApiAuthMiddleware implements MiddlewareInterface
{
/** 不需要 auth-token 的路径(仅获取 token 的接口) */
private const WHITELIST = [
'api/authToken',
];
public function process(Request $request, callable $handler): Response
{
$path = trim($request->path(), '/');
if ($this->isWhitelist($path)) {
return $handler($request);
}
$token = $request->header('auth-token');
if (empty($token)) {
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
$token = trim(substr($auth, 7));
}
}
if (empty($token)) {
throw new ApiException('请携带 auth-token', ReturnCode::MISSING_TOKEN);
}
try {
// ACCESS_TOKEN = 1JwtToken 内部私有常量)
$decoded = JwtToken::verify(1, $token);
$extend = $decoded['extend'] ?? [];
if (($extend['plat'] ?? '') !== 'api') {
throw new ApiException('auth-token 无效', ReturnCode::TOKEN_TIMEOUT);
}
} catch (JwtTokenExpiredException $e) {
Log::error('auth-token 已过期, 报错信息'. $e);
throw new ApiException('auth-token 已过期', ReturnCode::TOKEN_TIMEOUT);
} catch (JwtTokenException $e) {
Log::error('auth-token 无效, 报错信息'. $e);
throw new ApiException($e->getMessage() ?: 'auth-token 无效', ReturnCode::TOKEN_TIMEOUT);
} catch (\Throwable $e) {
Log::error('auth-token 校验失败, 报错信息'. $e);
throw new ApiException('auth-token 校验失败', ReturnCode::TOKEN_TIMEOUT);
}
return $handler($request);
}
private function isWhitelist(string $path): bool
{
foreach (self::WHITELIST as $prefix) {
if ($path === $prefix || str_starts_with($path, $prefix . '/')) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace app\api\middleware;
use support\Log;
use Webman\Http\Request;
use Webman\Http\Response;
use Webman\MiddlewareInterface;
use Tinywan\Jwt\JwtToken;
use Tinywan\Jwt\Exception\JwtTokenException;
use Tinywan\Jwt\Exception\JwtTokenExpiredException;
use app\api\util\ReturnCode;
use plugin\saiadmin\exception\ApiException;
/**
* 仅校验 auth-token 请求头
* 白名单路径(如 /api/authToken不校验其它接口必须携带有效 auth-token 或 Authorization: Bearer <token>,且必须通过 JWT 签名与 plat=api 校验
*/
class CheckAuthTokenMiddleware implements MiddlewareInterface
{
/** 不需要 auth-token 的路径 */
private const WHITELIST = [
'api/authToken',
];
/** JWT 至少为 xxx.yyy.zzz 三段 */
private const JWT_PARTS_MIN = 3;
public function process(Request $request, callable $handler): Response
{
$path = trim((string) $request->path(), '/');
if ($this->isWhitelist($path)) {
return $handler($request);
}
$token = $this->getAuthTokenFromRequest($request);
if ($token === '') {
throw new ApiException('请携带 auth-token', ReturnCode::MISSING_TOKEN);
}
if (!$this->looksLikeJwt($token)) {
throw new ApiException('auth-token 格式无效', ReturnCode::TOKEN_TIMEOUT);
}
$decoded = $this->verifyAuthToken($token);
$extend = $decoded['extend'] ?? [];
if (($extend['plat'] ?? '') !== 'api') {
throw new ApiException('auth-token 无效(非 API 凭证)', ReturnCode::TOKEN_TIMEOUT);
}
return $handler($request);
}
private function getAuthTokenFromRequest(Request $request): string
{
$token = $request->header('auth-token');
if ($token !== null && $token !== '') {
return trim((string) $token);
}
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
return trim(substr($auth, 7));
}
return '';
}
private function looksLikeJwt(string $token): bool
{
$parts = explode('.', $token);
return count($parts) >= self::JWT_PARTS_MIN;
}
/**
* 校验 auth-token 有效性签名、过期、iss 等),无效或过期必抛 ApiException
*/
private function verifyAuthToken(string $token): array
{
try {
return JwtToken::verify(1, $token);
} catch (JwtTokenExpiredException $e) {
Log::error('auth-token 已过期, 报错信息' . $e);
throw new ApiException('auth-token 已过期', ReturnCode::TOKEN_TIMEOUT);
} catch (JwtTokenException $e) {
Log::error('auth-token 无效, 报错信息' . $e);
throw new ApiException($e->getMessage() ?: 'auth-token 无效', ReturnCode::TOKEN_TIMEOUT);
} catch (\Throwable $e) {
Log::error('auth-token 校验失败, 报错信息' . $e);
throw new ApiException('auth-token 校验失败', ReturnCode::TOKEN_TIMEOUT);
}
}
private function isWhitelist(string $path): bool
{
foreach (self::WHITELIST as $prefix) {
if ($path === $prefix || str_starts_with($path, $prefix . '/')) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace app\api\middleware;
use Webman\Http\Request;
use Webman\Http\Response;
use Webman\MiddlewareInterface;
use app\api\logic\UserLogic;
use app\api\util\ReturnCode;
use plugin\saiadmin\exception\ApiException;
/**
* 校验 user-token 请求头
* 从 header 读取 user-token 或 Authorization: Bearer <user-token>,校验通过后将 user_id、userToken 写入 request 供控制器使用
*/
class CheckUserTokenMiddleware implements MiddlewareInterface
{
public function process(Request $request, callable $handler): Response
{
$token = $request->header('user-token');
if (empty($token)) {
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
$token = trim(substr($auth, 7));
}
}
if (empty($token)) {
throw new ApiException('请携带 user-token', ReturnCode::MISSING_TOKEN);
}
$userId = UserLogic::getUserIdFromToken($token);
if ($userId === null) {
throw new ApiException('user-token 无效或已过期', ReturnCode::TOKEN_TIMEOUT);
}
$request->user_id = $userId;
$request->userToken = $token;
return $handler($request);
}
}

View File

@@ -3,6 +3,10 @@
* API 鉴权与用户相关配置
*/
return [
// auth-token 签名密钥(与客户端约定,用于 /api/authToken 的 signature 校验,必填)
'auth_token_secret' => env('API_AUTH_TOKEN_SECRET', ''),
// auth-token 时间戳允许误差(秒),防重放,默认 300 秒
'auth_token_time_tolerance' => (int) env('API_AUTH_TOKEN_TIME_TOLERANCE', 300),
// auth-token 有效期(秒),默认 24 小时
'auth_token_exp' => (int) env('API_AUTH_TOKEN_EXP', 86400),
// user-token 有效期(秒),默认 7 天

View File

@@ -13,22 +13,29 @@
*/
use Webman\Route;
use app\api\middleware\CheckApiAuthMiddleware;
use app\api\middleware\CheckAuthTokenMiddleware;
use app\api\middleware\CheckUserTokenMiddleware;
// API 路由:需先调用 /api/authToken 获取 auth-token请求时携带 header: auth-token 或 Authorization: Bearer <token>
// 仅需 auth-token 的路由组(authToken 接口在中间件内白名单跳过)
Route::group('/api', function () {
Route::any('/authToken', [app\api\controller\AuthTokenController::class, 'index']);
Route::post('/user/login', [app\api\controller\UserController::class, 'login']);
Route::post('/user/register', [app\api\controller\UserController::class, 'register']);
Route::post('/user/logout', [app\api\controller\UserController::class, 'logout']);
Route::get('/user/info', [app\api\controller\UserController::class, 'info']);
Route::get('/user/balance', [app\api\controller\UserController::class, 'balance']);
Route::get('/user/walletRecord', [app\api\controller\UserController::class, 'walletRecord']);
Route::get('/user/playGameRecord', [app\api\controller\UserController::class, 'playGameRecord']);
Route::post('/game/buyLotteryTickets', [app\api\controller\GameController::class, 'buyLotteryTickets']);
Route::get('/game/lotteryPool', [app\api\controller\GameController::class, 'lotteryPool']);
Route::post('/game/playStart', [app\api\controller\GameController::class, 'playStart']);
})->middleware([CheckApiAuthMiddleware::class]);
Route::any('/user/login', [app\api\controller\UserController::class, 'login']);
Route::any('/user/register', [app\api\controller\UserController::class, 'register']);
})->middleware([
CheckAuthTokenMiddleware::class,
]);
// 需 auth-token + user-token 的路由组
Route::group('/api', function () {
Route::any('/user/logout', [app\api\controller\UserController::class, 'logout']);
Route::any('/user/info', [app\api\controller\UserController::class, 'info']);
Route::any('/user/balance', [app\api\controller\UserController::class, 'balance']);
Route::any('/user/walletRecord', [app\api\controller\UserController::class, 'walletRecord']);
Route::any('/user/playGameRecord', [app\api\controller\UserController::class, 'playGameRecord']);
Route::any('/game/buyLotteryTickets', [app\api\controller\GameController::class, 'buyLotteryTickets']);
Route::any('/game/lotteryPool', [app\api\controller\GameController::class, 'lotteryPool']);
Route::any('/game/playStart', [app\api\controller\GameController::class, 'playStart']);
})->middleware([
CheckAuthTokenMiddleware::class,
CheckUserTokenMiddleware::class,
]);

View File

@@ -20,6 +20,11 @@ namespace support;
*/
class Request extends \Webman\Http\Request
{
/** 由 CheckUserTokenMiddleware 注入:当前用户 ID */
public ?int $user_id = null;
/** 由 CheckUserTokenMiddleware 注入:当前 user-token 原始字符串 */
public ?string $userToken = null;
/**
* 获取参数增强方法