18e8e787d2
新增完整的令牌工坊工具链: - 添加前端静态资源:登录界面、导航与Tool令牌管理页面,配套样式与脚本文件 - 添加后端接口与中间件:认证相关接口、路由权限控制,以及导航令牌的生成验证API - 添加默认配置文件,配置令牌工坊默认走导航登录,不对外公网开放
815 lines
29 KiB
JavaScript
815 lines
29 KiB
JavaScript
(() => {
|
||
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')
|
||
})
|
||
})()
|