/** * 授权系统 H5(浏览器)客户端,无任何第三方依赖。 * * 用法一:浏览器 * const client = new LicenseClient('https://license.example.com', 'A1B2C3D4E5F60718293A4B5C6D7E8F90'); * const login = await client.verify('FFCD-21F1-4349-EEE1-5411-7758', '办公电脑'); * if (login.status === 200 && login.data.ok) { * // 保存 login.data.licenseTicket / expireUnix / remainDays * } * * 用法二:ES Module * import { LicenseClient, generateMachineCode } from './license-client.js'; * * 机器码持久化(浏览器无法读取硬件信息): * const machineCode = loadOrCreateMachineCode(); // 默认存 localStorage['license_machine_code'] * * 离线验签(可选):请部署方提供 RSA 公钥(PEM),每次启动用 verifyTicket 校验本地票据。 * * 注意:浏览器跨域请求需服务端返回 CORS 头;建议将授权服务与业务页同域部署, * 或在服务端为 /api/card/* 配置允许的来源。 */ (function (root, factory) { if (typeof module === 'object' && module.exports) { module.exports = factory(); } else if (typeof define === 'function' && define.amd) { define([], factory); } else { root.LicenseClient = factory(); } })(typeof self !== 'undefined' ? self : this, function () { 'use strict'; function b64ToBytes(b64) { const normalized = b64.replace(/-/g, '+').replace(/_/g, '/'); const bin = atob(normalized); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return bytes; } function bytesToB64(bytes) { let bin = ''; for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin); } class ApiResponse { constructor(status, body) { this.status = status; this.body = body; try { this.data = JSON.parse(body); } catch (e) { this.data = null; } } } class LicenseClient { /** * @param {string} baseUrl 服务端地址,如 https://license.example.com * @param {string} machineCode 32 位机器码(自动转大写) * @param {object} [options] { timeoutMs: 10000, appKey: '管理后台生成的软件接入密钥' } */ constructor(baseUrl, machineCode, options) { this.baseUrl = String(baseUrl).replace(/\/+$/, ''); this.machineCode = String(machineCode || '').trim().toUpperCase(); this.appKey = String((options && options.appKey) || '').trim(); this.timeoutMs = (options && options.timeoutMs) || 10000; this.fetchImpl = (options && options.fetchImpl) || (typeof fetch !== 'undefined' ? fetch.bind(globalThis) : null); if (!this.fetchImpl) throw new Error('当前环境不支持 fetch,请提供 options.fetchImpl'); } /** 附带接入密钥(启用后服务端据其校验卡密归属,拒绝跨空间、跨软件验证) */ _scope(payload) { if (this.appKey) payload.appKey = this.appKey; return payload; } /** 登录/验证:校验卡密并绑定当前设备,成功返回授权票据 */ verify(cardKey, deviceName) { return this._post('/api/card/verify', this._scope({ cardKey, machineCode: this.machineCode, deviceName: deviceName || '', })); } /** 心跳:按业务周期调用,维持设备在线状态并刷新到期时间 */ heartbeat(cardKey) { return this._post('/api/card/heartbeat', this._scope({ cardKey, machineCode: this.machineCode, })); } /** 查询当前卡密已绑定的设备列表 */ listDevices(cardKey) { return this._post('/api/card/devices', this._scope({ cardKey, machineCode: this.machineCode, })); } /** 解绑当前设备(换机或归还授权时调用) */ unbind(cardKey) { return this._post('/api/card/unbind', this._scope({ cardKey, machineCode: this.machineCode, })); } /** * 离线校验授权票据(可选,WebCrypto 需要 https 或 localhost 环境)。 * licenseTicket = base64(payload) + '.' + base64(rsa-sha256 签名), * payload 含 ch(卡密哈希)/mc(机器码)/exp(到期 unix 秒)/ts(签发时间)。 * * @param {string} ticket login.verify 返回的 licenseTicket * @param {string} publicKeyPem 部署方提供的 RSA 公钥 PEM(-----BEGIN PUBLIC KEY-----) * @returns {Promise} 校验通过返回 payload,否则抛错 */ async verifyTicket(ticket, publicKeyPem) { if (!globalThis.crypto || !globalThis.crypto.subtle) { throw new Error('当前环境不支持 WebCrypto,无法离线验签'); } const dot = ticket.lastIndexOf('.'); if (dot <= 0) throw new Error('授权票据格式错误'); const payloadRaw = b64ToBytes(ticket.slice(0, dot)); const signature = b64ToBytes(ticket.slice(dot + 1)).buffer; const pemBody = String(publicKeyPem) .replace(/-----BEGIN PUBLIC KEY-----/, '') .replace(/-----END PUBLIC KEY-----/, '') .replace(/\s+/g, ''); const key = await globalThis.crypto.subtle.importKey( 'spki', b64ToBytes(pemBody).buffer, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify'] ); const valid = await globalThis.crypto.subtle.verify( 'RSASSA-PKCS1-v1_5', key, signature, payloadRaw ); if (!valid) throw new Error('授权票据签名校验失败'); const payload = JSON.parse(new TextDecoder().decode(payloadRaw)); if (!payload || String(payload.mc || '').toUpperCase() !== this.machineCode) { throw new Error('授权票据与本机机器码不一致'); } if (Number(payload.exp) < Math.floor(Date.now() / 1000)) { throw new Error('授权票据已过期'); } return payload; } async _post(path, payload) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeoutMs); try { const res = await this.fetchImpl(this.baseUrl + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: controller.signal, }); const body = await res.text(); return new ApiResponse(res.status, body); } finally { clearTimeout(timer); } } } /** 生成一个 32 位随机机器码(浏览器无硬件指纹,同一浏览器建议持久保存) */ function generateMachineCode() { const bytes = new Uint8Array(16); if (globalThis.crypto && globalThis.crypto.getRandomValues) { globalThis.crypto.getRandomValues(bytes); } else { for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256); } return Array.from(bytes) .map((b) => b.toString(16).padStart(2, '0')) .join('') .toUpperCase(); } /** * 读取或创建设备机器码并持久化到 localStorage。 * @param {string} [storageKey] 默认 'license_machine_code' */ function loadOrCreateMachineCode(storageKey) { const key = storageKey || 'license_machine_code'; let code = ''; try { code = String(localStorage.getItem(key) || '').trim(); } catch (e) { code = ''; } if (!/^[0-9A-Fa-f]{32}$/.test(code)) { code = generateMachineCode(); try { localStorage.setItem(key, code); } catch (e) { /* 隐私模式等场景忽略持久化失败 */ } } return code.toUpperCase(); } return { LicenseClient: LicenseClient, ApiResponse: ApiResponse, generateMachineCode: generateMachineCode, loadOrCreateMachineCode: loadOrCreateMachineCode, bytesToB64: bytesToB64, b64ToBytes: b64ToBytes, }; });