From 65962eb940598d10781af45f29fb74a62a06c3b9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 7 Sep 2026 12:30:17 +0800 Subject: [PATCH] fix: read pda authorization outside jwt --- AGENTS.md | 2 +- README.md | 2 +- src/stores/session.js | 44 ++++++++++++++++++++++++--------- tests/overseas-request.test.mjs | 5 ++-- tests/session-auth.test.mjs | 29 +++++++++++++++------- 5 files changed, 57 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aaf8d4a..b4dbc53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ ## 服务与发布边界 - 国内 Auth 是唯一账号和 Token 签发方;海外业务请求统一经 `gateway-overseas`。 -- 登录、刷新和本地会话必须同时校验国内/海外 Token 的平台主体、成员、冻结海外仓一致;海外 Token 还必须精确包含 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 与 `lastleg:pda:operate`。无效、过期、跨主体或撤权响应必须 fail-closed 清理会话,不得只依赖页面入口隐藏。 +- 登录、刷新和本地会话必须同时校验国内/海外 Token 的平台主体、成员、冻结海外仓一致;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 与 `lastleg:pda:operate` 只从 Auth 登录/刷新响应的顶层权限快照读取并持久化,不得解析 JWT 权限。权限快照缺失、无效、过期、跨主体或撤权响应必须 fail-closed 清理会话,不得只依赖页面入口隐藏。 - 并发海外 401 只允许一次国内 refresh;双 Token 完整校验后原子替换,每个原请求或照片上传最多重放一次。刷新失败清理本地会话,业务错误和网络错误保留 code、requestId、traceId 或 network 诊断。 - 国内与海外 AppID、Android 包名、签名、版本号、构建产物和在线更新通道必须独立。 - `src/manifest.json` 中的 AppID 在正式打包前由海外版所属 DCloud 账号创建;不得伪造或复用国内 AppID。 diff --git a/README.md b/README.md index 6ee0a84..af2ccbd 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ npm run build:app - 出库作业已接 `/api/pda/v1/last-leg/outbound-tasks/**`:支持普通出库和一件代发任务列表/详情、托盘码/箱码/SKU/入仓码扫码拣货与核平完成;不会展示或进入派送流程。 - 排车与出库写操作使用可恢复的稳定幂等键。网络失败保留当前任务、扫码草稿和幂等键;成功后释放键并按服务端状态恢复,避免重复完成或发车。 - 入仓请求不手填客户、计划、分配、仓库或库位标识;身份与当前柜范围由 Token、Gateway 和服务端会话确定。页面同时支持 PDA 广播扫码与 H5 人工输入。 -- 登录、恢复和刷新会 fail-closed 校验国内/海外双 Token 的同一平台操作主体、`OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate` 及同一冻结海外仓;并发海外 401 共享一次国内 refresh,校验完成后原子替换双 Token并将原请求或照片上传重放一次。 +- 登录、恢复和刷新会 fail-closed 校验国内/海外双 Token 的同一平台操作主体和同一冻结海外仓,并从 Auth 登录/刷新响应的顶层权限快照校验 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate`,不从 JWT 解析权限;并发海外 401 共享一次国内 refresh,校验完成后原子替换双 Token和权限快照,并将原请求或照片上传重放一次。 - 库存盘点、商品移库、换标收货/执行/复核以及库存/库位/托盘查询均已接入 `/api/pda/**` 专用接口;支持 PDA 广播扫码与 H5 人工输入,并按服务端版本、幂等键和冲突状态恢复,不调用平台、客户或内部接口。 - 本地契约测试与 H5/App 资源编译不等于真实开发环境 HTTP 或 PDA 真机验收。 diff --git a/src/stores/session.js b/src/stores/session.js index c23a909..b82e2d0 100644 --- a/src/stores/session.js +++ b/src/stores/session.js @@ -8,7 +8,10 @@ const SESSION_KEY = 'overseas-pda-auth-session' export const PDA_ROLE = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' export const PDA_AUTHORITY = 'lastleg:pda:operate' -export const session = reactive({ currentUser: null, token: '', domesticToken: '', refreshToken: '', tenantId: '', apiBase: OVERSEAS_API_ROOT }) +export const session = reactive({ + currentUser: null, token: '', domesticToken: '', refreshToken: '', tenantId: '', apiBase: OVERSEAS_API_ROOT, + authorization: { roles: [], authorities: [] } +}) function decodeBase64Url(value) { const normalized = value.replace(/-/g, '+').replace(/_/g, '/') @@ -53,7 +56,18 @@ function requireActiveClaims(claims, label) { } } -export function validatePdaTokenPair(accessToken, overseasAccessToken) { +function requireAuthorizationSnapshot(authorization) { + if (!Array.isArray(authorization?.roles) || !Array.isArray(authorization?.authorities)) { + throw new Error('登录响应缺少完整权限快照') + } + const roles = authorization.roles.map(String) + const authorities = authorization.authorities.map(String) + if (!includesClaim(roles, PDA_ROLE)) throw new Error('当前账号不是海外仓 PDA 作业员') + if (!includesClaim(authorities, PDA_AUTHORITY)) throw new Error(`当前账号缺少${PDA_AUTHORITY}权限`) + return { roles, authorities } +} + +export function validatePdaTokenPair(accessToken, overseasAccessToken, authorization) { const domestic = decodeAccessToken(accessToken) const overseas = decodeAccessToken(overseasAccessToken) requireActiveClaims(domestic, '登录状态') @@ -63,9 +77,7 @@ export function validatePdaTokenPair(accessToken, overseasAccessToken) { } const warehouseId = claimId(overseas, 'overseas_warehouse_id') if (!/^\d+$/.test(warehouseId) || warehouseId === '0') throw new Error('海外PDA账号未绑定有效作业仓库') - if (!includesClaim(overseas.roles, PDA_ROLE)) throw new Error('当前账号不是海外仓 PDA 作业员') - if (!includesClaim(overseas.authorities, PDA_AUTHORITY)) throw new Error(`当前账号缺少${PDA_AUTHORITY}权限`) - return { domestic, overseas, warehouseId } + return { domestic, overseas, warehouseId, authorization: requireAuthorizationSnapshot(authorization) } } function userFromClaims(pair, saved = {}) { @@ -75,7 +87,7 @@ function userFromClaims(pair, saved = {}) { id: claimId(pair.domestic, 'user_id'), name: pair.domestic.username || '仓库作业员', role: PDA_ROLE, - roles: [...pair.overseas.roles], + roles: [...pair.authorization.roles], warehouse: sameWarehouse && saved.warehouseName ? saved.warehouseName : '', warehouseId: pair.warehouseId, warehouseCode: sameWarehouse ? (saved.warehouseCode || '') : '', @@ -95,7 +107,8 @@ function persistSession() { domesticToken: session.domesticToken, refreshToken: session.refreshToken, tenantId: session.tenantId, - apiBase: session.apiBase + apiBase: session.apiBase, + authorization: session.authorization }) } @@ -107,6 +120,10 @@ export function signIn(account, warehouse, options = {}) { session.refreshToken = options.refreshToken || account.refreshToken || '' session.tenantId = options.tenantId || account.tenantId || '' session.apiBase = options.apiBase || account.apiBase || OVERSEAS_API_ROOT + session.authorization = { + roles: [...(options.authorization?.roles || [])], + authorities: [...(options.authorization?.authorities || [])] + } persistSession() return session.currentUser } @@ -114,19 +131,20 @@ export function signIn(account, warehouse, options = {}) { export function signInWithTokens(tokens) { if (!tokens?.accessToken) throw new Error('登录响应缺少 accessToken') if (!tokens?.overseasAccessToken) throw new Error('登录响应缺少 overseasAccessToken') - const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken) + const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken, tokens) const user = userFromClaims(pair) return signIn(user, user.warehouse, { token: tokens.overseasAccessToken, domesticToken: tokens.accessToken, refreshToken: tokens.refreshToken || '', - tenantId: claimId(pair.domestic, 'tenant_id') + tenantId: claimId(pair.domestic, 'tenant_id'), + authorization: pair.authorization }) } export function replaceTokens(tokens) { if (!tokens?.accessToken || !tokens?.overseasAccessToken) throw new Error('刷新响应缺少双区域访问令牌') - const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken) + const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken, tokens) const user = userFromClaims(pair, session.currentUser) const nextRefreshToken = tokens.refreshToken || session.refreshToken // Validate the complete replacement before mutating reactive or persisted session state. @@ -135,6 +153,7 @@ export function replaceTokens(tokens) { session.refreshToken = nextRefreshToken session.tenantId = claimId(pair.domestic, 'tenant_id') session.currentUser = user + session.authorization = pair.authorization persistSession() } @@ -160,10 +179,10 @@ export function restoreSession() { return false } try { - const pair = validatePdaTokenPair(saved.domesticToken, saved.token) + const pair = validatePdaTokenPair(saved.domesticToken, saved.token, saved.authorization) Object.assign(session, saved, { currentUser: userFromClaims(pair, saved.currentUser), tenantId: claimId(pair.domestic, 'tenant_id'), - apiBase: OVERSEAS_API_ROOT + apiBase: OVERSEAS_API_ROOT, authorization: pair.authorization }) return true } catch (_) { @@ -179,6 +198,7 @@ export function signOut() { session.refreshToken = '' session.tenantId = '' session.apiBase = OVERSEAS_API_ROOT + session.authorization = { roles: [], authorities: [] } if (typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(SESSION_KEY) resetReceivingSession() resetFulfillmentSession() diff --git a/tests/overseas-request.test.mjs b/tests/overseas-request.test.mjs index aea38d3..c496bc6 100644 --- a/tests/overseas-request.test.mjs +++ b/tests/overseas-request.test.mjs @@ -15,8 +15,9 @@ function tokens(version = 'old') { const base = { user_id: 42, membership_id: 84, tenant_id: 0, app_code: 'PLATFORM', overseas_warehouse_id: 900, exp: future(), version } return { accessToken: jwt({ ...base, username: 'picker-42' }), - overseasAccessToken: jwt({ ...base, roles: [PDA_ROLE], authorities: [PDA_AUTHORITY] }), - refreshToken: `refresh-${version}` + overseasAccessToken: jwt(base), + refreshToken: `refresh-${version}`, + roles: [PDA_ROLE], authorities: [PDA_AUTHORITY] } } diff --git a/tests/session-auth.test.mjs b/tests/session-auth.test.mjs index 257a35d..07431c6 100644 --- a/tests/session-auth.test.mjs +++ b/tests/session-auth.test.mjs @@ -24,13 +24,12 @@ function tokenPair(overrides = {}) { overseas_warehouse_id: 900, exp: future() } const domestic = { ...base, username: 'picker-42', ...(overrides.domestic || {}) } - const overseas = { - ...base, roles: [PDA_ROLE], authorities: [PDA_AUTHORITY], - ...(overrides.overseas || {}) - } + const overseas = { ...base, ...(overrides.overseas || {}) } return { accessToken: jwt(domestic), overseasAccessToken: jwt(overseas), - refreshToken: overrides.refreshToken ?? 'refresh-42' + refreshToken: overrides.refreshToken ?? 'refresh-42', + roles: Object.hasOwn(overrides, 'roles') ? overrides.roles : [PDA_ROLE], + authorities: Object.hasOwn(overrides, 'authorities') ? overrides.authorities : [PDA_AUTHORITY] } } @@ -47,7 +46,7 @@ function storage() { test('valid platform warehouse picker tokens freeze the same operator and warehouse', () => { storage(); signOut() const tokens = tokenPair() - const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken) + const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken, tokens) assert.equal(pair.warehouseId, '900') const user = signInWithTokens(tokens) assert.deepEqual(user.roles, [PDA_ROLE]) @@ -55,6 +54,9 @@ test('valid platform warehouse picker tokens freeze the same operator and wareho assert.equal(user.warehouse, '') assert.equal(session.tenantId, '0') assert.equal(session.token, tokens.overseasAccessToken) + assert.deepEqual(session.authorization, { roles: [PDA_ROLE], authorities: [PDA_AUTHORITY] }) + assert.equal(decodeAccessToken(tokens.overseasAccessToken).roles, undefined) + assert.equal(decodeAccessToken(tokens.overseasAccessToken).authorities, undefined) }) test('JWT decoding preserves unquoted snowflake subjects and warehouse IDs exactly', () => { @@ -75,8 +77,10 @@ test('warehouse master profile replaces the technical ID only for the token-boun test('login fails closed for wrong role, authority, warehouse, application, or token subject', () => { storage(); signOut() const invalidPairs = [ - tokenPair({ overseas: { roles: ['PLATFORM_ADMIN'] } }), - tokenPair({ overseas: { authorities: [] } }), + tokenPair({ roles: ['PLATFORM_ADMIN'] }), + tokenPair({ authorities: [] }), + tokenPair({ roles: null }), + tokenPair({ authorities: null }), tokenPair({ overseas: { overseas_warehouse_id: null } }), tokenPair({ domestic: { app_code: 'MERCHANT', tenant_id: 100 } }), tokenPair({ overseas: { user_id: 99 } }), @@ -94,7 +98,7 @@ test('refresh replacement validates both tokens before atomically changing sessi const original = tokenPair() signInWithTokens(original) const before = { domestic: session.domesticToken, overseas: session.token, refresh: session.refreshToken } - const invalid = tokenPair({ overseas: { authorities: [] }, refreshToken: 'invalid-refresh' }) + const invalid = tokenPair({ authorities: [], refreshToken: 'invalid-refresh' }) assert.throws(() => replaceTokens(invalid), /lastleg:pda:operate/) assert.deepEqual({ domestic: session.domesticToken, overseas: session.token, refresh: session.refreshToken }, before) @@ -113,6 +117,12 @@ test('restore rebuilds identity from claims and removes expired or tampered stor assert.equal(restoreSession(), true) assert.equal(session.currentUser.warehouseId, '900') + const missingAuthorization = { ...saved } + delete missingAuthorization.authorization + signOut(); values.set('overseas-pda-auth-session', missingAuthorization) + assert.equal(restoreSession(), false) + assert.equal(values.has('overseas-pda-auth-session'), false) + const tampered = { ...saved, token: tokenPair({ overseas: { overseas_warehouse_id: 901 } }).overseasAccessToken } signOut(); values.set('overseas-pda-auth-session', tampered) assert.equal(restoreSession(), false) @@ -144,6 +154,7 @@ test('sign out clears tokens, frozen warehouse identity, and every receiving or assert.equal(session.token, '') assert.equal(session.domesticToken, '') assert.equal(session.refreshToken, '') + assert.deepEqual(session.authorization, { roles: [], authorities: [] }) assert.equal(values.has('overseas-pda-auth-session'), false) assert.deepEqual(receiving.candidates, []) assert.equal(receiving.current, null)