@kne/fastify-signature 1.0.2 → 1.0.4
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/libs/controllers/main.js +54 -14
- package/libs/schema/response.js +39 -0
- package/libs/services/main.js +53 -19
- package/package.json +1 -1
package/libs/controllers/main.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
const fp = require('fastify-plugin');
|
|
2
|
+
const { str, bool, any, success, empty, pageResult, STATUS, secret } = require('../schema/response');
|
|
3
|
+
|
|
4
|
+
const ADMIN_TAG = '签名管理后台-密钥';
|
|
5
|
+
const TAG = '签名-验签';
|
|
2
6
|
|
|
3
7
|
module.exports = fp(async (fastify, options) => {
|
|
4
8
|
const { services } = fastify[options.name];
|
|
@@ -9,7 +13,8 @@ module.exports = fp(async (fastify, options) => {
|
|
|
9
13
|
{
|
|
10
14
|
onRequest: [userAuth, adminAuth],
|
|
11
15
|
schema: {
|
|
12
|
-
|
|
16
|
+
tags: [ADMIN_TAG],
|
|
17
|
+
description: '分页查询全部用户的密钥,按创建时间倒序;密钥只返回脱敏值',
|
|
13
18
|
summary: '获取密钥列表',
|
|
14
19
|
query: {
|
|
15
20
|
type: 'object',
|
|
@@ -23,9 +28,20 @@ module.exports = fp(async (fastify, options) => {
|
|
|
23
28
|
type: 'number',
|
|
24
29
|
description: '每页数量',
|
|
25
30
|
default: 20
|
|
31
|
+
},
|
|
32
|
+
filter: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
additionalProperties: true,
|
|
35
|
+
description: '筛选条件',
|
|
36
|
+
properties: {
|
|
37
|
+
keyword: { type: 'string', description: '关键字(AppId/描述/所属用户)' },
|
|
38
|
+
appId: { type: 'string', description: 'AppId' },
|
|
39
|
+
status: { description: '状态: 0开启,1禁用' }
|
|
40
|
+
}
|
|
26
41
|
}
|
|
27
42
|
}
|
|
28
|
-
}
|
|
43
|
+
},
|
|
44
|
+
response: success('密钥分页列表', pageResult('密钥', secret))
|
|
29
45
|
}
|
|
30
46
|
},
|
|
31
47
|
async request => {
|
|
@@ -38,7 +54,8 @@ module.exports = fp(async (fastify, options) => {
|
|
|
38
54
|
{
|
|
39
55
|
onRequest: [userAuth, adminAuth],
|
|
40
56
|
schema: {
|
|
41
|
-
|
|
57
|
+
tags: [ADMIN_TAG],
|
|
58
|
+
description: '生成一对 AppId / 密钥,密钥明文只在创建时返回一次',
|
|
42
59
|
summary: '创建密钥',
|
|
43
60
|
body: {
|
|
44
61
|
type: 'object',
|
|
@@ -46,9 +63,19 @@ module.exports = fp(async (fastify, options) => {
|
|
|
46
63
|
description: {
|
|
47
64
|
type: 'string',
|
|
48
65
|
description: '描述'
|
|
66
|
+
},
|
|
67
|
+
userId: {
|
|
68
|
+
description: '密钥所属用户 ID,不传为当前登录用户;验签通过后以该用户身份访问开放接口'
|
|
49
69
|
}
|
|
50
70
|
}
|
|
51
|
-
}
|
|
71
|
+
},
|
|
72
|
+
response: success('创建的密钥', {
|
|
73
|
+
type: 'object',
|
|
74
|
+
properties: {
|
|
75
|
+
appId: str('AppId'),
|
|
76
|
+
secretKey: str('密钥明文,只返回这一次')
|
|
77
|
+
}
|
|
78
|
+
})
|
|
52
79
|
}
|
|
53
80
|
},
|
|
54
81
|
async request => {
|
|
@@ -61,7 +88,8 @@ module.exports = fp(async (fastify, options) => {
|
|
|
61
88
|
{
|
|
62
89
|
onRequest: [userAuth, adminAuth],
|
|
63
90
|
schema: {
|
|
64
|
-
|
|
91
|
+
tags: [ADMIN_TAG],
|
|
92
|
+
description: '删除密钥,删除后该 AppId 无法再通过验签;AppId 不存在时报错',
|
|
65
93
|
summary: '删除密钥',
|
|
66
94
|
body: {
|
|
67
95
|
type: 'object',
|
|
@@ -71,7 +99,8 @@ module.exports = fp(async (fastify, options) => {
|
|
|
71
99
|
description: '密钥ID'
|
|
72
100
|
}
|
|
73
101
|
}
|
|
74
|
-
}
|
|
102
|
+
},
|
|
103
|
+
response: empty('删除成功')
|
|
75
104
|
}
|
|
76
105
|
},
|
|
77
106
|
async request => {
|
|
@@ -85,7 +114,8 @@ module.exports = fp(async (fastify, options) => {
|
|
|
85
114
|
{
|
|
86
115
|
onRequest: [userAuth, adminAuth],
|
|
87
116
|
schema: {
|
|
88
|
-
|
|
117
|
+
tags: [ADMIN_TAG],
|
|
118
|
+
description: '修改密钥状态与描述,未传的字段保持原值;AppId 不存在时报错',
|
|
89
119
|
summary: '更新密钥',
|
|
90
120
|
body: {
|
|
91
121
|
type: 'object',
|
|
@@ -96,14 +126,15 @@ module.exports = fp(async (fastify, options) => {
|
|
|
96
126
|
},
|
|
97
127
|
status: {
|
|
98
128
|
type: 'number',
|
|
99
|
-
description:
|
|
129
|
+
description: `密钥${STATUS}`
|
|
100
130
|
},
|
|
101
131
|
description: {
|
|
102
132
|
type: 'string',
|
|
103
133
|
description: '描述'
|
|
104
134
|
}
|
|
105
135
|
}
|
|
106
|
-
}
|
|
136
|
+
},
|
|
137
|
+
response: empty('更新成功')
|
|
107
138
|
}
|
|
108
139
|
},
|
|
109
140
|
async request => {
|
|
@@ -116,7 +147,8 @@ module.exports = fp(async (fastify, options) => {
|
|
|
116
147
|
`${options.prefix}/verify`,
|
|
117
148
|
{
|
|
118
149
|
schema: {
|
|
119
|
-
|
|
150
|
+
tags: [TAG],
|
|
151
|
+
description: '免登录。按 HMAC-SHA256(`${appId}|${timestamp}|${expire}`, 密钥) 校验签名,通过后记录最后访问时间;签名错误或过期返回 result=false,AppId 不存在时报错',
|
|
120
152
|
summary: '验证密钥',
|
|
121
153
|
body: {
|
|
122
154
|
type: 'object',
|
|
@@ -127,18 +159,26 @@ module.exports = fp(async (fastify, options) => {
|
|
|
127
159
|
},
|
|
128
160
|
timestamp: {
|
|
129
161
|
type: 'number',
|
|
130
|
-
description: '
|
|
162
|
+
description: '签名时的时间戳(秒)'
|
|
131
163
|
},
|
|
132
164
|
signature: {
|
|
133
165
|
type: 'string',
|
|
134
|
-
description: '
|
|
166
|
+
description: '签名(hex)'
|
|
135
167
|
},
|
|
136
168
|
expire: {
|
|
137
169
|
type: 'number',
|
|
138
|
-
description: '
|
|
170
|
+
description: '过期时间戳(秒),当前时间超过即验签失败'
|
|
139
171
|
}
|
|
140
172
|
}
|
|
141
|
-
}
|
|
173
|
+
},
|
|
174
|
+
response: success('验签结果', {
|
|
175
|
+
type: 'object',
|
|
176
|
+
properties: {
|
|
177
|
+
result: bool('是否通过验签'),
|
|
178
|
+
message: str('结果说明'),
|
|
179
|
+
userId: any('密钥所属用户 ID,仅通过验签时返回')
|
|
180
|
+
}
|
|
181
|
+
})
|
|
142
182
|
}
|
|
143
183
|
},
|
|
144
184
|
async request => {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// 响应 schema 会参与 fast-json-stringify 序列化:对象一律 additionalProperties: true 以免裁掉未声明字段;
|
|
2
|
+
// 用 nullable 而非类型数组(类型数组遇到不匹配的值会直接 500),类型不确定的字段(如外键 id、日期)只写说明不声明类型
|
|
3
|
+
const str = description => ({ type: 'string', nullable: true, description });
|
|
4
|
+
const num = description => ({ type: 'number', nullable: true, description });
|
|
5
|
+
const bool = description => ({ type: 'boolean', nullable: true, description });
|
|
6
|
+
const any = description => ({ description });
|
|
7
|
+
const list = (description, items) => ({ type: 'array', nullable: true, description, items });
|
|
8
|
+
const obj = (description, properties = {}) => ({ type: 'object', nullable: true, description, additionalProperties: true, properties });
|
|
9
|
+
|
|
10
|
+
const success = (description, schema) => ({
|
|
11
|
+
200: Object.assign({}, schema || { type: 'object' }, { description })
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const empty = (description = '操作成功,返回空对象') => success(description);
|
|
15
|
+
|
|
16
|
+
const pageResult = (description, item) => ({
|
|
17
|
+
type: 'object',
|
|
18
|
+
additionalProperties: true,
|
|
19
|
+
properties: {
|
|
20
|
+
pageData: list(`当前页${description}列表`, item),
|
|
21
|
+
totalCount: num('符合条件的总条数')
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const STATUS = '状态:0 开启,1 禁用';
|
|
26
|
+
|
|
27
|
+
const secret = obj('密钥', {
|
|
28
|
+
id: str('密钥 ID(同 appId)'),
|
|
29
|
+
appId: str('AppId,即密钥 ID'),
|
|
30
|
+
secretKey: str('脱敏后的密钥,只保留首尾各 3 位'),
|
|
31
|
+
user: any('所属用户,未关联到用户时为 null'),
|
|
32
|
+
description: str('描述'),
|
|
33
|
+
lastVisitedAt: any('最后一次验签成功的时间'),
|
|
34
|
+
status: num(STATUS),
|
|
35
|
+
createdAt: any('创建时间'),
|
|
36
|
+
updatedAt: any('更新时间')
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
module.exports = { str, num, bool, any, list, obj, success, empty, pageResult, STATUS, secret };
|
package/libs/services/main.js
CHANGED
|
@@ -3,16 +3,27 @@ const crypto = require('crypto');
|
|
|
3
3
|
|
|
4
4
|
module.exports = fp(async (fastify, options) => {
|
|
5
5
|
const { models, services } = fastify[options.name];
|
|
6
|
+
const { Op } = fastify.sequelize.Sequelize;
|
|
7
|
+
|
|
8
|
+
const escapeLike = value => String(value).replace(/[\\%_]/g, '\\$&');
|
|
9
|
+
|
|
10
|
+
const resolveAppId = appId => {
|
|
11
|
+
if (appId == null || appId === '') {
|
|
12
|
+
throw new Error('Invalid appId');
|
|
13
|
+
}
|
|
14
|
+
// 雪花 id 超过 Number.MAX_SAFE_INTEGER,必须始终按字符串查,禁止 Number(appId)
|
|
15
|
+
return String(appId);
|
|
16
|
+
};
|
|
6
17
|
|
|
7
18
|
const create = async (authenticatePayload, { description, userId: targetUserId }) => {
|
|
8
19
|
const { id: userId } = authenticatePayload;
|
|
9
20
|
const secretKey = crypto.randomBytes(options.secretLength).toString('hex');
|
|
10
21
|
const newSecret = await models.secret.create({ secretKey, description, userId: targetUserId || userId });
|
|
11
|
-
return { appId: newSecret.id, secretKey: newSecret.secretKey };
|
|
22
|
+
return { appId: String(newSecret.id), secretKey: newSecret.secretKey };
|
|
12
23
|
};
|
|
13
24
|
|
|
14
25
|
const verify = async ({ appId, timestamp, expire, signature }) => {
|
|
15
|
-
const secret = await models.secret.findByPk(appId);
|
|
26
|
+
const secret = await models.secret.findByPk(resolveAppId(appId));
|
|
16
27
|
if (!secret) {
|
|
17
28
|
throw new Error('Invalid appId');
|
|
18
29
|
}
|
|
@@ -47,19 +58,49 @@ module.exports = fp(async (fastify, options) => {
|
|
|
47
58
|
};
|
|
48
59
|
};
|
|
49
60
|
|
|
50
|
-
const list = async (authenticatePayload, { perPage, currentPage }) => {
|
|
51
|
-
|
|
61
|
+
const list = async (authenticatePayload, { perPage = 20, currentPage = 1, filter }) => {
|
|
62
|
+
filter = Object.assign({}, filter);
|
|
63
|
+
const whereQuery = {};
|
|
64
|
+
const sequelize = fastify.sequelize;
|
|
65
|
+
const castIdLike = value =>
|
|
66
|
+
sequelize.where(sequelize.cast(sequelize.col('secret.id'), 'TEXT'), {
|
|
67
|
+
[Op.iLike]: `%${escapeLike(value)}%`
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const keyword = filter.keyword != null ? String(filter.keyword).trim() : '';
|
|
71
|
+
if (keyword) {
|
|
72
|
+
const like = { [Op.iLike]: `%${escapeLike(keyword)}%` };
|
|
73
|
+
whereQuery[Op.or] = [castIdLike(keyword), { description: like }, { '$user.nickname$': like }, { '$user.email$': like }, { '$user.phone$': like }];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const appId = filter.appId != null ? String(filter.appId).trim() : '';
|
|
77
|
+
if (appId) {
|
|
78
|
+
whereQuery[Op.and] = [...(whereQuery[Op.and] || []), castIdLike(appId)];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (filter.status !== undefined && filter.status !== null && filter.status !== '') {
|
|
82
|
+
whereQuery.status = Number(filter.status);
|
|
83
|
+
}
|
|
84
|
+
|
|
52
85
|
const { count, rows } = await models.secret.findAndCountAll({
|
|
53
|
-
include:
|
|
54
|
-
|
|
86
|
+
include: [
|
|
87
|
+
{
|
|
88
|
+
model: options.getUserModel(),
|
|
89
|
+
required: false
|
|
90
|
+
}
|
|
91
|
+
],
|
|
92
|
+
where: whereQuery,
|
|
55
93
|
limit: perPage,
|
|
56
|
-
offset: (currentPage - 1) * perPage
|
|
94
|
+
offset: (currentPage - 1) * perPage,
|
|
95
|
+
order: [['createdAt', 'DESC']],
|
|
96
|
+
distinct: true
|
|
57
97
|
});
|
|
58
98
|
return {
|
|
59
99
|
pageData: rows.map(item => {
|
|
100
|
+
const id = String(item.id);
|
|
60
101
|
return {
|
|
61
|
-
id
|
|
62
|
-
appId:
|
|
102
|
+
id,
|
|
103
|
+
appId: id,
|
|
63
104
|
secretKey: item.secretKey.substring(0, 3) + '*'.repeat(8) + item.secretKey.substring(item.secretKey.length - 3),
|
|
64
105
|
user: item.user,
|
|
65
106
|
description: item.description,
|
|
@@ -73,27 +114,20 @@ module.exports = fp(async (fastify, options) => {
|
|
|
73
114
|
};
|
|
74
115
|
};
|
|
75
116
|
|
|
117
|
+
// list/update/remove 均为 admin 接口,可管理任意用户密钥;勿再按创建者校验归属
|
|
76
118
|
const remove = async (authenticatePayload, { appId }) => {
|
|
77
|
-
const
|
|
78
|
-
const secret = await models.secret.findByPk(appId);
|
|
119
|
+
const secret = await models.secret.findByPk(resolveAppId(appId));
|
|
79
120
|
if (!secret) {
|
|
80
121
|
throw new Error('Invalid appId');
|
|
81
122
|
}
|
|
82
|
-
if (secret.userId !== userId) {
|
|
83
|
-
throw new Error('Invalid appId');
|
|
84
|
-
}
|
|
85
123
|
await secret.destroy();
|
|
86
124
|
};
|
|
87
125
|
|
|
88
126
|
const update = async (authenticatePayload, { appId, status, description }) => {
|
|
89
|
-
const
|
|
90
|
-
const secret = await models.secret.findByPk(appId);
|
|
127
|
+
const secret = await models.secret.findByPk(resolveAppId(appId));
|
|
91
128
|
if (!secret) {
|
|
92
129
|
throw new Error('Invalid appId');
|
|
93
130
|
}
|
|
94
|
-
if (secret.userId !== userId) {
|
|
95
|
-
throw new Error('Invalid appId');
|
|
96
|
-
}
|
|
97
131
|
await secret.update({ status, description });
|
|
98
132
|
};
|
|
99
133
|
|