fix: read pda authorization outside jwt

This commit is contained in:
2026-09-07 12:30:17 +08:00
parent 4f314fa3cf
commit 65962eb940
5 changed files with 57 additions and 25 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
## 服务与发布边界 ## 服务与发布边界
- 国内 Auth 是唯一账号和 Token 签发方;海外业务请求统一经 `gateway-overseas`。 - 国内 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 诊断。 - 并发海外 401 只允许一次国内 refresh;双 Token 完整校验后原子替换,每个原请求或照片上传最多重放一次。刷新失败清理本地会话,业务错误和网络错误保留 code、requestId、traceId 或 network 诊断。
- 国内与海外 AppID、Android 包名、签名、版本号、构建产物和在线更新通道必须独立。 - 国内与海外 AppID、Android 包名、签名、版本号、构建产物和在线更新通道必须独立。
- `src/manifest.json` 中的 AppID 在正式打包前由海外版所属 DCloud 账号创建;不得伪造或复用国内 AppID。 - `src/manifest.json` 中的 AppID 在正式打包前由海外版所属 DCloud 账号创建;不得伪造或复用国内 AppID。
+1 -1
View File
@@ -46,7 +46,7 @@ npm run build:app
- 出库作业已接 `/api/pda/v1/last-leg/outbound-tasks/**`:支持普通出库和一件代发任务列表/详情、托盘码/箱码/SKU/入仓码扫码拣货与核平完成;不会展示或进入派送流程。 - 出库作业已接 `/api/pda/v1/last-leg/outbound-tasks/**`:支持普通出库和一件代发任务列表/详情、托盘码/箱码/SKU/入仓码扫码拣货与核平完成;不会展示或进入派送流程。
- 排车与出库写操作使用可恢复的稳定幂等键。网络失败保留当前任务、扫码草稿和幂等键;成功后释放键并按服务端状态恢复,避免重复完成或发车。 - 排车与出库写操作使用可恢复的稳定幂等键。网络失败保留当前任务、扫码草稿和幂等键;成功后释放键并按服务端状态恢复,避免重复完成或发车。
- 入仓请求不手填客户、计划、分配、仓库或库位标识;身份与当前柜范围由 Token、Gateway 和服务端会话确定。页面同时支持 PDA 广播扫码与 H5 人工输入。 - 入仓请求不手填客户、计划、分配、仓库或库位标识;身份与当前柜范围由 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 人工输入,并按服务端版本、幂等键和冲突状态恢复,不调用平台、客户或内部接口。 - 库存盘点、商品移库、换标收货/执行/复核以及库存/库位/托盘查询均已接入 `/api/pda/**` 专用接口;支持 PDA 广播扫码与 H5 人工输入,并按服务端版本、幂等键和冲突状态恢复,不调用平台、客户或内部接口。
- 本地契约测试与 H5/App 资源编译不等于真实开发环境 HTTP 或 PDA 真机验收。 - 本地契约测试与 H5/App 资源编译不等于真实开发环境 HTTP 或 PDA 真机验收。
+32 -12
View File
@@ -8,7 +8,10 @@ const SESSION_KEY = 'overseas-pda-auth-session'
export const PDA_ROLE = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' export const PDA_ROLE = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR'
export const PDA_AUTHORITY = 'lastleg:pda:operate' 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) { function decodeBase64Url(value) {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/') 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 domestic = decodeAccessToken(accessToken)
const overseas = decodeAccessToken(overseasAccessToken) const overseas = decodeAccessToken(overseasAccessToken)
requireActiveClaims(domestic, '登录状态') requireActiveClaims(domestic, '登录状态')
@@ -63,9 +77,7 @@ export function validatePdaTokenPair(accessToken, overseasAccessToken) {
} }
const warehouseId = claimId(overseas, 'overseas_warehouse_id') const warehouseId = claimId(overseas, 'overseas_warehouse_id')
if (!/^\d+$/.test(warehouseId) || warehouseId === '0') throw new Error('海外PDA账号未绑定有效作业仓库') if (!/^\d+$/.test(warehouseId) || warehouseId === '0') throw new Error('海外PDA账号未绑定有效作业仓库')
if (!includesClaim(overseas.roles, PDA_ROLE)) throw new Error('当前账号不是海外仓 PDA 作业员') return { domestic, overseas, warehouseId, authorization: requireAuthorizationSnapshot(authorization) }
if (!includesClaim(overseas.authorities, PDA_AUTHORITY)) throw new Error(`当前账号缺少${PDA_AUTHORITY}权限`)
return { domestic, overseas, warehouseId }
} }
function userFromClaims(pair, saved = {}) { function userFromClaims(pair, saved = {}) {
@@ -75,7 +87,7 @@ function userFromClaims(pair, saved = {}) {
id: claimId(pair.domestic, 'user_id'), id: claimId(pair.domestic, 'user_id'),
name: pair.domestic.username || '仓库作业员', name: pair.domestic.username || '仓库作业员',
role: PDA_ROLE, role: PDA_ROLE,
roles: [...pair.overseas.roles], roles: [...pair.authorization.roles],
warehouse: sameWarehouse && saved.warehouseName ? saved.warehouseName : '', warehouse: sameWarehouse && saved.warehouseName ? saved.warehouseName : '',
warehouseId: pair.warehouseId, warehouseId: pair.warehouseId,
warehouseCode: sameWarehouse ? (saved.warehouseCode || '') : '', warehouseCode: sameWarehouse ? (saved.warehouseCode || '') : '',
@@ -95,7 +107,8 @@ function persistSession() {
domesticToken: session.domesticToken, domesticToken: session.domesticToken,
refreshToken: session.refreshToken, refreshToken: session.refreshToken,
tenantId: session.tenantId, 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.refreshToken = options.refreshToken || account.refreshToken || ''
session.tenantId = options.tenantId || account.tenantId || '' session.tenantId = options.tenantId || account.tenantId || ''
session.apiBase = options.apiBase || account.apiBase || OVERSEAS_API_ROOT session.apiBase = options.apiBase || account.apiBase || OVERSEAS_API_ROOT
session.authorization = {
roles: [...(options.authorization?.roles || [])],
authorities: [...(options.authorization?.authorities || [])]
}
persistSession() persistSession()
return session.currentUser return session.currentUser
} }
@@ -114,19 +131,20 @@ export function signIn(account, warehouse, options = {}) {
export function signInWithTokens(tokens) { export function signInWithTokens(tokens) {
if (!tokens?.accessToken) throw new Error('登录响应缺少 accessToken') if (!tokens?.accessToken) throw new Error('登录响应缺少 accessToken')
if (!tokens?.overseasAccessToken) throw new Error('登录响应缺少 overseasAccessToken') 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) const user = userFromClaims(pair)
return signIn(user, user.warehouse, { return signIn(user, user.warehouse, {
token: tokens.overseasAccessToken, token: tokens.overseasAccessToken,
domesticToken: tokens.accessToken, domesticToken: tokens.accessToken,
refreshToken: tokens.refreshToken || '', refreshToken: tokens.refreshToken || '',
tenantId: claimId(pair.domestic, 'tenant_id') tenantId: claimId(pair.domestic, 'tenant_id'),
authorization: pair.authorization
}) })
} }
export function replaceTokens(tokens) { export function replaceTokens(tokens) {
if (!tokens?.accessToken || !tokens?.overseasAccessToken) throw new Error('刷新响应缺少双区域访问令牌') 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 user = userFromClaims(pair, session.currentUser)
const nextRefreshToken = tokens.refreshToken || session.refreshToken const nextRefreshToken = tokens.refreshToken || session.refreshToken
// Validate the complete replacement before mutating reactive or persisted session state. // Validate the complete replacement before mutating reactive or persisted session state.
@@ -135,6 +153,7 @@ export function replaceTokens(tokens) {
session.refreshToken = nextRefreshToken session.refreshToken = nextRefreshToken
session.tenantId = claimId(pair.domestic, 'tenant_id') session.tenantId = claimId(pair.domestic, 'tenant_id')
session.currentUser = user session.currentUser = user
session.authorization = pair.authorization
persistSession() persistSession()
} }
@@ -160,10 +179,10 @@ export function restoreSession() {
return false return false
} }
try { try {
const pair = validatePdaTokenPair(saved.domesticToken, saved.token) const pair = validatePdaTokenPair(saved.domesticToken, saved.token, saved.authorization)
Object.assign(session, saved, { Object.assign(session, saved, {
currentUser: userFromClaims(pair, saved.currentUser), tenantId: claimId(pair.domestic, 'tenant_id'), currentUser: userFromClaims(pair, saved.currentUser), tenantId: claimId(pair.domestic, 'tenant_id'),
apiBase: OVERSEAS_API_ROOT apiBase: OVERSEAS_API_ROOT, authorization: pair.authorization
}) })
return true return true
} catch (_) { } catch (_) {
@@ -179,6 +198,7 @@ export function signOut() {
session.refreshToken = '' session.refreshToken = ''
session.tenantId = '' session.tenantId = ''
session.apiBase = OVERSEAS_API_ROOT session.apiBase = OVERSEAS_API_ROOT
session.authorization = { roles: [], authorities: [] }
if (typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(SESSION_KEY) if (typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(SESSION_KEY)
resetReceivingSession() resetReceivingSession()
resetFulfillmentSession() resetFulfillmentSession()
+3 -2
View File
@@ -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 } const base = { user_id: 42, membership_id: 84, tenant_id: 0, app_code: 'PLATFORM', overseas_warehouse_id: 900, exp: future(), version }
return { return {
accessToken: jwt({ ...base, username: 'picker-42' }), accessToken: jwt({ ...base, username: 'picker-42' }),
overseasAccessToken: jwt({ ...base, roles: [PDA_ROLE], authorities: [PDA_AUTHORITY] }), overseasAccessToken: jwt(base),
refreshToken: `refresh-${version}` refreshToken: `refresh-${version}`,
roles: [PDA_ROLE], authorities: [PDA_AUTHORITY]
} }
} }
+20 -9
View File
@@ -24,13 +24,12 @@ function tokenPair(overrides = {}) {
overseas_warehouse_id: 900, exp: future() overseas_warehouse_id: 900, exp: future()
} }
const domestic = { ...base, username: 'picker-42', ...(overrides.domestic || {}) } const domestic = { ...base, username: 'picker-42', ...(overrides.domestic || {}) }
const overseas = { const overseas = { ...base, ...(overrides.overseas || {}) }
...base, roles: [PDA_ROLE], authorities: [PDA_AUTHORITY],
...(overrides.overseas || {})
}
return { return {
accessToken: jwt(domestic), overseasAccessToken: jwt(overseas), 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', () => { test('valid platform warehouse picker tokens freeze the same operator and warehouse', () => {
storage(); signOut() storage(); signOut()
const tokens = tokenPair() const tokens = tokenPair()
const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken) const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken, tokens)
assert.equal(pair.warehouseId, '900') assert.equal(pair.warehouseId, '900')
const user = signInWithTokens(tokens) const user = signInWithTokens(tokens)
assert.deepEqual(user.roles, [PDA_ROLE]) 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(user.warehouse, '')
assert.equal(session.tenantId, '0') assert.equal(session.tenantId, '0')
assert.equal(session.token, tokens.overseasAccessToken) 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', () => { 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', () => { test('login fails closed for wrong role, authority, warehouse, application, or token subject', () => {
storage(); signOut() storage(); signOut()
const invalidPairs = [ const invalidPairs = [
tokenPair({ overseas: { roles: ['PLATFORM_ADMIN'] } }), tokenPair({ roles: ['PLATFORM_ADMIN'] }),
tokenPair({ overseas: { authorities: [] } }), tokenPair({ authorities: [] }),
tokenPair({ roles: null }),
tokenPair({ authorities: null }),
tokenPair({ overseas: { overseas_warehouse_id: null } }), tokenPair({ overseas: { overseas_warehouse_id: null } }),
tokenPair({ domestic: { app_code: 'MERCHANT', tenant_id: 100 } }), tokenPair({ domestic: { app_code: 'MERCHANT', tenant_id: 100 } }),
tokenPair({ overseas: { user_id: 99 } }), tokenPair({ overseas: { user_id: 99 } }),
@@ -94,7 +98,7 @@ test('refresh replacement validates both tokens before atomically changing sessi
const original = tokenPair() const original = tokenPair()
signInWithTokens(original) signInWithTokens(original)
const before = { domestic: session.domesticToken, overseas: session.token, refresh: session.refreshToken } 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.throws(() => replaceTokens(invalid), /lastleg:pda:operate/)
assert.deepEqual({ domestic: session.domesticToken, overseas: session.token, refresh: session.refreshToken }, before) 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(restoreSession(), true)
assert.equal(session.currentUser.warehouseId, '900') 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 } const tampered = { ...saved, token: tokenPair({ overseas: { overseas_warehouse_id: 901 } }).overseasAccessToken }
signOut(); values.set('overseas-pda-auth-session', tampered) signOut(); values.set('overseas-pda-auth-session', tampered)
assert.equal(restoreSession(), false) 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.token, '')
assert.equal(session.domesticToken, '') assert.equal(session.domesticToken, '')
assert.equal(session.refreshToken, '') assert.equal(session.refreshToken, '')
assert.deepEqual(session.authorization, { roles: [], authorities: [] })
assert.equal(values.has('overseas-pda-auth-session'), false) assert.equal(values.has('overseas-pda-auth-session'), false)
assert.deepEqual(receiving.candidates, []) assert.deepEqual(receiving.candidates, [])
assert.equal(receiving.current, null) assert.equal(receiving.current, null)