diff --git a/stores/session.js b/stores/session.js index a5fa224..89ffc90 100644 --- a/stores/session.js +++ b/stores/session.js @@ -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() diff --git a/tests/auth-session.test.mjs b/tests/auth-session.test.mjs index e6a1108..0cf13d4 100644 --- a/tests/auth-session.test.mjs +++ b/tests/auth-session.test.mjs @@ -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 () => { diff --git a/utils/jwt-claims.js b/utils/jwt-claims.js new file mode 100644 index 0000000..b4e3410 --- /dev/null +++ b/utils/jwt-claims.js @@ -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 无法解析') + } +}