feat 增加消费token

This commit is contained in:
damonyuan
2026-07-22 17:19:36 +08:00
parent d539bdc73d
commit 221cdb4d26
9 changed files with 323 additions and 8 deletions

View File

@@ -1,6 +1,7 @@
import { Message } from 'element-ui';
import JSEncrypt from 'jsencrypt'
import config from '~/config/app'
import { createConsumeToken, clearConsumeToken } from '~/utils/consumeToken'
const passwordFields = [
'password', 'password2', 'password_confirm', 'passwordConfirm',
@@ -40,6 +41,9 @@ export default function ({ $axios, redirect, app, store }, inject) {
data.password_key_id = response.data.key_id || response.data.keyId
requestConfig.data = data
}
if (requestConfig.headers.token) {
requestConfig.headers['X-Consume-Token'] = await createConsumeToken(requestConfig.headers.token)
}
return requestConfig
})
$axios.onResponse((response) => {
@@ -48,6 +52,9 @@ export default function ({ $axios, redirect, app, store }, inject) {
show,
msg
} = response.data;
if (code == 0 && response.data.data && response.data.data.error === 'consume_token_invalid') {
clearConsumeToken()
}
if (code == 0 && show && msg) {
Message({
message: msg,

68
pc/utils/consumeToken.js Normal file
View File

@@ -0,0 +1,68 @@
import axios from 'axios'
import config from '~/config/app'
let cachedToken = ''
let cachedLoginToken = ''
let refreshAt = 0
let issuingPromise = null
let issuingLoginToken = ''
export async function createConsumeToken(loginToken) {
if (!loginToken) {
clearCache()
return ''
}
if (cachedLoginToken === loginToken && Date.now() < refreshAt) {
return cachedToken
}
if (issuingPromise && issuingLoginToken === loginToken) {
return issuingPromise
}
const promise = issue(loginToken)
issuingPromise = promise
issuingLoginToken = loginToken
try {
return await promise
} finally {
if (issuingPromise === promise) {
issuingPromise = null
issuingLoginToken = ''
}
}
}
async function issue(loginToken) {
const host = String(config.baseUrl || '').replace(/\/$/, '')
if (!host && typeof process !== 'undefined' && process.server) {
return ''
}
const response = await axios.post(`${host}/api/account/consumeToken`, {}, {
headers: { token: loginToken },
timeout: 10000
})
const result = response.data || {}
if (result.code === -1) {
return ''
}
if (!result || result.code !== 1) {
throw new Error(result && result.msg ? result.msg : '消费票据签发失败')
}
const token = result.data && result.data.token ? result.data.token : ''
const expiresIn = Number(result.data && result.data.expires_in) || 30
cachedLoginToken = loginToken
cachedToken = token
refreshAt = Date.now() + (token ? Math.max(expiresIn - 2, 1) : 30) * 1000
return token
}
export function clearConsumeToken() {
cachedToken = ''
cachedLoginToken = ''
refreshAt = 0
}
function clearCache() {
clearConsumeToken()
}

View File

@@ -13,4 +13,8 @@ hostport = 3306
[project]
env_name =
file_domain = www.likeshopv2.localhost
file_domain = www.likeshopv2.localhost
[consume_token]
enabled = false
ttl_seconds = 600

View File

@@ -23,6 +23,7 @@ use app\api\logic\LoginLogic;
use app\api\validate\WechatLoginValidate;
use app\common\server\CaptchaService;
use app\common\server\ConfigServer;
use app\common\server\ConsumeTokenService;
use app\common\server\PasswordCryptoService;
use Exception;
@@ -36,6 +37,17 @@ class Account extends ApiBase
$this->_success('OK', PasswordCryptoService::createSession());
}
/**
* Issue the short-lived ticket required by protected API requests.
*/
public function consumeToken()
{
$this->_success('OK', ConsumeTokenService::issue(
$this->request->header('token'),
$this->user_id
));
}
/**
* @notes 图形验证码
* @throws Exception

View File

@@ -22,6 +22,7 @@ namespace app\api\http\middleware;
use app\api\cache\TokenCache;
use app\api\validate\Token as TokenValidate;
use app\common\server\ConsumeTokenService;
use app\common\server\PasswordCryptoService;
class Login
@@ -37,7 +38,7 @@ class Login
//允许跨域调用
header('Access-Control-Allow-Origin: *');
header("Access-Control-Allow-Headers: Authorization, Sec-Fetch-Mode, DNT, X-Mx-ReqToken, Keep-Alive, User-Agent, If-Match, If-None-Match, If-Unmodified-Since, X-Requested-With, If-Modified-Since, Cache-Control, Content-Type, Accept-Language, Origin, Accept-Encoding,Access-Token,token");
header("Access-Control-Allow-Headers: Authorization, Sec-Fetch-Mode, DNT, X-Mx-ReqToken, Keep-Alive, User-Agent, If-Match, If-None-Match, If-Unmodified-Since, X-Requested-With, If-Modified-Since, Cache-Control, Content-Type, Accept-Language, Origin, Accept-Encoding,Access-Token,token,X-Consume-Token");
header('Access-Control-Allow-Methods: GET, POST, PATCH, PUT, DELETE, post');
header('Access-Control-Max-Age: 1728000');
header('Access-Control-Allow-Credentials:true');
@@ -69,6 +70,9 @@ class Login
//token缓存,直接通过
if (!$token_cache->isEmpty()) {
$request->user_info = $token_cache->get(600);
if (!$this->isNotNeedLogin($request) && !ConsumeTokenService::validate($request, $token, $request->user_info['id'] ?? 0)) {
return $this->consumeTokenError();
}
return $next($request);
}
@@ -77,6 +81,9 @@ class Login
$result = $token_validate->check(['token' => $token]);
if ($result === true) {
$request->user_info = $token_cache->set(600);
if (!$this->isNotNeedLogin($request) && !ConsumeTokenService::validate($request, $token, $request->user_info['id'] ?? 0)) {
return $this->consumeTokenError();
}
return $next($request);
}
@@ -90,6 +97,17 @@ class Login
}
private function consumeTokenError()
{
return json([
'code' => 0,
'msg' => '消费Token无效或已过期',
'data' => ['error' => 'consume_token_invalid'],
'show' => 1,
'time' => 0,
]);
}
/**
* 是否免登录验证

View File

@@ -0,0 +1,103 @@
<?php
namespace app\common\server;
use think\facade\Cache;
/**
* Issues short-lived tickets bound to the current API login session.
*/
class ConsumeTokenService
{
const CACHE_PREFIX = 'consume_token:';
const TICKET_PREFIX = 'consume_token_ticket:';
const VERSION = 'v1';
const MAX_TTL = 600;
public static function enabled()
{
return (bool) config('consume_token.enabled');
}
public static function ttl()
{
return min(max((int) config('consume_token.ttl'), 1), self::MAX_TTL);
}
public static function binding($login_token, $user_id)
{
return hash('sha256', 'api|' . (int) $user_id . '|' . hash('sha256', (string) $login_token));
}
public static function issue($login_token, $user_id)
{
$ttl = self::ttl();
if (!self::enabled()) {
return ['token' => '', 'expires_in' => $ttl];
}
$binding = self::binding($login_token, $user_id);
$cache_key = self::CACHE_PREFIX . $binding;
$cached = Cache::get($cache_key);
if (is_array($cached) && !empty($cached['token']) && !empty($cached['expires_at'])
&& $cached['expires_at'] > time() && self::validFormat($cached['token'])
&& self::ticketMatches($cached['token'], $binding)) {
return [
'token' => $cached['token'],
'expires_in' => max(1, $cached['expires_at'] - time()),
];
}
$token = self::create();
$expires_at = time() + $ttl;
$ticket = [
'binding' => $binding,
'expires_at' => $expires_at,
];
if (!Cache::set(self::TICKET_PREFIX . hash('sha256', $token), $ticket, $ttl)) {
throw new \RuntimeException('消费Token签发失败');
}
Cache::set($cache_key, ['token' => $token, 'expires_at' => $expires_at], $ttl);
return ['token' => $token, 'expires_in' => $ttl];
}
public static function validate($request, $login_token, $user_id)
{
if (!self::enabled() || self::isExcluded($request)) {
return true;
}
$token = $request->header('X-Consume-Token');
if (!is_string($token) || $token === '' || !self::validFormat($token)) {
return false;
}
$binding = self::binding($login_token, $user_id);
return self::ticketMatches($token, $binding);
}
private static function isExcluded($request)
{
return strtolower($request->controller()) === 'account'
&& strtolower($request->action()) === 'consumetoken';
}
private static function create()
{
return self::VERSION . '.' . rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
}
private static function validFormat($token)
{
return preg_match('/^' . self::VERSION . '\\.[A-Za-z0-9_-]{43}$/', $token) === 1;
}
private static function ticketMatches($token, $binding)
{
$ticket = Cache::get(self::TICKET_PREFIX . hash('sha256', $token));
return is_array($ticket) && !empty($ticket['binding']) && !empty($ticket['expires_at'])
&& $ticket['expires_at'] > time() && hash_equals($ticket['binding'], $binding);
}
}

View File

@@ -0,0 +1,17 @@
<?php
use think\facade\Env;
$enabled = Env::get('consume_token.enabled', false);
if (is_string($enabled)) {
$enabled = in_array(strtolower($enabled), ['1', 'true', 'on', 'yes'], true);
}
$ttl = (int) Env::get('consume_token.ttl_seconds', 600);
return [
// Disabled by default. Enable it in server/.env after all clients are updated.
'enabled' => (bool) $enabled,
// Keep the ticket short-lived even if a deployment sets an unsafe value.
'ttl' => min(max($ttl, 1), 600),
];

View File

@@ -0,0 +1,79 @@
import Cache from './cache'
import { TOKEN } from '../config/cachekey'
import { baseURL } from '../config/app'
let cachedToken = ''
let cachedLoginToken = ''
let refreshAt = 0
let issuingPromise = null
let issuingLoginToken = ''
export async function createConsumeToken(loginToken) {
if (!loginToken) {
clearCache()
return ''
}
if (cachedLoginToken === loginToken && Date.now() < refreshAt) {
return cachedToken
}
if (issuingPromise && issuingLoginToken === loginToken) {
return issuingPromise
}
const promise = issue(loginToken)
issuingPromise = promise
issuingLoginToken = loginToken
try {
return await promise
} finally {
if (issuingPromise === promise) {
issuingPromise = null
issuingLoginToken = ''
}
}
}
function issue(loginToken) {
return new Promise((resolve, reject) => {
const host = String(baseURL || '').replace(/\/$/, '')
uni.request({
url: `${host}/api/account/consumeToken`,
method: 'POST',
header: { token: loginToken },
success(response) {
const result = response.data || {}
if (result.code !== 1) {
if (result.code === -1) {
resolve('')
return
}
reject(new Error(result.msg || '消费票据签发失败'))
return
}
if (Cache.get(TOKEN) !== loginToken) {
resolve('')
return
}
const token = result.data && result.data.token ? result.data.token : ''
const expiresIn = Number(result.data && result.data.expires_in) || 30
cachedLoginToken = loginToken
cachedToken = token
refreshAt = Date.now() + (token ? Math.max(expiresIn - 2, 1) : 30) * 1000
resolve(token)
},
fail(error) {
reject(error)
}
})
})
}
export function clearConsumeToken() {
cachedToken = ''
cachedLoginToken = ''
refreshAt = 0
}
function clearCache() {
clearConsumeToken()
}

View File

@@ -3,9 +3,10 @@ import JSEncrypt from 'jsencrypt'
import store from '../store'
import { paramsToStr, currentPage, tabbarList, acountList } from './tools'
import Cache from './cache'
import { TOKEN, BACK_URL } from '../config/cachekey'
import { TOKEN, BACK_URL } from '../config/cachekey'
import { baseURL } from '../config/app'
import { getWxCode, toLogin, wxMnpLogin } from './login'
import { createConsumeToken, clearConsumeToken } from './consumeToken'
const passwordFields = [
'password', 'password2', 'password_confirm', 'passwordConfirm',
@@ -68,6 +69,9 @@ service.interceptors.request.use(
}
config.header = config.header || {}
config.header.token = config.header.token || Cache.get(TOKEN)
if (config.header.token) {
config.header['X-Consume-Token'] = await createConsumeToken(config.header.token)
}
return config
},
(error) => {
@@ -80,11 +84,14 @@ service.interceptors.request.use(
// response 拦截器
service.interceptors.response.use(
async (response) => {
if (response.data) {
const { code, show, msg } = response.data
const { route, options } = currentPage()
if (code == 0 && show && msg) {
uni.showToast({
if (response.data) {
const { code, show, msg, data } = response.data
const { route, options } = currentPage()
if (code == 0 && data && data.error === 'consume_token_invalid') {
clearConsumeToken()
}
if (code == 0 && show && msg) {
uni.showToast({
title: msg,
icon: 'none'
})