Files
yangxiangyuan 18e8e787d2 feat(token_lab): 新增令牌工坊工具及配套后端逻辑
新增完整的令牌工坊工具链:
- 添加前端静态资源:登录界面、导航与Tool令牌管理页面,配套样式与脚本文件
- 添加后端接口与中间件:认证相关接口、路由权限控制,以及导航令牌的生成验证API
- 添加默认配置文件,配置令牌工坊默认走导航登录,不对外公网开放
2026-08-02 10:18:44 +08:00

815 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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')
})
})()