feat(token_lab): 新增令牌工坊工具及配套后端逻辑
新增完整的令牌工坊工具链: - 添加前端静态资源:登录界面、导航与Tool令牌管理页面,配套样式与脚本文件 - 添加后端接口与中间件:认证相关接口、路由权限控制,以及导航令牌的生成验证API - 添加默认配置文件,配置令牌工坊默认走导航登录,不对外公网开放
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"_comment": "令牌工坊默认走导航登录,不对公网开放",
|
||||
"enable_auth": true,
|
||||
"max_age_days": 30
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0b1020">
|
||||
<title>令牌工坊</title>
|
||||
<link rel="stylesheet" href="/tools/token_lab/token_lab.css">
|
||||
</head>
|
||||
<body>
|
||||
<section id="authScreen" class="auth-screen">
|
||||
<div class="auth-card">
|
||||
<input id="authUsername" class="input" type="text" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder="账号">
|
||||
<input id="authPassword" class="input" type="password" autocomplete="off" placeholder="密码">
|
||||
<button id="btnAuthLogin" class="btn btn-primary auth-submit">登录</button>
|
||||
<div id="authHint" class="auth-hint"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="workshopShell" class="page-shell" hidden>
|
||||
<header class="hero">
|
||||
<div>
|
||||
<div class="eyebrow">Token Lab</div>
|
||||
<h1>令牌工坊</h1>
|
||||
<p>首页导航令牌与 Tool 令牌的生成、验证、信息回看,都放在这里统一处理。</p>
|
||||
</div>
|
||||
<div class="hero-side">
|
||||
<div id="globalNotice" class="notice" hidden></div>
|
||||
<button id="btnAuthLogout" class="btn" hidden>退出工坊登录</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav id="toolTabs" class="tab-nav" hidden>
|
||||
<button class="tab-btn active" data-tab="nav">首页导航生成器</button>
|
||||
<button class="tab-btn" data-tab="tool">Tool 令牌生成器</button>
|
||||
</nav>
|
||||
|
||||
<main id="appMain" hidden>
|
||||
<section class="tab-panel active" data-panel="nav">
|
||||
<div class="layout-grid">
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>生成首页导航令牌</h2>
|
||||
</div>
|
||||
<p class="card-desc">服务端使用当前导航私钥签发首页长链接,适合你自己的首页入口轮换。</p>
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>基础地址</span>
|
||||
<input id="navBaseUrl" class="input" type="text" placeholder="https://traesite.umersoft.com:8975/">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>过期时间</span>
|
||||
<input id="navExpiry" class="input" type="datetime-local">
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="btnNavGenerate" class="btn btn-primary">生成首页令牌</button>
|
||||
<button id="btnNavCopy" class="btn">复制链接</button>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>生成结果</span>
|
||||
<textarea id="navLinkOutput" class="textarea mono" rows="5" readonly wrap="off"></textarea>
|
||||
</label>
|
||||
<div id="navGenerateMeta" class="result-grid"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>验证首页导航令牌</h2>
|
||||
</div>
|
||||
<p class="card-desc">可以粘贴完整链接或纯 token,快速看签名、过期时间、剩余时长和载荷字段。</p>
|
||||
<label class="field">
|
||||
<span>待验证内容</span>
|
||||
<textarea id="navVerifyInput" class="textarea mono" rows="6" placeholder="粘贴 https://.../?token=... 或直接粘贴 token" wrap="off"></textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="btnNavVerify" class="btn btn-primary">验证首页令牌</button>
|
||||
</div>
|
||||
<div id="navVerifyMeta" class="result-grid"></div>
|
||||
<details class="raw-details">
|
||||
<summary>详细输出</summary>
|
||||
<pre id="navVerifyRaw" class="result-box"></pre>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab-panel" data-panel="tool">
|
||||
<div class="layout-grid">
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>生成 Tool 令牌</h2>
|
||||
</div>
|
||||
<p class="card-desc">这部分沿用原 WinForm 的思路:浏览器本地持有 RSA 私钥,本地完成签名,不把 tool 私钥交给服务器。</p>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>站点预设</span>
|
||||
<select id="toolPresetSelect" class="input"></select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>预设名称</span>
|
||||
<input id="toolPresetName" class="input" type="text" placeholder="expense">
|
||||
</label>
|
||||
<label class="field field-span-2">
|
||||
<span>基础地址</span>
|
||||
<input id="toolBaseUrl" class="input" type="text" placeholder="https://traesite.umersoft.com:8975/tools/expense">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>设备别名</span>
|
||||
<input id="toolAlias" class="input" type="text" placeholder="nav-private">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>过期时间</span>
|
||||
<input id="toolExpiry" class="input" type="datetime-local">
|
||||
</label>
|
||||
<label class="field field-span-2">
|
||||
<span>自定义密码</span>
|
||||
<input id="toolPassword" class="input" type="password" placeholder="可留空">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="actions wrap">
|
||||
<button id="btnToolGenerateKey" class="btn">生成 RSA 密钥</button>
|
||||
<button id="btnToolImportXml" class="btn">导入私钥 XML</button>
|
||||
<button id="btnToolExportXml" class="btn">导出私钥 XML</button>
|
||||
<button id="btnToolCopyJwk" class="btn">复制公钥 JWK</button>
|
||||
<button id="btnToolNewPreset" class="btn">新建预设</button>
|
||||
<button id="btnToolSavePreset" class="btn">保存当前预设</button>
|
||||
<button id="btnToolDeletePreset" class="btn">删除当前预设</button>
|
||||
</div>
|
||||
<input id="toolXmlFile" type="file" accept=".xml,text/xml" hidden>
|
||||
|
||||
<div id="toolKeyMeta" class="result-grid"></div>
|
||||
|
||||
<label class="field">
|
||||
<span>公钥 JWK</span>
|
||||
<textarea id="toolPublicJwk" class="textarea mono" rows="5" readonly wrap="off"></textarea>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button id="btnToolGenerateToken" class="btn btn-primary">生成 Tool 令牌</button>
|
||||
<button id="btnToolCopyLink" class="btn">复制链接</button>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>生成结果</span>
|
||||
<textarea id="toolLinkOutput" class="textarea mono" rows="5" readonly wrap="off"></textarea>
|
||||
</label>
|
||||
<div id="toolGenerateMeta" class="result-grid"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>验证 Tool 令牌</h2>
|
||||
</div>
|
||||
<p class="card-desc">粘贴旧链接或旧 token,马上就能看出 alias、到期时间、剩余时长,以及当前密钥是否验得过。</p>
|
||||
<label class="field">
|
||||
<span>待验证内容</span>
|
||||
<textarea id="toolVerifyInput" class="textarea mono" rows="6" placeholder="粘贴 https://.../?token=... 或直接粘贴 token" wrap="off"></textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="btnToolVerify" class="btn btn-primary">验证 Tool 令牌</button>
|
||||
</div>
|
||||
<div id="toolVerifyMeta" class="result-grid"></div>
|
||||
<details class="raw-details">
|
||||
<summary>详细输出</summary>
|
||||
<pre id="toolVerifyRaw" class="result-box"></pre>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/tools/token_lab/token_lab.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,356 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
background: linear-gradient(180deg, #091121 0%, #111a2f 100%);
|
||||
color: #eef2ff;
|
||||
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
padding: 20px 14px 32px;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
width: min(1240px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 24px;
|
||||
background: rgba(10, 17, 34, 0.78);
|
||||
backdrop-filter: blur(18px);
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.hero-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #8ea4ff;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 8px 0 10px;
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 0;
|
||||
line-height: 1.7;
|
||||
color: #b8c2de;
|
||||
}
|
||||
|
||||
.notice {
|
||||
min-width: 240px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(67, 56, 202, 0.18);
|
||||
border: 1px solid rgba(129, 140, 248, 0.35);
|
||||
color: #dbe4ff;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.notice.error {
|
||||
background: rgba(127, 29, 29, 0.24);
|
||||
border-color: rgba(248, 113, 113, 0.38);
|
||||
color: #fee2e2;
|
||||
}
|
||||
|
||||
.notice.success {
|
||||
background: rgba(20, 83, 45, 0.24);
|
||||
border-color: rgba(74, 222, 128, 0.35);
|
||||
color: #dcfce7;
|
||||
}
|
||||
|
||||
.tab-nav {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.auth-screen {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: calc(100vh - 40px);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: min(320px, calc(100vw - 28px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.auth-hint {
|
||||
min-height: 20px;
|
||||
padding-left: 2px;
|
||||
color: #c7d2fe;
|
||||
line-height: 1.5;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.auth-hint.error {
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.auth-hint.success {
|
||||
color: #bbf7d0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 12px 18px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #d8def5;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: linear-gradient(135deg, #6f7bf7 0%, #9b71f6 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 24px rgba(111, 123, 247, 0.32);
|
||||
}
|
||||
|
||||
.tab-panel {
|
||||
display: none;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.tab-panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.layout-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 22px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(8, 13, 27, 0.8);
|
||||
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.card-head h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
margin: 10px 0 18px;
|
||||
color: #b2bfdc;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.field-span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.field > span {
|
||||
font-size: 14px;
|
||||
color: #c9d3ef;
|
||||
}
|
||||
|
||||
.input, .textarea, .result-box, select.input {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
background: rgba(16, 22, 40, 0.92);
|
||||
color: #eff3ff;
|
||||
padding: 12px 14px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.textarea, .result-box {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.textarea.mono {
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
overflow-wrap: normal;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.result-box {
|
||||
min-height: 120px;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
overflow-wrap: normal;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.raw-details {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.raw-details > summary {
|
||||
cursor: pointer;
|
||||
color: #c9d3ef;
|
||||
font-size: 14px;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.raw-details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.raw-details > summary::before {
|
||||
content: '展开';
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
color: #8ea4ff;
|
||||
}
|
||||
|
||||
.raw-details[open] > summary::before {
|
||||
content: '收起';
|
||||
}
|
||||
|
||||
.raw-details .result-box {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: "Cascadia Mono", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 6px 0 14px;
|
||||
}
|
||||
|
||||
.actions.wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
padding: 11px 16px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #eef2ff;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #6f7bf7 0%, #7c55f2 100%);
|
||||
}
|
||||
|
||||
.auth-submit {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #7d88fb 0%, #8c62f8 100%);
|
||||
}
|
||||
|
||||
.result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.kv {
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.kv .k {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #96a5ca;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.kv .v {
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.kv.good .v {
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.kv.bad .v {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.layout-grid,
|
||||
.form-grid,
|
||||
.result-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.field-span-2 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero-side {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,814 @@
|
||||
(() => {
|
||||
const $ = (sel) => document.querySelector(sel)
|
||||
const $$ = (sel) => Array.from(document.querySelectorAll(sel))
|
||||
|
||||
const STORAGE_PRESETS = 'token_lab.tool_presets.v1'
|
||||
const STORAGE_CURRENT_PRESET = 'token_lab.tool_current_preset.v1'
|
||||
|
||||
const state = {
|
||||
toolPresets: [],
|
||||
currentPresetId: '',
|
||||
toolKeys: null,
|
||||
authenticated: false
|
||||
}
|
||||
|
||||
const el = {
|
||||
workshopShell: $('#workshopShell'),
|
||||
notice: $('#globalNotice'),
|
||||
tabButtons: $$('.tab-btn'),
|
||||
tabPanels: $$('.tab-panel'),
|
||||
authScreen: $('#authScreen'),
|
||||
toolTabs: $('#toolTabs'),
|
||||
appMain: $('#appMain'),
|
||||
authUsername: $('#authUsername'),
|
||||
authPassword: $('#authPassword'),
|
||||
authHint: $('#authHint'),
|
||||
btnAuthLogout: $('#btnAuthLogout'),
|
||||
|
||||
navBaseUrl: $('#navBaseUrl'),
|
||||
navExpiry: $('#navExpiry'),
|
||||
navLinkOutput: $('#navLinkOutput'),
|
||||
navGenerateMeta: $('#navGenerateMeta'),
|
||||
navVerifyInput: $('#navVerifyInput'),
|
||||
navVerifyMeta: $('#navVerifyMeta'),
|
||||
navVerifyRaw: $('#navVerifyRaw'),
|
||||
|
||||
toolPresetSelect: $('#toolPresetSelect'),
|
||||
toolPresetName: $('#toolPresetName'),
|
||||
toolBaseUrl: $('#toolBaseUrl'),
|
||||
toolAlias: $('#toolAlias'),
|
||||
toolExpiry: $('#toolExpiry'),
|
||||
toolPassword: $('#toolPassword'),
|
||||
toolXmlFile: $('#toolXmlFile'),
|
||||
toolPublicJwk: $('#toolPublicJwk'),
|
||||
toolLinkOutput: $('#toolLinkOutput'),
|
||||
toolKeyMeta: $('#toolKeyMeta'),
|
||||
toolGenerateMeta: $('#toolGenerateMeta'),
|
||||
toolVerifyInput: $('#toolVerifyInput'),
|
||||
toolVerifyMeta: $('#toolVerifyMeta'),
|
||||
toolVerifyRaw: $('#toolVerifyRaw')
|
||||
}
|
||||
|
||||
function setNotice(message, type = '') {
|
||||
if (!message) {
|
||||
el.notice.hidden = true
|
||||
el.notice.className = 'notice'
|
||||
el.notice.textContent = ''
|
||||
return
|
||||
}
|
||||
el.notice.hidden = false
|
||||
el.notice.className = `notice ${type}`.trim()
|
||||
el.notice.textContent = message
|
||||
if (type === 'success') {
|
||||
window.clearTimeout(setNotice._timer)
|
||||
setNotice._timer = window.setTimeout(() => {
|
||||
if (el.notice.textContent === message) setNotice('')
|
||||
}, 1800)
|
||||
}
|
||||
}
|
||||
|
||||
function setAuthHint(message, type = '') {
|
||||
el.authHint.textContent = String(message || '')
|
||||
el.authHint.className = `auth-hint ${type}`.trim()
|
||||
}
|
||||
|
||||
function applyAuthUi() {
|
||||
el.authScreen.hidden = state.authenticated
|
||||
el.workshopShell.hidden = !state.authenticated
|
||||
el.toolTabs.hidden = !state.authenticated
|
||||
el.appMain.hidden = !state.authenticated
|
||||
el.btnAuthLogout.hidden = !state.authenticated
|
||||
if (!state.authenticated) {
|
||||
el.authPassword.value = ''
|
||||
setNotice('')
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson(url, options) {
|
||||
const resp = await fetch(url, options)
|
||||
let data = null
|
||||
try {
|
||||
data = await resp.json()
|
||||
} catch {
|
||||
data = null
|
||||
}
|
||||
if (resp.status === 401) {
|
||||
state.authenticated = false
|
||||
applyAuthUi()
|
||||
}
|
||||
if (!resp.ok || !data || data.ok === false) {
|
||||
const error = data && data.error ? data.error : `http_${resp.status}`
|
||||
throw new Error(error)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
async function refreshAuthStatus() {
|
||||
try {
|
||||
const data = await fetchJson('/api/token_lab/auth/status')
|
||||
state.authenticated = !!data.authenticated
|
||||
applyAuthUi()
|
||||
} catch (err) {
|
||||
state.authenticated = false
|
||||
applyAuthUi()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAuthLogin() {
|
||||
try {
|
||||
const username = String(el.authUsername.value || '').trim()
|
||||
const password = String(el.authPassword.value || '')
|
||||
if (!username || !password) {
|
||||
setAuthHint('请输入账号和密码', 'error')
|
||||
return
|
||||
}
|
||||
await fetchJson('/api/token_lab/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
})
|
||||
state.authenticated = true
|
||||
el.authUsername.value = ''
|
||||
el.authPassword.value = ''
|
||||
setAuthHint('')
|
||||
applyAuthUi()
|
||||
setNotice('令牌工坊登录成功。', 'success')
|
||||
} catch (err) {
|
||||
state.authenticated = false
|
||||
applyAuthUi()
|
||||
setAuthHint('登录失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAuthLogout() {
|
||||
try {
|
||||
await fetchJson('/api/token_lab/auth/logout', { method: 'POST' })
|
||||
state.authenticated = false
|
||||
el.authUsername.value = ''
|
||||
el.authPassword.value = ''
|
||||
applyAuthUi()
|
||||
setAuthHint('')
|
||||
setNotice('令牌工坊已退出登录。', 'success')
|
||||
} catch (err) {
|
||||
setNotice(`退出登录失败:${err.message || err}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function safeJsonParse(text, fallback) {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function toDatetimeLocalValue(date) {
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
const d = new Date(date)
|
||||
const yyyy = d.getFullYear()
|
||||
const mm = pad(d.getMonth() + 1)
|
||||
const dd = pad(d.getDate())
|
||||
const hh = pad(d.getHours())
|
||||
const mi = pad(d.getMinutes())
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${mi}`
|
||||
}
|
||||
|
||||
function epochFromDatetimeLocal(value) {
|
||||
if (!value) return 0
|
||||
const ms = new Date(value).getTime()
|
||||
if (!Number.isFinite(ms)) return 0
|
||||
return Math.floor(ms / 1000)
|
||||
}
|
||||
|
||||
function formatEpoch(epochSeconds) {
|
||||
if (!epochSeconds) return '-'
|
||||
try {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
}).format(new Date(epochSeconds * 1000))
|
||||
} catch {
|
||||
return new Date(epochSeconds * 1000).toLocaleString()
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelative(exp) {
|
||||
if (!exp) return '-'
|
||||
const diff = exp - Math.floor(Date.now() / 1000)
|
||||
const abs = Math.abs(diff)
|
||||
const days = Math.floor(abs / 86400)
|
||||
const hours = Math.floor((abs % 86400) / 3600)
|
||||
const mins = Math.floor((abs % 3600) / 60)
|
||||
const text = `${days}天 ${hours}小时 ${mins}分钟`
|
||||
return diff >= 0 ? `剩余 ${text}` : `已过期 ${text}`
|
||||
}
|
||||
|
||||
function base64UrlFromBytes(bytes) {
|
||||
let binary = ''
|
||||
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
|
||||
for (let i = 0; i < arr.length; i += 1) binary += String.fromCharCode(arr[i])
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function bytesFromBase64Url(str) {
|
||||
const normalized = String(str || '').replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4)
|
||||
const binary = atob(padded)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
|
||||
return bytes
|
||||
}
|
||||
|
||||
function bytesFromBase64(str) {
|
||||
const binary = atob(String(str || ''))
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
|
||||
return bytes
|
||||
}
|
||||
|
||||
function base64FromBytes(bytes) {
|
||||
let binary = ''
|
||||
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
|
||||
for (let i = 0; i < arr.length; i += 1) binary += String.fromCharCode(arr[i])
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
function encodeUtf8(text) {
|
||||
return new TextEncoder().encode(String(text || ''))
|
||||
}
|
||||
|
||||
function decodeJwtPart(part) {
|
||||
try {
|
||||
return JSON.parse(new TextDecoder().decode(bytesFromBase64Url(part)))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function extractToken(value) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) return ''
|
||||
if (/^[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+$/.test(text)) return text
|
||||
try {
|
||||
const url = new URL(text)
|
||||
const token = String(url.searchParams.get('token') || '').trim()
|
||||
if (token) return token
|
||||
} catch {}
|
||||
const match = text.match(/(?:^|[?&])token=([^&#\s]+)/)
|
||||
return match && match[1] ? decodeURIComponent(match[1]) : ''
|
||||
}
|
||||
|
||||
function appendTokenToUrl(baseUrl, token) {
|
||||
const base = String(baseUrl || '').trim()
|
||||
if (!base) return `?token=${token}`
|
||||
return base.includes('?') ? `${base}&token=${token}` : `${base}?token=${token}`
|
||||
}
|
||||
|
||||
function createKvHtml(items) {
|
||||
return items.map((item) => {
|
||||
const status = item.status ? ` ${item.status}` : ''
|
||||
return `<div class="kv${status}"><span class="k">${escapeHtml(item.label)}</span><span class="v">${escapeHtml(item.value)}</span></div>`
|
||||
}).join('')
|
||||
}
|
||||
|
||||
function createVerifyDebugJson(payload) {
|
||||
return JSON.stringify(payload, null, 2)
|
||||
}
|
||||
|
||||
function deriveToolAlias() {
|
||||
const preset = currentPreset()
|
||||
const fromName = String(el.toolPresetName.value || '').trim()
|
||||
if (fromName) return fromName
|
||||
if (preset && preset.name) return String(preset.name).trim()
|
||||
return 'tool-token'
|
||||
}
|
||||
|
||||
function scrollToolGenerateIntoView() {
|
||||
try {
|
||||
el.toolLinkOutput.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function switchTab(name) {
|
||||
el.tabButtons.forEach((btn) => btn.classList.toggle('active', btn.dataset.tab === name))
|
||||
el.tabPanels.forEach((panel) => panel.classList.toggle('active', panel.dataset.panel === name))
|
||||
}
|
||||
|
||||
function defaultPresets() {
|
||||
const origin = window.location.origin || ''
|
||||
return [
|
||||
{
|
||||
id: 'expense',
|
||||
name: 'expense',
|
||||
baseUrl: `${origin}/tools/expense`,
|
||||
privateXml: ''
|
||||
},
|
||||
{
|
||||
id: 'ai-lib',
|
||||
name: 'ai-lib',
|
||||
baseUrl: `${origin}/tools/ai-lib`,
|
||||
privateXml: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function loadPresets() {
|
||||
const stored = safeJsonParse(localStorage.getItem(STORAGE_PRESETS) || '', null)
|
||||
const presets = Array.isArray(stored) && stored.length ? stored : defaultPresets()
|
||||
state.toolPresets = presets.map((item, index) => ({
|
||||
id: String(item.id || item.name || `preset-${index + 1}`),
|
||||
name: String(item.name || `preset-${index + 1}`),
|
||||
baseUrl: String(item.baseUrl || ''),
|
||||
privateXml: String(item.privateXml || '')
|
||||
}))
|
||||
state.currentPresetId = localStorage.getItem(STORAGE_CURRENT_PRESET) || state.toolPresets[0].id
|
||||
}
|
||||
|
||||
function savePresets() {
|
||||
localStorage.setItem(STORAGE_PRESETS, JSON.stringify(state.toolPresets))
|
||||
localStorage.setItem(STORAGE_CURRENT_PRESET, state.currentPresetId || '')
|
||||
}
|
||||
|
||||
function currentPreset() {
|
||||
return state.toolPresets.find((item) => item.id === state.currentPresetId) || null
|
||||
}
|
||||
|
||||
function renderPresetSelect() {
|
||||
el.toolPresetSelect.innerHTML = state.toolPresets.map((item) => (
|
||||
`<option value="${escapeHtml(item.id)}">${escapeHtml(item.name)}</option>`
|
||||
)).join('')
|
||||
el.toolPresetSelect.value = state.currentPresetId || ''
|
||||
}
|
||||
|
||||
async function applyPreset(presetId) {
|
||||
state.currentPresetId = presetId
|
||||
savePresets()
|
||||
renderPresetSelect()
|
||||
const preset = currentPreset()
|
||||
if (!preset) return
|
||||
el.toolPresetName.value = preset.name
|
||||
el.toolBaseUrl.value = preset.baseUrl
|
||||
if (!String(el.toolAlias.value || '').trim()) el.toolAlias.value = preset.name
|
||||
if (preset.privateXml) {
|
||||
try {
|
||||
state.toolKeys = await importKeyPairFromXml(preset.privateXml)
|
||||
} catch {
|
||||
state.toolKeys = null
|
||||
setNotice(`预设「${preset.name}」里的私钥 XML 载入失败,请重新导入。`, 'error')
|
||||
}
|
||||
} else {
|
||||
state.toolKeys = null
|
||||
}
|
||||
await refreshToolKeyUi()
|
||||
}
|
||||
|
||||
async function saveCurrentPreset() {
|
||||
let preset = currentPreset()
|
||||
const name = String(el.toolPresetName.value || '').trim()
|
||||
const baseUrl = String(el.toolBaseUrl.value || '').trim()
|
||||
if (!name) {
|
||||
setNotice('请先填写预设名称。', 'error')
|
||||
return
|
||||
}
|
||||
let privateXml = ''
|
||||
if (state.toolKeys && state.toolKeys.privateKey) privateXml = await exportPrivateXml(state.toolKeys.privateKey)
|
||||
if (!preset) {
|
||||
let id = name
|
||||
let seq = 2
|
||||
while (state.toolPresets.some((item) => item.id === id)) {
|
||||
id = `${name}-${seq}`
|
||||
seq += 1
|
||||
}
|
||||
preset = { id, name, baseUrl, privateXml }
|
||||
state.toolPresets.push(preset)
|
||||
state.currentPresetId = preset.id
|
||||
} else {
|
||||
preset.id = preset.id || name
|
||||
preset.name = name
|
||||
preset.baseUrl = baseUrl
|
||||
preset.privateXml = privateXml
|
||||
state.currentPresetId = preset.id
|
||||
}
|
||||
savePresets()
|
||||
renderPresetSelect()
|
||||
el.toolPresetSelect.value = state.currentPresetId
|
||||
setNotice('当前预设已保存到本浏览器。', 'success')
|
||||
}
|
||||
|
||||
async function startNewPreset() {
|
||||
state.currentPresetId = ''
|
||||
savePresets()
|
||||
renderPresetSelect()
|
||||
el.toolPresetSelect.value = ''
|
||||
el.toolPresetName.value = ''
|
||||
el.toolBaseUrl.value = ''
|
||||
el.toolAlias.value = ''
|
||||
el.toolPassword.value = ''
|
||||
el.toolLinkOutput.value = ''
|
||||
el.toolGenerateMeta.innerHTML = ''
|
||||
el.toolVerifyInput.value = ''
|
||||
el.toolVerifyMeta.innerHTML = ''
|
||||
el.toolVerifyRaw.textContent = ''
|
||||
setNotice('已切到新预设草稿。填好名称后保存,就会新增一个站点预设。', 'success')
|
||||
}
|
||||
|
||||
function deleteCurrentPreset() {
|
||||
const preset = currentPreset()
|
||||
if (!preset) return
|
||||
if (!window.confirm(`确定删除预设「${preset.name}」吗?`)) return
|
||||
state.toolPresets = state.toolPresets.filter((item) => item.id !== preset.id)
|
||||
if (!state.toolPresets.length) state.toolPresets = defaultPresets()
|
||||
state.currentPresetId = state.toolPresets[0].id
|
||||
savePresets()
|
||||
applyPreset(state.currentPresetId)
|
||||
setNotice('预设已删除。', 'success')
|
||||
}
|
||||
|
||||
function xmlTagValue(doc, tag) {
|
||||
const node = doc.querySelector(tag)
|
||||
return node ? String(node.textContent || '').trim() : ''
|
||||
}
|
||||
|
||||
function xmlToPrivateJwk(xmlText) {
|
||||
const doc = new DOMParser().parseFromString(xmlText, 'application/xml')
|
||||
const modulus = xmlTagValue(doc, 'Modulus')
|
||||
const exponent = xmlTagValue(doc, 'Exponent')
|
||||
const d = xmlTagValue(doc, 'D')
|
||||
if (!modulus || !exponent || !d) throw new Error('私钥 XML 内容不完整')
|
||||
const jwk = {
|
||||
kty: 'RSA',
|
||||
n: base64UrlFromBytes(bytesFromBase64(modulus)),
|
||||
e: base64UrlFromBytes(bytesFromBase64(exponent)),
|
||||
d: base64UrlFromBytes(bytesFromBase64(d)),
|
||||
p: base64UrlFromBytes(bytesFromBase64(xmlTagValue(doc, 'P'))),
|
||||
q: base64UrlFromBytes(bytesFromBase64(xmlTagValue(doc, 'Q'))),
|
||||
dp: base64UrlFromBytes(bytesFromBase64(xmlTagValue(doc, 'DP'))),
|
||||
dq: base64UrlFromBytes(bytesFromBase64(xmlTagValue(doc, 'DQ'))),
|
||||
qi: base64UrlFromBytes(bytesFromBase64(xmlTagValue(doc, 'InverseQ'))),
|
||||
ext: true
|
||||
}
|
||||
return jwk
|
||||
}
|
||||
|
||||
function privateJwkToXml(jwk) {
|
||||
const read = (name) => base64FromBytes(bytesFromBase64Url(jwk[name] || ''))
|
||||
return [
|
||||
'<RSAKeyValue>',
|
||||
`<Modulus>${read('n')}</Modulus>`,
|
||||
`<Exponent>${read('e')}</Exponent>`,
|
||||
`<P>${read('p')}</P>`,
|
||||
`<Q>${read('q')}</Q>`,
|
||||
`<DP>${read('dp')}</DP>`,
|
||||
`<DQ>${read('dq')}</DQ>`,
|
||||
`<InverseQ>${read('qi')}</InverseQ>`,
|
||||
`<D>${read('d')}</D>`,
|
||||
'</RSAKeyValue>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
async function importKeyPairFromXml(xmlText) {
|
||||
const privateJwk = xmlToPrivateJwk(xmlText)
|
||||
const publicJwk = { kty: 'RSA', n: privateJwk.n, e: privateJwk.e, ext: true }
|
||||
const algorithm = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }
|
||||
const privateKey = await crypto.subtle.importKey('jwk', privateJwk, algorithm, true, ['sign'])
|
||||
const publicKey = await crypto.subtle.importKey('jwk', publicJwk, algorithm, true, ['verify'])
|
||||
return { privateKey, publicKey }
|
||||
}
|
||||
|
||||
async function generateKeyPair() {
|
||||
return crypto.subtle.generateKey({
|
||||
name: 'RSASSA-PKCS1-v1_5',
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-256'
|
||||
}, true, ['sign', 'verify'])
|
||||
}
|
||||
|
||||
async function exportPrivateXml(privateKey) {
|
||||
const jwk = await crypto.subtle.exportKey('jwk', privateKey)
|
||||
return privateJwkToXml(jwk)
|
||||
}
|
||||
|
||||
async function exportPublicJwk(publicKey) {
|
||||
return crypto.subtle.exportKey('jwk', publicKey)
|
||||
}
|
||||
|
||||
async function fingerprintFromPublicKey(publicKey) {
|
||||
const jwk = await exportPublicJwk(publicKey)
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytesFromBase64Url(jwk.n))
|
||||
return Array.from(new Uint8Array(digest)).slice(0, 6).map((n) => n.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
async function refreshToolKeyUi() {
|
||||
if (!state.toolKeys || !state.toolKeys.publicKey) {
|
||||
el.toolPublicJwk.value = ''
|
||||
el.toolKeyMeta.innerHTML = createKvHtml([
|
||||
{ label: '当前状态', value: '还没有载入私钥', status: 'bad' },
|
||||
{ label: '说明', value: '先生成或导入 XML 私钥后,才能生成和验签 Tool 令牌。' }
|
||||
])
|
||||
return
|
||||
}
|
||||
const publicJwk = await exportPublicJwk(state.toolKeys.publicKey)
|
||||
const fingerprint = await fingerprintFromPublicKey(state.toolKeys.publicKey)
|
||||
el.toolPublicJwk.value = JSON.stringify(publicJwk, null, 2)
|
||||
el.toolKeyMeta.innerHTML = createKvHtml([
|
||||
{ label: '当前状态', value: '私钥已载入', status: 'good' },
|
||||
{ label: '密钥指纹', value: fingerprint },
|
||||
{ label: '公钥 kty', value: publicJwk.kty || '-' },
|
||||
{ label: '说明', value: '保存预设后,这把私钥会只保存在当前浏览器本地。' }
|
||||
])
|
||||
}
|
||||
|
||||
async function copyText(value, successMessage) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
setNotice('没有可复制的内容。', 'error')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setNotice(successMessage || '已复制。', 'success')
|
||||
} catch {
|
||||
try {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.setAttribute('readonly', 'readonly')
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.left = '-9999px'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
ta.setSelectionRange(0, ta.value.length)
|
||||
const ok = document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
if (!ok) throw new Error('copy_failed')
|
||||
setNotice(successMessage || '已复制。', 'success')
|
||||
} catch {
|
||||
setNotice('复制失败,请手动选中复制。', 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNavGenerate() {
|
||||
try {
|
||||
const exp = epochFromDatetimeLocal(el.navExpiry.value)
|
||||
const baseUrl = String(el.navBaseUrl.value || '').trim()
|
||||
const data = await fetchJson('/api/token_lab/nav/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ baseUrl, exp })
|
||||
})
|
||||
el.navLinkOutput.value = data.link || ''
|
||||
el.navGenerateMeta.innerHTML = createKvHtml([
|
||||
{ label: 'iss', value: data.payload.iss || '-' },
|
||||
{ label: 'iat(北京时间)', value: formatEpoch(data.payload.iat) },
|
||||
{ label: 'exp(北京时间)', value: formatEpoch(data.payload.exp) },
|
||||
{ label: '剩余时长', value: formatRelative(data.payload.exp), status: 'good' },
|
||||
{ label: 'jti', value: data.payload.jti || '-' },
|
||||
{ label: '基础地址', value: baseUrl || '/' }
|
||||
])
|
||||
setNotice('首页导航令牌已生成。', 'success')
|
||||
} catch (err) {
|
||||
setNotice(`生成首页令牌失败:${err.message || err}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNavVerify() {
|
||||
try {
|
||||
const value = String(el.navVerifyInput.value || '').trim()
|
||||
const data = await fetchJson('/api/token_lab/nav/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value })
|
||||
})
|
||||
const payload = data.payload || {}
|
||||
const result = data.result || {}
|
||||
el.navVerifyMeta.innerHTML = createKvHtml([
|
||||
{ label: '类型', value: '首页导航 token' },
|
||||
{ label: '签名验证', value: result.sig_ok ? '通过' : '未通过', status: result.sig_ok ? 'good' : 'bad' },
|
||||
{ label: '整体状态', value: result.ok ? '可用' : `不可用(${result.reason || '校验失败'})`, status: result.ok ? 'good' : 'bad' },
|
||||
{ label: 'iss', value: payload.iss || '-' },
|
||||
{ label: 'iat(北京时间)', value: formatEpoch(payload.iat) },
|
||||
{ label: 'exp(北京时间)', value: formatEpoch(payload.exp) },
|
||||
{ label: '剩余时长', value: formatRelative(payload.exp), status: result.ok ? 'good' : 'bad' },
|
||||
{ label: 'jti', value: payload.jti || '-' }
|
||||
])
|
||||
el.navVerifyRaw.textContent = createVerifyDebugJson({
|
||||
header: data.header || null,
|
||||
payload,
|
||||
result: {
|
||||
ok: !!result.ok,
|
||||
sig_ok: !!result.sig_ok,
|
||||
reason: result.reason || '',
|
||||
matched_source: result.matched_source || '',
|
||||
checked_keys: result.checked_keys || 0,
|
||||
keys_sources: Array.isArray(result.keys_sources) ? result.keys_sources : []
|
||||
}
|
||||
})
|
||||
setNotice('首页令牌验证完成。', 'success')
|
||||
} catch (err) {
|
||||
setNotice(`验证首页令牌失败:${err.message || err}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256Base64Url(text) {
|
||||
const digest = await crypto.subtle.digest('SHA-256', encodeUtf8(text))
|
||||
return base64UrlFromBytes(new Uint8Array(digest))
|
||||
}
|
||||
|
||||
async function handleToolGenerate() {
|
||||
try {
|
||||
if (!state.toolKeys || !state.toolKeys.privateKey) throw new Error('请先生成或导入私钥 XML')
|
||||
const alias = String(el.toolAlias.value || '').trim() || deriveToolAlias()
|
||||
const baseUrl = String(el.toolBaseUrl.value || '').trim()
|
||||
const exp = epochFromDatetimeLocal(el.toolExpiry.value)
|
||||
el.toolAlias.value = alias
|
||||
if (!baseUrl) {
|
||||
el.toolBaseUrl.focus()
|
||||
throw new Error('请先填写基础地址')
|
||||
}
|
||||
if (!exp) {
|
||||
el.toolExpiry.focus()
|
||||
throw new Error('请先填写过期时间')
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
if (exp <= now) throw new Error('过期时间必须晚于当前时间')
|
||||
const payload = {
|
||||
alias,
|
||||
iat: now,
|
||||
exp,
|
||||
jti: crypto.randomUUID().replace(/-/g, ''),
|
||||
pwd: await sha256Base64Url(String(el.toolPassword.value || ''))
|
||||
}
|
||||
const header = { alg: 'RS256', typ: 'JWT' }
|
||||
const data = `${base64UrlFromBytes(encodeUtf8(JSON.stringify(header)))}.${base64UrlFromBytes(encodeUtf8(JSON.stringify(payload)))}`
|
||||
const signature = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', state.toolKeys.privateKey, encodeUtf8(data))
|
||||
const token = `${data}.${base64UrlFromBytes(new Uint8Array(signature))}`
|
||||
const link = appendTokenToUrl(baseUrl, token)
|
||||
el.toolLinkOutput.value = link
|
||||
el.toolGenerateMeta.innerHTML = createKvHtml([
|
||||
{ label: 'alias', value: payload.alias },
|
||||
{ label: 'iat(北京时间)', value: formatEpoch(payload.iat) },
|
||||
{ label: 'exp(北京时间)', value: formatEpoch(payload.exp) },
|
||||
{ label: '剩余时长', value: formatRelative(payload.exp), status: 'good' },
|
||||
{ label: 'jti', value: payload.jti },
|
||||
{ label: 'pwd 字段', value: payload.pwd ? '已生成' : '空' }
|
||||
])
|
||||
scrollToolGenerateIntoView()
|
||||
setNotice('Tool 令牌已在浏览器本地生成。', 'success')
|
||||
} catch (err) {
|
||||
el.toolGenerateMeta.innerHTML = createKvHtml([
|
||||
{ label: '生成失败', value: String(err.message || err), status: 'bad' }
|
||||
])
|
||||
scrollToolGenerateIntoView()
|
||||
setNotice(`生成 Tool 令牌失败:${err.message || err}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToolVerify() {
|
||||
try {
|
||||
const token = extractToken(el.toolVerifyInput.value)
|
||||
if (!token) throw new Error('没有识别到 token')
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) throw new Error('token 格式不正确')
|
||||
const header = decodeJwtPart(parts[0]) || {}
|
||||
const payload = decodeJwtPart(parts[1]) || {}
|
||||
let signatureLabel = '未载入当前密钥'
|
||||
let signatureStatus = ''
|
||||
let signatureOk = null
|
||||
if (state.toolKeys && state.toolKeys.publicKey) {
|
||||
signatureOk = await crypto.subtle.verify(
|
||||
'RSASSA-PKCS1-v1_5',
|
||||
state.toolKeys.publicKey,
|
||||
bytesFromBase64Url(parts[2]),
|
||||
encodeUtf8(`${parts[0]}.${parts[1]}`)
|
||||
)
|
||||
signatureLabel = signatureOk ? '通过' : '未通过'
|
||||
signatureStatus = signatureOk ? 'good' : 'bad'
|
||||
}
|
||||
el.toolVerifyMeta.innerHTML = createKvHtml([
|
||||
{ label: '类型', value: payload.alias || payload.pwd ? 'Tool token' : '未知格式' },
|
||||
{ label: '签名验证', value: signatureLabel, status: signatureStatus },
|
||||
{ label: 'alias', value: payload.alias || '-' },
|
||||
{ label: 'iat(北京时间)', value: formatEpoch(payload.iat) },
|
||||
{ label: 'exp(北京时间)', value: formatEpoch(payload.exp) },
|
||||
{ label: '剩余时长', value: formatRelative(payload.exp), status: payload.exp && payload.exp >= Math.floor(Date.now() / 1000) ? 'good' : 'bad' },
|
||||
{ label: 'jti', value: payload.jti || '-' },
|
||||
{ label: 'pwd 字段', value: payload.pwd ? '存在' : '不存在' }
|
||||
])
|
||||
el.toolVerifyRaw.textContent = createVerifyDebugJson({
|
||||
header,
|
||||
payload,
|
||||
result: {
|
||||
signature_ok: signatureOk,
|
||||
key_loaded: !!(state.toolKeys && state.toolKeys.publicKey)
|
||||
}
|
||||
})
|
||||
setNotice('Tool 令牌验证完成。', 'success')
|
||||
} catch (err) {
|
||||
setNotice(`验证 Tool 令牌失败:${err.message || err}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportXmlFile(file) {
|
||||
if (!file) return
|
||||
const text = await file.text()
|
||||
state.toolKeys = await importKeyPairFromXml(text)
|
||||
await refreshToolKeyUi()
|
||||
setNotice('XML 私钥已导入。记得按一下“保存当前预设”。', 'success')
|
||||
}
|
||||
|
||||
function downloadText(filename, content) {
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
el.tabButtons.forEach((btn) => {
|
||||
btn.addEventListener('click', () => switchTab(btn.dataset.tab))
|
||||
})
|
||||
|
||||
$('#btnNavGenerate').addEventListener('click', handleNavGenerate)
|
||||
$('#btnNavCopy').addEventListener('click', () => copyText(el.navLinkOutput.value, '首页链接已复制。'))
|
||||
$('#btnNavVerify').addEventListener('click', handleNavVerify)
|
||||
$('#btnAuthLogin').addEventListener('click', handleAuthLogin)
|
||||
el.authUsername.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') handleAuthLogin()
|
||||
})
|
||||
el.authPassword.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') handleAuthLogin()
|
||||
})
|
||||
el.btnAuthLogout.addEventListener('click', handleAuthLogout)
|
||||
|
||||
el.toolPresetSelect.addEventListener('change', (e) => applyPreset(e.target.value))
|
||||
$('#btnToolSavePreset').addEventListener('click', saveCurrentPreset)
|
||||
$('#btnToolDeletePreset').addEventListener('click', deleteCurrentPreset)
|
||||
$('#btnToolGenerateKey').addEventListener('click', async () => {
|
||||
state.toolKeys = await generateKeyPair()
|
||||
await refreshToolKeyUi()
|
||||
setNotice('新的 RSA 密钥已生成。记得按一下“保存当前预设”。', 'success')
|
||||
})
|
||||
$('#btnToolImportXml').addEventListener('click', () => el.toolXmlFile.click())
|
||||
el.toolXmlFile.addEventListener('change', async (e) => {
|
||||
try {
|
||||
await handleImportXmlFile(e.target.files && e.target.files[0])
|
||||
} catch (err) {
|
||||
setNotice(`导入 XML 失败:${err.message || err}`, 'error')
|
||||
} finally {
|
||||
e.target.value = ''
|
||||
}
|
||||
})
|
||||
$('#btnToolExportXml').addEventListener('click', async () => {
|
||||
try {
|
||||
if (!state.toolKeys || !state.toolKeys.privateKey) throw new Error('当前没有可导出的私钥')
|
||||
const xml = await exportPrivateXml(state.toolKeys.privateKey)
|
||||
const filename = `${String(el.toolPresetName.value || 'tool-token').trim() || 'tool-token'}_private_key.xml`
|
||||
downloadText(filename, xml)
|
||||
setNotice('私钥 XML 已导出。', 'success')
|
||||
} catch (err) {
|
||||
setNotice(`导出 XML 失败:${err.message || err}`, 'error')
|
||||
}
|
||||
})
|
||||
$('#btnToolNewPreset').addEventListener('click', startNewPreset)
|
||||
$('#btnToolCopyJwk').addEventListener('click', () => copyText(el.toolPublicJwk.value, '公钥 JWK 已复制。'))
|
||||
$('#btnToolGenerateToken').addEventListener('click', handleToolGenerate)
|
||||
$('#btnToolCopyLink').addEventListener('click', () => copyText(el.toolLinkOutput.value, 'Tool 链接已复制。'))
|
||||
$('#btnToolVerify').addEventListener('click', handleToolVerify)
|
||||
}
|
||||
|
||||
async function init() {
|
||||
loadPresets()
|
||||
renderPresetSelect()
|
||||
bindEvents()
|
||||
el.navBaseUrl.value = `${window.location.origin}/`
|
||||
el.navExpiry.value = toDatetimeLocalValue(Date.now() + 1000 * 60 * 60 * 24 * 30)
|
||||
el.toolExpiry.value = toDatetimeLocalValue(Date.now() + 1000 * 60 * 60 * 24 * 7)
|
||||
await applyPreset(state.currentPresetId)
|
||||
await refreshAuthStatus()
|
||||
}
|
||||
|
||||
init().catch((err) => {
|
||||
setNotice(`初始化失败:${err.message || err}`, 'error')
|
||||
})
|
||||
})()
|
||||
@@ -1573,6 +1573,24 @@ const signJwtRS256 = (payload, jwk) => {
|
||||
const sig = crypto.sign('RSA-SHA256', Buffer.from(data), priv).toString('base64').replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'')
|
||||
return data + '.' + sig
|
||||
}
|
||||
const extractJwtFromInput = (raw) => {
|
||||
const text = String(raw || '').trim()
|
||||
if (!text) return ''
|
||||
if (/^[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+$/.test(text)) return text
|
||||
try {
|
||||
const u = new URL(text)
|
||||
const fromQuery = String(u.searchParams.get('token') || '').trim()
|
||||
if (fromQuery) return fromQuery
|
||||
} catch {}
|
||||
const match = text.match(/(?:^|[?&])token=([^&#\s]+)/)
|
||||
if (match && match[1]) return decodeURIComponent(match[1])
|
||||
return ''
|
||||
}
|
||||
const appendTokenToUrl = (baseUrl, token) => {
|
||||
const base = String(baseUrl || '').trim()
|
||||
if (!base) return `/?token=${token}`
|
||||
return base.includes('?') ? `${base}&token=${token}` : `${base}?token=${token}`
|
||||
}
|
||||
const readNavTargets = () => {
|
||||
try {
|
||||
const s = String(process.env.NAV_TARGETS_JSON || '')
|
||||
@@ -4145,6 +4163,98 @@ app.get('/api/debug/nav/private', (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/token_lab/auth/status', (req, res) => {
|
||||
try {
|
||||
if (!hasNavAuthRoot(req)) return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||||
return res.json({
|
||||
ok: true,
|
||||
authenticated: hasTokenLabAuth(req)
|
||||
})
|
||||
} catch (e) {
|
||||
return res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/token_lab/auth/login', (req, res) => {
|
||||
try {
|
||||
if (!hasNavAuthRoot(req)) return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||||
const username = String(req.body?.username || '').trim()
|
||||
const password = String(req.body?.password || '')
|
||||
const expectedUser = String(process.env.TOKEN_LAB_USERNAME || '').trim()
|
||||
const expectedPass = String(process.env.TOKEN_LAB_PASSWORD || '')
|
||||
if (!expectedUser || !expectedPass) return res.status(500).json({ ok: false, error: 'token_lab_creds_not_configured' })
|
||||
if (username !== expectedUser || password !== expectedPass) {
|
||||
return res.status(401).json({ ok: false, error: '账号或密码错误' })
|
||||
}
|
||||
res.cookie(TOKEN_LAB_AUTH_COOKIE, '1', { httpOnly: true, sameSite: 'lax', maxAge: getTokenLabAuthMaxAge(), path: '/' })
|
||||
return res.json({ ok: true })
|
||||
} catch (e) {
|
||||
return res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/token_lab/auth/logout', (req, res) => {
|
||||
try {
|
||||
if (!hasNavAuthRoot(req)) return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||||
res.clearCookie(TOKEN_LAB_AUTH_COOKIE, { httpOnly: true, sameSite: 'lax', path: '/' })
|
||||
return res.json({ ok: true })
|
||||
} catch (e) {
|
||||
return res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
app.use('/api/token_lab', (req, res, next) => {
|
||||
try {
|
||||
if (!hasNavAuthRoot(req)) return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||||
if (req.path === '/auth/status' || req.path === '/auth/login' || req.path === '/auth/logout') return next()
|
||||
if (hasTokenLabAuth(req)) return next()
|
||||
return res.status(401).json({ ok: false, error: 'token_lab_login_required' })
|
||||
} catch {
|
||||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/token_lab/nav/generate', (req, res) => {
|
||||
try {
|
||||
const baseUrl = String(req.body?.baseUrl || '').trim()
|
||||
const exp = parseInt(String(req.body?.exp || ''), 10)
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
if (!Number.isFinite(exp) || exp <= now) return res.status(400).json({ ok: false, error: 'bad_exp' })
|
||||
if (exp > now + 3600 * 24 * 365 * 2) return res.status(400).json({ ok: false, error: 'exp_too_far' })
|
||||
const priv = readNavPrivateJwk()
|
||||
if (!priv) return res.status(500).json({ ok: false, error: 'nav_private_key_missing' })
|
||||
const payload = {
|
||||
iss: getNavIssFromFlags(),
|
||||
iat: now,
|
||||
exp,
|
||||
jti: crypto.randomUUID()
|
||||
}
|
||||
const token = signJwtRS256(payload, priv)
|
||||
const link = appendTokenToUrl(baseUrl || '/', token)
|
||||
return res.json({ ok: true, token, link, payload })
|
||||
} catch (e) {
|
||||
return res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/token_lab/nav/verify', (req, res) => {
|
||||
try {
|
||||
const raw = String(req.body?.value || '').trim()
|
||||
const token = extractJwtFromInput(raw)
|
||||
if (!token) return res.status(400).json({ ok: false, error: 'missing_token' })
|
||||
const result = debugVerifyJwt(token, [], { issStrict: computeNavIssStrict() })
|
||||
return res.json({
|
||||
ok: true,
|
||||
token,
|
||||
result,
|
||||
payload: result.payload || null,
|
||||
header: result.header || null
|
||||
})
|
||||
} catch (e) {
|
||||
return res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/flags', (req, res) => {
|
||||
const enabled = computeDebugEnabled()
|
||||
res.json({ ok: true, debugToolEnabled: enabled, debug: { weibo: computeToolDebug('weibo'), markets: computeToolDebug('markets'), wall: computeToolDebug('wall'), funds_guoxin: computeToolDebug('funds_guoxin'), funds_huatai: computeToolDebug('funds_huatai'), weekly: computeToolDebug('weekly') } })
|
||||
@@ -5545,12 +5655,20 @@ app.get('/api/archives/get', (req, res) => {
|
||||
})
|
||||
|
||||
const NAV_AUTH_COOKIE = 'nav_gate'
|
||||
const TOKEN_LAB_AUTH_COOKIE = 'token_lab_gate'
|
||||
const hasNavAuthRoot = (req) => {
|
||||
try {
|
||||
const cookies = parseCookie(req.headers.cookie || '')
|
||||
return cookies[NAV_AUTH_COOKIE] === '1'
|
||||
} catch { return false }
|
||||
}
|
||||
const hasTokenLabAuth = (req) => {
|
||||
try {
|
||||
const cookies = parseCookie(req.headers.cookie || '')
|
||||
return cookies[TOKEN_LAB_AUTH_COOKIE] === '1'
|
||||
} catch { return false }
|
||||
}
|
||||
const getTokenLabAuthMaxAge = () => 1000 * 60 * 60 * 24
|
||||
|
||||
app.get('/index.html', (req, res) => {
|
||||
try {
|
||||
@@ -6195,6 +6313,35 @@ app.use('/tools/web_order_box', (req, res, next) => {
|
||||
next()
|
||||
})
|
||||
app.use('/tools/web_order_box', express.static(path.join(process.cwd(), 'public', 'tools', 'web_order_box')))
|
||||
app.get('/tools/token_lab', (req, res) => {
|
||||
try {
|
||||
if (hasNavAuthRoot(req)) return res.redirect('/tools/token_lab/index.html')
|
||||
const token = String(req.query.token || '').trim()
|
||||
if (!token) return res.status(401).send('未授权')
|
||||
const payload = verifyJwtWithKeys(token, [])
|
||||
if (!payload) return res.status(401).send('未授权')
|
||||
const iss = String(payload.iss || '')
|
||||
const navIss = getNavIssFromFlags()
|
||||
if (iss === navIss && !audMatch(payload, 'Tools-token_lab')) return res.status(401).send('未授权')
|
||||
res.cookie(NAV_AUTH_COOKIE, '1', { httpOnly: true, sameSite: 'lax' })
|
||||
return res.redirect('/tools/token_lab/index.html')
|
||||
} catch {
|
||||
return res.status(401).send('未授权')
|
||||
}
|
||||
})
|
||||
app.use('/tools/token_lab', (req, res, next) => {
|
||||
try {
|
||||
if (hasNavAuthRoot(req)) return next()
|
||||
return res.status(401).send('未授权')
|
||||
} catch {
|
||||
return res.status(401).send('未授权')
|
||||
}
|
||||
})
|
||||
app.use('/tools/token_lab', (req, res, next) => {
|
||||
res.set('X-Frame-Options', 'SAMEORIGIN')
|
||||
next()
|
||||
})
|
||||
app.use('/tools/token_lab', express.static(path.join(process.cwd(), 'public', 'tools', 'token_lab')))
|
||||
app.use('/tools/investment_ledger', (req, res, next) => {
|
||||
try {
|
||||
if (hasNavAuthRoot(req)) return next()
|
||||
|
||||
Reference in New Issue
Block a user