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.
Files changed (3) hide show
  1. package/package.json +39 -0
  2. package/src/index.js +18 -0
  3. 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
+ }