feat 增加消费token
This commit is contained in:
@@ -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
68
pc/utils/consumeToken.js
Normal 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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 是否免登录验证
|
||||
|
||||
103
server/application/common/server/ConsumeTokenService.php
Normal file
103
server/application/common/server/ConsumeTokenService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
17
server/config/consume_token.php
Normal file
17
server/config/consume_token.php
Normal 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),
|
||||
];
|
||||
79
uniapp/utils/consumeToken.js
Normal file
79
uniapp/utils/consumeToken.js
Normal 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()
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user