This commit is contained in:
2026-09-20 22:19:02 +08:00
parent 1e171ec81c
commit 4ed1faea5f
3 changed files with 71 additions and 20 deletions
+16 -19
View File
@@ -1,30 +1,15 @@
import { reactive } from 'vue'
import { resetPickingSession } from '@/stores/picking.js'
import { PLATFORM_API_ROOT } from '@/config/runtime.js'
import { decodeAccessToken } from '@/utils/jwt-claims.js'
export { decodeAccessToken } from '@/utils/jwt-claims.js'
const SESSION_KEY = 'pda-auth-session'
const LOGIN_PREFERENCE_KEY = 'pda-login-account-preference'
export const session = reactive({ currentUser: null, token: '', refreshToken: '', tenantId: '', apiBase: PLATFORM_API_ROOT })
function decodeBase64Url(value) {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
if (typeof atob === 'function') return decodeURIComponent(Array.from(atob(padded), (char) => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`).join(''))
throw new Error('当前运行环境无法解析登录令牌')
}
export function decodeAccessToken(token) {
const payload = String(token || '').split('.')[1]
if (!payload) throw new Error('登录响应中的 accessToken 格式无效')
try {
return JSON.parse(decodeBase64Url(payload))
} catch (error) {
if (error.message === '当前运行环境无法解析登录令牌') throw error
throw new Error('登录响应中的 accessToken 无法解析')
}
}
export function isAccessTokenExpiring(token = session.token, skewSeconds = 30) {
try {
const expiresAt = Number(decodeAccessToken(token).exp || 0) * 1000
@@ -137,7 +122,19 @@ export function restoreSession() {
const claims = decodeAccessToken(saved.token)
if (claims.app_code !== 'PLATFORM') throw new Error('登录域无效')
if (claims.exp && claims.exp * 1000 <= Date.now() && !saved.refreshToken) throw new Error('登录状态已过期')
Object.assign(session, saved, { apiBase: saved.apiBase || PLATFORM_API_ROOT })
const savedUser = saved.currentUser || {}
Object.assign(session, saved, {
currentUser: {
...savedUser,
id: String(claims.user_id ?? savedUser.id ?? ''),
name: claims.username || savedUser.name || '仓库作业员',
role: Array.isArray(claims.roles) ? claims.roles.join('、') : (savedUser.role || ''),
roles: claims.roles || savedUser.roles || []
},
tenantId: claims.tenant_id == null ? (saved.tenantId || '') : String(claims.tenant_id),
apiBase: saved.apiBase || PLATFORM_API_ROOT
})
persistSession()
return true
} catch (_) {
signOut()
+17 -1
View File
@@ -26,10 +26,26 @@ test('refresh rotation persists the complete token pair and preserves operator c
})
test('app launch restores and validates persisted tokens before entering the workbench', async () => {
const [login, http] = await Promise.all([read('../pages/login/login.vue'), read('../api/http.js')])
const [login, http, session] = await Promise.all([read('../pages/login/login.vue'), read('../api/http.js'), read('../stores/session.js')])
assert.match(login, /async onLoad\(\)[\s\S]*?restoreSession\(\)[\s\S]*?await ensureAccessToken\(\)[\s\S]*?pages\/home\/home/)
assert.match(http, /export function ensureAccessToken\(\)/)
assert.match(login, /暂时无法恢复登录状态/)
assert.match(session, /id: String\(claims\.user_id \?\? savedUser\.id \?\? ''\)/)
assert.match(session, /persistSession\(\)[\s\S]*?return true/)
})
test('JWT decoding preserves 19 digit identity claims exactly', async () => {
const { decodeAccessToken } = await import('../utils/jwt-claims.js')
const payload = Buffer.from(JSON.stringify({
app_code: 'PLATFORM',
user_id: 1,
tenant_id: '2101483345056575489',
exp: 4102444800
}).replace('"user_id":1', '"user_id":2100885001485500418')).toString('base64url')
const claims = decodeAccessToken(`header.${payload}.signature`)
assert.equal(claims.user_id, '2100885001485500418')
assert.equal(claims.tenant_id, '2101483345056575489')
assert.equal(claims.exp, 4102444800)
})
test('confirmed session expiry clears sensitive state and redirects only once', async () => {
+38
View File
@@ -0,0 +1,38 @@
const EXACT_INTEGER_CLAIMS = [
'user_id',
'userId',
'tenant_id',
'tenantId',
'operator_tenant_id',
'membership_id',
'overseas_warehouse_id',
'actor_user_id',
'actor_membership_id'
]
function decodeBase64Url(value) {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
if (typeof atob === 'function') return decodeURIComponent(Array.from(atob(padded), (char) => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`).join(''))
throw new Error('当前运行环境无法解析登录令牌')
}
function restoreExactIntegerClaims(payload, claims) {
for (const key of EXACT_INTEGER_CLAIMS) {
const match = payload.match(new RegExp(`"${key}"\\s*:\\s*(-?\\d+)`))
if (match) claims[key] = match[1]
}
return claims
}
export function decodeAccessToken(token) {
const payloadPart = String(token || '').split('.')[1]
if (!payloadPart) throw new Error('登录响应中的 accessToken 格式无效')
try {
const payload = decodeBase64Url(payloadPart)
return restoreExactIntegerClaims(payload, JSON.parse(payload))
} catch (error) {
if (error.message === '当前运行环境无法解析登录令牌') throw error
throw new Error('登录响应中的 accessToken 无法解析')
}
}