init
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
data
|
||||||
84
README.md
Normal file
84
README.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Kintone License Server
|
||||||
|
|
||||||
|
一个 Node.js TypeScript 服务端程序,用于验证客户端的许可证,通过 RSA 签名生成 JWT。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 提供 `POST /api/license/check` 接口,根据请求 body 中的 `domain`、`pluginId`、`pluginKey` 参数:
|
||||||
|
- 如果许可证不存在,创建 30 天试用许可证(到期时间为最后一天的 23:59:59)。
|
||||||
|
- 如果存在,加载现有许可证。
|
||||||
|
- 使用对应的插件 RSA 私钥生成 JWT,返回给客户端。
|
||||||
|
- JWT payload 结构符合客户端要求的 LicenseInfo 接口。
|
||||||
|
- 支持试用和付费许可证:基于数据库 `purchase_date` 是否有值判断 `isPaid`,付费许可证 `expiredTime = -1`。
|
||||||
|
|
||||||
|
## 运行步骤
|
||||||
|
|
||||||
|
1. **安装依赖:**
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **构建项目:**
|
||||||
|
```
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **初始化数据库:**
|
||||||
|
- 运行初始化脚本:`init-db.sql`
|
||||||
|
|
||||||
|
4. **启动服务器:**
|
||||||
|
```
|
||||||
|
npm run dev # 开发模式
|
||||||
|
npm start # 生产模式(需预先构建)
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **测试 API:**
|
||||||
|
- 健康检查:`GET http://localhost:3000/health`
|
||||||
|
- 许可证检查:`POST http://localhost:3000/api/license/check`
|
||||||
|
请求 body 示例:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"domain": "example.com",
|
||||||
|
"pluginId": "test-plugin",
|
||||||
|
"pluginKey": "default-plugin"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"jwt": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
- `src/server.ts`:主服务器代码,包含 Express 应用、路由和数据库连接。
|
||||||
|
- `init-db.sql`:数据库初始化脚本,创建 plugins、customers、licenses 三张表。
|
||||||
|
- `private/kintone-vue-template/rsa_private.pem`:示例 RSA 私钥(已导入数据库)。
|
||||||
|
|
||||||
|
## 数据库结构
|
||||||
|
|
||||||
|
- **plugins**:插件信息(id, plugin_id, plugin_name, private_key, deleted)。多插件支持,每个插件有唯一私钥。
|
||||||
|
- **customers**:客户信息(id, name, email, phone, comment)。暂未使用。
|
||||||
|
- **licenses**:许可证记录(id, domain, plugin_id, expired_time, purchase_date, plugin_fk, customer_fk, message)。
|
||||||
|
- 外键 `plugin_fk` 关联插件,`customer_fk` 关联客户(暂未实施)。
|
||||||
|
|
||||||
|
许可逻辑:
|
||||||
|
- 请求 (domain, pluginId) 组合唯一标识许可证。
|
||||||
|
- 试用:`purchase_date = NULL`,`isPaid = false`,`expiredTime` = 30 天后时间戳。
|
||||||
|
- 付费:`purchase_date` 被外部系统设置,`isPaid = true`,`expiredTime = -1`。
|
||||||
|
|
||||||
|
## 认证和安全性
|
||||||
|
|
||||||
|
- 使用 RSA-256 签名 JWT,私钥存储在 `plugins` 表中,按 `pluginKey` 区分。
|
||||||
|
- 默认插入 "default-plugin" 插件,使用现有 RSA 私钥。
|
||||||
|
- 数据库连接硬编码凭据(生产环境使用环境变量,如 .env 文件)。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 当前实现试用许可证,30 天过期时间硬编码。
|
||||||
|
- 付费购买由其他系统更新 `purchase_date`,本服务仅校验。
|
||||||
|
- PostgreSQL 需要预先配置和运行。
|
||||||
|
- 代码使用 TypeScript 严格模式,确保类型安全。
|
||||||
73
init-db.sql
Normal file
73
init-db.sql
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
-- 初始化 kintone_license_server 数据库
|
||||||
|
|
||||||
|
-- 创建数据库(需要管理员权限运行,或手动创建后运行此脚本)
|
||||||
|
-- CREATE DATABASE kintone_license_server;
|
||||||
|
|
||||||
|
-- 连接到数据库
|
||||||
|
-- \c kintone_license_server;
|
||||||
|
|
||||||
|
-- 创建 plugins 表:存储插件信息
|
||||||
|
CREATE TABLE IF NOT EXISTS plugins (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
plugin_id VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
plugin_name VARCHAR(255) NOT NULL,
|
||||||
|
private_key TEXT NOT NULL,
|
||||||
|
deleted BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建 customers 表:客户信息
|
||||||
|
CREATE TABLE IF NOT EXISTS customers (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255),
|
||||||
|
email VARCHAR(255),
|
||||||
|
phone VARCHAR(50),
|
||||||
|
comment TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建 licenses 表:许可证记录
|
||||||
|
CREATE TABLE IF NOT EXISTS licenses (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
domain VARCHAR(255) NOT NULL,
|
||||||
|
plugin_id VARCHAR(255) NOT NULL,
|
||||||
|
expired_time TIMESTAMPTZ NOT NULL,
|
||||||
|
purchase_date TIMESTAMPTZ,
|
||||||
|
plugin_fk INTEGER REFERENCES plugins(id),
|
||||||
|
customer_fk INTEGER REFERENCES customers(id),
|
||||||
|
message TEXT,
|
||||||
|
UNIQUE(domain, plugin_id) -- 确保每个 domain + plugin_id 组合唯一
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 插入默认插件数据(使用现有的 RSA 私钥)
|
||||||
|
INSERT INTO plugins (plugin_id, plugin_name, private_key)
|
||||||
|
VALUES (
|
||||||
|
'kintone-vue-template',
|
||||||
|
'kintone Vue テンプレート',
|
||||||
|
'-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDOAqDRabJn6vga
|
||||||
|
YXnWuYKPomQVNV0R0HPmuN1N4qo7OMDkFH2mln86w68/9/bHMb7TcVaFGxNblpe2
|
||||||
|
tIjr/u/XfnS2+NK6ckTWOhmPUrqLG6A3a4v49dymvFdfyjAYOCBTlKzrdQTjdFAX
|
||||||
|
5EUE007n/G+RxYiEAm0NXpVqa+aw/Yvm5+Lr7GrKpyamAAygu5jYDE3rateyfJ1h
|
||||||
|
P2fEXEqR4RPD5pgyKD+4ZVbygaYqAw4rZu4OOZ4QtPCtksSenn94Yx9UUlex3TNW
|
||||||
|
oTBrFp+8z9wjg+oF6/5Ai/PsXLDpbkIusnQo1yYvrULwaAHBvL6wgj1FOL+g6Tmp
|
||||||
|
P6MhU7EbAgMBAAECggEAAyia0vXQv0jPgAa26a9ozI9RWIU7xZOmy0anV1Peh0+X
|
||||||
|
SYf05efQez6tLQkTbA9xB73o9TEqlVC+8mqx32hxFffsOIf3zIBcWiqEN3nYaVya
|
||||||
|
6BlKiUkqa8C0guqkh80zKwTlt9XQpYokK5GblfeFcgd2pctbt1Ea30XFewm80kGH
|
||||||
|
OrHP4bi25KYkS+6PL5wUI+OqfaR/LsWevwt+cLlZPGmDDgE6+2Bvcg0JWnKKwk1k
|
||||||
|
jhKpSAek6zUkweaysxx1BscNBvGtDnRRRy0+2jMK66ad80Cg0G8xLQ/tu8kBwfml
|
||||||
|
T/9ViSRUjUrMdcfSVkrdOV3xAsOXdWspz1aljJAwcQKBgQDxebBimIBgUhNJ+2/9
|
||||||
|
27OQXsHLBYbqPurR4fKCM7a5qAIpXJcgth0YAuNvJT+rxEHTPypQCTF1fvvJuU+v
|
||||||
|
w1HjUz1CHzGYWdj0xibQDdtgkPlKqQQeT3Cnpwy3RkT4IF6FCH9YfKcUzYdamVZy
|
||||||
|
S8kFqLzEStwWIoaQX2HoBZLoUQKBgQDaZtZJoIHr+d0fLz+7rAoB8WEBA8lOaEhU
|
||||||
|
/OHwrUbuAez6k7zEDsTXgLuB8N34p1yMSaOqdGJ8rj3Pg8AlrXGELfXOF7Fe9zG0
|
||||||
|
LxujNmftmJKE8XTIALPpSBJgF5LDnh13LcNMd4b6w0DDjF7wCA2T3DTLjjqi3fo7
|
||||||
|
nDW9WQyTqwKBgEXlbXL8pZw75a1yhHY80/skEoBLt0OytpHODz407d1LjmSekng7
|
||||||
|
fqxmmaKga4+ynUMic4L7Rj+2Y/d+FlzP8rIUdBThpp9s0mn3uWBbwnZvQFmmFrUX
|
||||||
|
VYqRxhJ+2pPf+rwTO5lHa62P2HAXFni7CxMCRrGi4ZXepIjBsztP8bghAoGBAMj5
|
||||||
|
Bsmb1NJkDBGNNhWpm0/sYbpAVLc9CQqD5hnGKdYMmZh/6J11hbdVM7bAAlK1F1nU
|
||||||
|
zbGmBZb7888ISwGg2Cus61tpvANKb0eCbelDwGEIHBQP6Mm+s8/ATYB1UM2Hq0+n
|
||||||
|
Iec0ulX45JjNi/NPRcdBRKfnypdissi111HVJtifAoGADrT/t2j1bMHd1zY76Jv5
|
||||||
|
M/BWhOAgHwBo683ROEL3p6G98Xla2kd7hP6NEdo6OLJnlfK3RfoPWnotLZKEsfQM
|
||||||
|
Wog9GAcYtQbDDaYWxNBgyscSdVousY1EXs+t5vkt3XSIwUwLcEVJOu7/Fvt+YXpn
|
||||||
|
nR7aD8F62ARYxI+bv+bQR5w=
|
||||||
|
-----END PRIVATE KEY-----'
|
||||||
|
) ON CONFLICT (plugin_id) DO NOTHING;
|
||||||
1689
package-lock.json
generated
Normal file
1689
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "kintone-license-server",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsx src/server.ts",
|
||||||
|
"start": "node dist/server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"jsrsasign": "^11.1.0",
|
||||||
|
"pg": "^8.11.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^20.8.9",
|
||||||
|
"@types/pg": "^8.10.7",
|
||||||
|
"tsx": "^4.6.2",
|
||||||
|
"typescript": "^5.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
149
src/server.ts
Normal file
149
src/server.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const KJUR = require('jsrsasign');
|
||||||
|
|
||||||
|
// 定义接口
|
||||||
|
interface LicenseInfo {
|
||||||
|
expiredTime: number;
|
||||||
|
isPaid: boolean;
|
||||||
|
domain: string;
|
||||||
|
pluginId: string;
|
||||||
|
fetchTime: number;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据库连接配置
|
||||||
|
const pool = new Pool({
|
||||||
|
user: 'postgres',
|
||||||
|
host: 'localhost',
|
||||||
|
database: 'kintone_license_server',
|
||||||
|
password: 'psadmin',
|
||||||
|
port: 5432,
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = 3000;
|
||||||
|
|
||||||
|
// 中间件
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
const TRIAL_DATE = 30
|
||||||
|
const RSA_VERSION = 1
|
||||||
|
|
||||||
|
// 许可证检查路由
|
||||||
|
app.post('/api/license/check', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { domain, pluginId, pluginKey } = req.body as { domain: string; pluginId: string; pluginKey: string };
|
||||||
|
|
||||||
|
if (!domain || !pluginId || !pluginKey) {
|
||||||
|
return res.status(400).json({ error: 'Missing domain, pluginId or pluginKey' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询是否已有许可证
|
||||||
|
const existingQuery = await pool.query(
|
||||||
|
'SELECT * FROM licenses WHERE domain = $1 AND plugin_id = $2',
|
||||||
|
[domain, pluginId]
|
||||||
|
);
|
||||||
|
|
||||||
|
let licenseData;
|
||||||
|
if (existingQuery.rows.length === 0) {
|
||||||
|
// 创建新的试用许可证(30天有效期)
|
||||||
|
const expiredDate = new Date();
|
||||||
|
expiredDate.setDate(expiredDate.getDate() + TRIAL_DATE);
|
||||||
|
expiredDate.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
// 查找 plugin_fk
|
||||||
|
const pluginQuery = await pool.query(
|
||||||
|
'SELECT id FROM plugins WHERE plugin_id = $1 AND deleted = FALSE',
|
||||||
|
[pluginKey]
|
||||||
|
);
|
||||||
|
if (pluginQuery.rows.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Invalid pluginKey' });
|
||||||
|
}
|
||||||
|
const pluginFk = pluginQuery.rows[0].id;
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO licenses (domain, plugin_id, expired_time, plugin_fk) VALUES ($1, $2, $3, $4)',
|
||||||
|
[domain, pluginId, expiredDate.toISOString(), pluginFk]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 重新查询新创建的记录
|
||||||
|
const newQuery = await pool.query(
|
||||||
|
'SELECT * FROM licenses WHERE domain = $1 AND plugin_id = $2',
|
||||||
|
[domain, pluginId]
|
||||||
|
);
|
||||||
|
licenseData = newQuery.rows[0];
|
||||||
|
} else {
|
||||||
|
licenseData = existingQuery.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPaid = !!licenseData.purchase_date;
|
||||||
|
// 生成 JWT payload
|
||||||
|
const payload: LicenseInfo = {
|
||||||
|
expiredTime: isPaid ? -1 : new Date(licenseData.expired_time).getTime(), // 永久许可证则是 -1
|
||||||
|
isPaid,
|
||||||
|
domain: licenseData.domain,
|
||||||
|
pluginId: licenseData.plugin_id,
|
||||||
|
fetchTime: Date.now(),
|
||||||
|
version: RSA_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取私钥(对应的插件)
|
||||||
|
const privateKeyQuery = await pool.query(
|
||||||
|
'SELECT private_key FROM plugins WHERE id = $1',
|
||||||
|
[licenseData.plugin_fk]
|
||||||
|
);
|
||||||
|
const privateKey = privateKeyQuery.rows[0].private_key.trim();
|
||||||
|
|
||||||
|
// 创建 JWT header 和 payload
|
||||||
|
const header = {
|
||||||
|
alg: 'RS256',
|
||||||
|
typ: 'JWT'
|
||||||
|
};
|
||||||
|
|
||||||
|
const sHeader = JSON.stringify(header);
|
||||||
|
const jwtPayload = {
|
||||||
|
...payload,
|
||||||
|
iat: Math.floor(Date.now() / 1000)
|
||||||
|
};
|
||||||
|
const sPayload = JSON.stringify(jwtPayload);
|
||||||
|
|
||||||
|
// 生成 JWT
|
||||||
|
const jwt = KJUR.jws.JWS.sign(null, sHeader, sPayload, privateKey);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
jwt: jwt,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing license check:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 健康检查
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({ status: 'OK' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动服务器
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`License server running on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 优雅关闭
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
console.log('Shutting down server...');
|
||||||
|
await pool.end();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
console.log('Shutting down server...');
|
||||||
|
await pool.end();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user