james-api-sign 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +39 -0
- package/src/index.js +18 -0
- package/src/signer.js +149 -0
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "james-api-sign",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Axios request interceptor with HMAC-SHA256 API signing (X-Access-Key/X-Timestamp/X-Nonce/X-Sign), Bearer token and comid header injection. Zero-config by default, fully configurable.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"module": "./src/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./src/index.js",
|
|
11
|
+
"default": "./src/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"axios",
|
|
20
|
+
"interceptor",
|
|
21
|
+
"hmac",
|
|
22
|
+
"sha256",
|
|
23
|
+
"api-sign",
|
|
24
|
+
"signature",
|
|
25
|
+
"request-signing"
|
|
26
|
+
],
|
|
27
|
+
"author": "james.xu",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=14"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"crypto-js": "^4.2.0"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public",
|
|
37
|
+
"registry": "https://registry.npmjs.org/"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* james-api-sign
|
|
3
|
+
* 开放接口 HMAC-SHA256 请求签名封装
|
|
4
|
+
*
|
|
5
|
+
* 使用:
|
|
6
|
+
* import { applyApiSign } from 'james-api-sign'
|
|
7
|
+
* // 在 axios 请求拦截器中调用
|
|
8
|
+
* applyApiSign(config)
|
|
9
|
+
*/
|
|
10
|
+
export {
|
|
11
|
+
applyApiSign,
|
|
12
|
+
sha256HexUpper,
|
|
13
|
+
hmacSha256HexUpper,
|
|
14
|
+
randomNonce,
|
|
15
|
+
parseQueryString,
|
|
16
|
+
sortedQueryParams,
|
|
17
|
+
defaultSignOptions,
|
|
18
|
+
} from './signer.js'
|
package/src/signer.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 开放接口 HMAC-SHA256 签名核心逻辑
|
|
3
|
+
* 与后端 ApiSignInterceptor 校验规则一一对应:
|
|
4
|
+
*
|
|
5
|
+
* canonicalString = HTTP_METHOD + "\n"
|
|
6
|
+
* + requestPath(不含 context-path/query) + "\n"
|
|
7
|
+
* + sortedQueryParams("k=v" 按key字典序排序, & 连接) + "\n"
|
|
8
|
+
* + sha256Hex(requestBody) + "\n"
|
|
9
|
+
* + timestamp + "\n"
|
|
10
|
+
* + nonce
|
|
11
|
+
* sign = hexUpper( HMAC-SHA256( accessSecret, canonicalString ) )
|
|
12
|
+
*
|
|
13
|
+
* 环境变量控制(包以 ESM 源码发布,VITE_* 会在使用方的 Vite 构建期被静态替换):
|
|
14
|
+
* VITE_API_SIGN_ENABLED 是否开启(默认 true,后端未开启时多发几个请求头无副作用)
|
|
15
|
+
* VITE_API_ACCESS_KEY 访问标识(对应 PHARMACY_CONFIG.access_key)
|
|
16
|
+
* VITE_API_ACCESS_SECRET 访问密钥
|
|
17
|
+
*
|
|
18
|
+
* james.xu 2026.09.14
|
|
19
|
+
*/
|
|
20
|
+
import CryptoJS from 'crypto-js'
|
|
21
|
+
|
|
22
|
+
/** 读取使用方构建期注入的 Vite 环境变量;非 Vite 环境下返回 undefined */
|
|
23
|
+
function readViteEnv(key) {
|
|
24
|
+
return typeof import.meta !== 'undefined' && import.meta.env ? import.meta.env[key] : undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 默认签名配置,优先级:调用方传参 > VITE_* 环境变量 > 内置默认值 */
|
|
28
|
+
export const defaultSignOptions = {
|
|
29
|
+
enabled: (readViteEnv('VITE_API_SIGN_ENABLED') ?? 'true') !== 'false',
|
|
30
|
+
accessKey: readViteEnv('VITE_API_ACCESS_KEY') || '1301',
|
|
31
|
+
accessSecret: readViteEnv('VITE_API_ACCESS_SECRET') || 'Z1OGF13RJUA2LTCS599DQ6ZOB8W4JNL2',
|
|
32
|
+
contextPath: '/api/rxcp',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** SHA-256 摘要,大写 hex;空内容返回空串(与后端约定一致) */
|
|
36
|
+
export function sha256HexUpper(text) {
|
|
37
|
+
if (!text) return ''
|
|
38
|
+
return CryptoJS.SHA256(text).toString(CryptoJS.enc.Hex).toUpperCase()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** HMAC-SHA256 签名,大写 hex */
|
|
42
|
+
export function hmacSha256HexUpper(secret, content) {
|
|
43
|
+
return CryptoJS.HmacSHA256(content, secret).toString(CryptoJS.enc.Hex).toUpperCase()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 生成随机 nonce(32位hex) */
|
|
47
|
+
export function randomNonce() {
|
|
48
|
+
return CryptoJS.lib.WordArray.random(16).toString(CryptoJS.enc.Hex)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 解析 url 中的 query 串为键值对(多值取第一个,与后端约定一致) */
|
|
52
|
+
export function parseQueryString(queryString) {
|
|
53
|
+
const params = {}
|
|
54
|
+
if (!queryString) return params
|
|
55
|
+
queryString.split('&').forEach(pair => {
|
|
56
|
+
if (!pair) return
|
|
57
|
+
const idx = pair.indexOf('=')
|
|
58
|
+
const key = idx >= 0 ? pair.substring(0, idx) : pair
|
|
59
|
+
const value = idx >= 0 ? pair.substring(idx + 1) : ''
|
|
60
|
+
if (!(key in params)) {
|
|
61
|
+
params[decodeURIComponent(key)] = decodeURIComponent(value)
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
return params
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** query 参数按 key 字典序排序,以 k=v&k=v 形式拼接(与后端 HmacSignUtils 一致) */
|
|
68
|
+
export function sortedQueryParams(params) {
|
|
69
|
+
const keys = Object.keys(params).sort()
|
|
70
|
+
const parts = []
|
|
71
|
+
keys.forEach(key => {
|
|
72
|
+
const value = params[key]
|
|
73
|
+
if (value === undefined || value === null) return
|
|
74
|
+
parts.push(`${key}=${Array.isArray(value) ? value[0] : value}`)
|
|
75
|
+
})
|
|
76
|
+
return parts.join('&')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 为 axios 请求配置追加签名请求头
|
|
81
|
+
* 仅处理 url 以 contextPath 开头的请求,其余请求原样返回
|
|
82
|
+
*
|
|
83
|
+
* 注意:POST/PUT 的 body 在此被序列化为字符串并写回 config.data,
|
|
84
|
+
* 保证签名摘要与实际发送的字节完全一致
|
|
85
|
+
*
|
|
86
|
+
* @param {object} axiosConfig axios 请求配置(会被原地修改)
|
|
87
|
+
* @param {object|boolean} [options] 签名配置;传 false 表示关闭签名
|
|
88
|
+
* @param {boolean} [options.enabled]
|
|
89
|
+
* @param {string} [options.accessKey]
|
|
90
|
+
* @param {string} [options.accessSecret]
|
|
91
|
+
* @param {string} [options.contextPath]
|
|
92
|
+
* @returns {object} 修改后的 axiosConfig
|
|
93
|
+
*/
|
|
94
|
+
export function applyApiSign(axiosConfig, options) {
|
|
95
|
+
if (options === false) return axiosConfig
|
|
96
|
+
const cfg = Object.assign({}, defaultSignOptions, options || {})
|
|
97
|
+
if (!cfg.enabled || !axiosConfig.url || !axiosConfig.url.startsWith(cfg.contextPath)) {
|
|
98
|
+
return axiosConfig
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 拆分 url 中的 path 与 query
|
|
102
|
+
const qIndex = axiosConfig.url.indexOf('?')
|
|
103
|
+
const fullPath = qIndex >= 0 ? axiosConfig.url.substring(0, qIndex) : axiosConfig.url
|
|
104
|
+
const urlQuery = qIndex >= 0 ? axiosConfig.url.substring(qIndex + 1) : ''
|
|
105
|
+
|
|
106
|
+
// 签名路径 = 请求路径去掉 context-path(与后端拦截器 resolveSignPath 一致)
|
|
107
|
+
const signPath = fullPath.startsWith(cfg.contextPath) ? fullPath.substring(cfg.contextPath.length) : fullPath
|
|
108
|
+
|
|
109
|
+
// 合并 url query 与 axios params(同名时 url 中的优先,仅影响签名,不影响实际发送)
|
|
110
|
+
const queryParams = Object.assign({}, parseQueryString(urlQuery))
|
|
111
|
+
if (axiosConfig.params) {
|
|
112
|
+
Object.keys(axiosConfig.params).forEach(key => {
|
|
113
|
+
if (!(key in queryParams)) {
|
|
114
|
+
queryParams[key] = axiosConfig.params[key]
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// body 统一序列化为字符串,签名与发送使用同一份内容
|
|
120
|
+
let bodyText = ''
|
|
121
|
+
axiosConfig.headers = axiosConfig.headers || {}
|
|
122
|
+
if (axiosConfig.data !== undefined && axiosConfig.data !== null) {
|
|
123
|
+
bodyText = typeof axiosConfig.data === 'string' ? axiosConfig.data : JSON.stringify(axiosConfig.data)
|
|
124
|
+
axiosConfig.data = bodyText
|
|
125
|
+
// data 为字符串时 axios 默认会发 application/x-www-form-urlencoded,需显式指定 JSON
|
|
126
|
+
if (!axiosConfig.headers['Content-Type'] && !axiosConfig.headers['content-type']) {
|
|
127
|
+
axiosConfig.headers['Content-Type'] = 'application/json;charset=UTF-8'
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const timestamp = Date.now()
|
|
132
|
+
const nonce = randomNonce()
|
|
133
|
+
const canonical = [
|
|
134
|
+
(axiosConfig.method || 'get').toUpperCase(),
|
|
135
|
+
signPath,
|
|
136
|
+
sortedQueryParams(queryParams),
|
|
137
|
+
sha256HexUpper(bodyText),
|
|
138
|
+
String(timestamp),
|
|
139
|
+
nonce,
|
|
140
|
+
].join('\n')
|
|
141
|
+
|
|
142
|
+
const sign = hmacSha256HexUpper(cfg.accessSecret, canonical)
|
|
143
|
+
|
|
144
|
+
axiosConfig.headers['X-Access-Key'] = cfg.accessKey
|
|
145
|
+
axiosConfig.headers['X-Timestamp'] = String(timestamp)
|
|
146
|
+
axiosConfig.headers['X-Nonce'] = nonce
|
|
147
|
+
axiosConfig.headers['X-Sign'] = sign
|
|
148
|
+
return axiosConfig
|
|
149
|
+
}
|