fix: read pda authorization outside jwt
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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 真机验收。
|
||||
|
||||
|
||||
+32
-12
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user