fix(web): access token 移出 localStorage;401 刷新 single-flight 并跳登录页

- access token 只存内存:XSS 拿不到持久凭据;刷新页面由 bootstrap 用 HttpOnly refresh cookie 静默换新;bootstrap 并发去重
- 并发 401 共享一次刷新(refresh 已是轮换式,各自刷新会互相踩);会话失效自动回登录页
- 防主题闪烁内联脚本挪到外部 theme-init.js(配合 CSP script-src 'self')
- 修 console 用量页与管理用户搜索的查询参数未编码;极小金额不再显示科学计数法;渠道表单数字输入清空后省略字段而非提交 0(权重 0 的渠道永远选不中)
- 改密成功后主动清会话回登录页(后端已递增 token_version,当前会话已作废)
This commit is contained in:
Sakurasan committed 2026-10-29 02:56:52 +08:00
1 parent e3ff0d3329
commit 4df2506a74
9 files changed
+83 -42

No files matched your search

+2 -12
View File
@@ -4,18 +4,8 @@
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script>
// 防止主题闪烁:应用前先按 localStorage/系统偏好设置 data-theme
;(function () {
try {
var m = localStorage.getItem('ot_theme') || 'system'
var dark = m === 'dark' || (m === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
} catch (e) {
document.documentElement.setAttribute('data-theme', 'dark')
}
})()
</script>
<!-- 防主题闪烁;外部文件是为了过 CSP script-src 'self' -->
<script src="/theme-init.js"></script>
<title>openteam · LLM API 中转站</title>
</head>
<body>
+11
View File
@@ -0,0 +1,11 @@
// 防止主题闪烁:CSS 加载前按 localStorage/系统偏好设置 data-theme。
// 独立外部文件而非内联脚本:CSP script-src 'self' 下内联会被拦掉。
;(function () {
try {
var m = localStorage.getItem('ot_theme') || 'system'
var dark = m === 'dark' || (m === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
} catch (e) {
document.documentElement.setAttribute('data-theme', 'dark')
}
})()
+25 -10
View File
@@ -15,22 +15,37 @@ http.interceptors.request.use((config) => {
return config
})
// single-flight:并发 401 共享同一次刷新。refresh 已是轮换式(每次换发新 cookie),
// 两个请求各自刷新会让第二个带着已被轮换的会话状态,容易随机登出。
let refreshInflight: Promise<unknown> | null = null
async function redirectToLogin() {
if (window.location.pathname.startsWith('/login')) return
const { router } = await import('@/router')
await router.push({ name: 'login', query: { redirect: window.location.pathname + window.location.search } })
}
http.interceptors.response.use(
(resp) => resp,
async (error) => {
if (error.response?.status === 401) {
const status = error.response?.status
const cfg = error.config ?? {}
const isRefreshCall = String(cfg.url ?? '').includes('/auth/refresh')
if (status === 401 && !cfg._retried && !isRefreshCall) {
const auth = useAuthStore()
// 尝试用 refresh cookie 换新 token 后再试一次
if (auth.accessToken && !error.config?._retried) {
error.config._retried = true
try {
await auth.refresh()
return http.request(error.config)
} catch {
auth.clear()
cfg._retried = true
try {
if (!refreshInflight) {
refreshInflight = auth.refresh().finally(() => {
refreshInflight = null
})
}
} else {
await refreshInflight
return http.request(cfg)
} catch {
// 会话已失效:清状态并回登录页,否则用户停在受保护页面每个操作都报错
auth.clear()
await redirectToLogin()
}
}
return Promise.reject(error)
+3 -1
View File
@@ -15,6 +15,8 @@ export function fmtTime(s?: string | null): string {
export function fmtCost(v: number): string {
if (v === 0) return '$0'
if (v < 0.01) return '$' + v.toExponential(2)
// toLocaleString 对 <0.01 会出科学计数法($5e-3):定点 6 位并去尾零
if (v < 1e-6) return '<$0.000001'
if (v < 0.01) return '$' + v.toFixed(6).replace(/0+$/, '').replace(/\.$/, '')
return fmtMoney(v)
}
+22 -12
View File
@@ -2,10 +2,16 @@ import { defineStore } from 'pinia'
import { http } from '@/api/client'
import type { User } from '@/types'
// App.vue 的 onMounted 与路由守卫都可能触发 bootstrap:并发时共享同一次刷新,
// 避免重复打 /auth/refresh
let bootstrapInflight: Promise<void> | null = null
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null as User | null,
accessToken: localStorage.getItem('ot_access') ?? '',
// access token 只存内存:XSS 即使发生也拿不到持久凭据。
// 刷新页面后由 bootstrap 用 HttpOnly refresh cookie 静默换新 token。
accessToken: '',
ready: false,
}),
getters: {
@@ -14,15 +20,22 @@ export const useAuthStore = defineStore('auth', {
},
actions: {
async bootstrap() {
try {
if (!this.accessToken) {
await this.refresh()
}
await this.fetchMe()
} catch {
this.clear()
if (this.ready) return
if (!bootstrapInflight) {
bootstrapInflight = (async () => {
try {
await this.refresh()
await this.fetchMe()
} catch {
this.clear()
} finally {
this.ready = true
}
})().finally(() => {
bootstrapInflight = null
})
}
this.ready = true
await bootstrapInflight
},
async register(username: string, email: string, password: string) {
await http.post('/auth/register', { username, email, password })
@@ -34,12 +47,10 @@ export const useAuthStore = defineStore('auth', {
setSession(d: { access_token: string; user: User }) {
this.accessToken = d.access_token
this.user = d.user
localStorage.setItem('ot_access', d.access_token)
},
async refresh() {
const { data } = await http.post('/auth/refresh')
this.accessToken = data.data.access_token as string
localStorage.setItem('ot_access', this.accessToken)
},
async fetchMe() {
const { data } = await http.get('/auth/me')
@@ -56,7 +67,6 @@ export const useAuthStore = defineStore('auth', {
clear() {
this.accessToken = ''
this.user = null
localStorage.removeItem('ot_access')
},
},
})
+11 -4
View File
@@ -122,12 +122,19 @@ async function save() {
return
}
saving.value = true
// 数字输入被清空时 v-model 是 ''(Number('')==0):直接提交会把权重写成 0(永远选不中)。
// 省略该字段:创建走后端默认值,更新端点是指针字段不动原值。
const numOrOmit = (v: unknown) => {
if (v === '' || v === null || v === undefined) return undefined
const n = Number(v)
return Number.isNaN(n) ? undefined : n
}
const payload = {
...form,
weight: Number(form.weight),
priority: Number(form.priority),
timeout_ms: Number(form.timeout_ms),
max_concurrency: Number(form.max_concurrency),
weight: numOrOmit(form.weight),
priority: numOrOmit(form.priority),
timeout_ms: numOrOmit(form.timeout_ms),
max_concurrency: numOrOmit(form.max_concurrency),
}
try {
if (editing.value) {
+1 -1
View File
@@ -42,7 +42,7 @@ const balanceForm = reactive({ amount: 0, remark: '' })
async function load() {
try {
const { data } = await http.get(`/admin/users?page=${page.value}&page_size=${pageSize}${q.value ? '&q=' + q.value : ''}`)
const { data } = await http.get(`/admin/users?page=${page.value}&page_size=${pageSize}${q.value ? '&q=' + encodeURIComponent(q.value) : ''}`)
users.value = data.data.items
total.value = data.data.total
} catch (e) {
+7 -1
View File
@@ -95,8 +95,14 @@ async function changePassword() {
saving.value = true
try {
await http.post('/auth/password', { old_password: oldPwd.value, new_password: newPwd.value })
toast.ok('密码已更新')
toast.ok('密码已更新,请重新登录')
oldPwd.value = newPwd.value = confirmPwd.value = ''
// 后端改密即递增会话版本:当前 access/refresh 已全部作废,主动回登录页
const { useAuthStore } = await import('@/stores/auth')
const auth = useAuthStore()
auth.clear()
const { router } = await import('@/router')
await router.push({ name: 'login' })
} catch (e) {
toast.err(errMsg(e))
} finally {
+1 -1
View File
@@ -23,7 +23,7 @@ async function load() {
const [s, st, l] = await Promise.all([
http.get('/usage/summary'),
http.get(`/usage/stats?group=${group.value}`),
http.get(`/usage/logs?page=${page.value}&page_size=${pageSize}${modelFilter.value ? '&model=' + modelFilter.value : ''}`),
http.get(`/usage/logs?page=${page.value}&page_size=${pageSize}${modelFilter.value ? '&model=' + encodeURIComponent(modelFilter.value) : ''}`),
])
summary.value = s.data.data
const items = st.data.data.items as { date?: string; model?: string; cost: number; requests: number }[]