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:
1 parent
e3ff0d3329
commit
4df2506a74
9 files changed
+83
-42
No files matched your search
+25
-10
@@ -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)
|
||||
|
||||
@@ -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
@@ -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')
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }[]
|
||||
|
||||
Reference in new issue
Block a user