fix(iam): align cross-app proofs and add native IAM target launch

This commit is contained in:
2026-10-29 18:49:32 +08:00
parent dcb0db8275
commit 7cbc679287
25 changed files with 837 additions and 99 deletions
+13 -5
View File
@@ -16,11 +16,18 @@
<p class="my-3 text-gray-500">
关联只确认同一人的身份,不会复制业务角色或自动授予其他平台权限。
</p>
<el-alert
v-if="!linked && status.protected_account"
class="mb-4"
type="warning"
:closable="false"
title="此账号属于敏感账号,请由本地管理员在 IAM 关联管理发起单人中央复核,不使用个人自助绑定。"
/>
<div class="flex flex-wrap gap-3">
<el-button @click="reauthVisible = true">验证当前身份</el-button>
<el-button @click="wecomRelogin">企业微信重新登录</el-button>
<el-button
v-if="!linked"
v-if="!linked && !status.protected_account"
type="primary"
:loading="busy"
@click="navigate('/self-link/start')"
@@ -54,9 +61,7 @@
仅验证当前账号,验证有效期 5 分钟。密码不会被保存或传给 IAM。
</p>
<template #footer
><el-button
@click="cancelReauth"
>取消</el-button
><el-button @click="cancelReauth">取消</el-button
><el-button type="primary" :loading="busy" @click="reauth"
>验证</el-button
></template
@@ -82,7 +87,10 @@ async function refresh() {
error.value = identityError(e)
}
}
function cancelReauth() { reauthVisible.value = false; password.value = '' }
function cancelReauth() {
reauthVisible.value = false
password.value = ''
}
async function reauth() {
busy.value = true
try {
+1 -1
View File
@@ -81,7 +81,7 @@ const defaultPath = PageEnum.INDEX
const changePasswordPath = '/change-password'
const bindWorkWechatPath = '/bind-work-wechat'
// 免登录白名单
const whiteList: string[] = [PageEnum.LOGIN, PageEnum.ERROR_403]
const whiteList: string[] = [PageEnum.LOGIN, PageEnum.ERROR_403, '/iam/launch']
router.beforeEach(async (to, from, next) => {
document.title = to.meta.title ?? config.title
const userStore = useUserStore()
+5
View File
@@ -23,6 +23,11 @@ export const LAYOUT = () => Promise.resolve(Layout)
export const INDEX_ROUTE_NAME = Symbol()
export const constantRoutes: Array<RouteRecordRaw> = [
{
path: '/iam/launch',
component: () => import('@/views/account/iam-launch.vue'),
meta: { title: '统一身份进入甄养堂', hideTab: true }
},
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/error/404.vue')
+9
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import type { RouteRecordRaw } from 'vue-router'
import { getUserInfo, iamLogin, login, logout, workWechatLogin } from '@/api/user'
import { iamPost } from '@/api/iam-v2'
import { TOKEN_KEY } from '@/enums/cacheEnums'
import { PageEnum } from '@/enums/pageEnum'
import router, { filterAsyncRoutes } from '@/router'
@@ -83,6 +84,14 @@ const useUserStore = defineStore({
})
})
},
async iamLaunchLogin(ticket: string) {
const data = await iamPost('/launch/exchange', { ticket })
this.resetState()
this.token = data.token
this.isPaw = data.is_paw ?? 1
cache.set(TOKEN_KEY, data.token)
return data
},
async iamLogin(ticket: string) {
const data = await iamLogin(ticket)
this.token = data.token
+93
View File
@@ -0,0 +1,93 @@
<template>
<main class="min-h-screen flex items-center justify-center bg-page p-6">
<el-card class="w-full max-w-xl" shadow="never">
<h1 class="text-xl font-semibold mb-4">统一身份进入甄养堂</h1>
<p v-if="busy" class="text-gray-500">正在核验同一身份,请稍候…</p>
<el-alert v-if="error" :title="error" type="warning" :closable="false" show-icon />
<p class="text-gray-500 my-4">
已有 ZYT 账号将进行一致性检查。系统不会自动替换当前已登录的人。
</p>
<div v-if="error" class="flex flex-wrap gap-3">
<el-button v-if="restartUrl" type="primary" @click="back"
>返回 IAM 重新发起</el-button
>
<el-button v-if="restartUrl" :loading="busy" @click="switchAccount"
>退出当前 ZYT 身份并返回 IAM</el-button
>
<router-link to="/login"><el-button>使用原登录方式</el-button></router-link>
</div>
</el-card>
</main>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { iamGet, iamPost, identityError } from '@/api/iam-v2'
import useUserStore from '@/stores/modules/user'
import { clearAuthInfo } from '@/utils/auth'
import feedback from '@/utils/feedback'
const route = useRoute(),
router = useRouter(),
store = useUserStore()
const busy = ref(true),
error = ref(''),
restartUrl = ref('')
let handled = false
const text = (v: unknown) => (typeof v === 'string' ? v : '')
function back() {
if (restartUrl.value) window.location.assign(restartUrl.value)
}
async function switchAccount() {
await feedback.confirm(
'确认退出当前浏览器的 ZYT 身份?其他平台和其他浏览器不受影响。请回 IAM 重新发起。'
)
busy.value = true
try {
await iamPost('/launch/reset')
clearAuthInfo()
back()
} catch (e) {
error.value = identityError(e)
} finally {
busy.value = false
}
}
onMounted(async () => {
if (handled) return
handled = true
const ticket = text(route.query.ticket),
source = text(route.query.source),
intent = text(route.query.intent),
issue = text(route.query.iam_error)
// Strip one-use material before any network/async work; never retain it in history.
window.history.replaceState(window.history.state, '', window.location.pathname)
try {
const config = await iamGet('/launch/config', { source })
restartUrl.value = config.restart_url || ''
if (!config.enabled) throw { error: { code: 'FEATURE_DISABLED' } }
if (issue) throw { error: { code: issue } }
if (ticket) {
const result = await store.iamLaunchLogin(ticket)
await router.replace(
result.is_paw === 0
? '/change-password'
: result.need_bind_work_wechat
? '/bind-work-wechat'
: '/'
)
} else {
if (
!['iam-admin', 'iam-portal'].includes(source) ||
!/^[A-Za-z0-9_-]{32,256}$/.test(intent)
)
throw { error: { code: 'INVALID_REQUEST' } }
const result = await iamPost('/launch/prepare', { source, intent })
window.location.assign(result.url)
}
} catch (e) {
error.value = identityError(e)
} finally {
busy.value = false
}
})
</script>
+50 -17
View File
@@ -81,14 +81,23 @@
<el-button
link
type="primary"
:disabled="!!row.root || row.iam.state === 'verified' || !can('bind')"
:disabled="
(!!row.root && !reviewAllowed) ||
row.iam.state === 'verified' ||
!can(row.iam.state === 'revoked' ? 'rebind' : 'bind')
"
@click="open(row, 'bind')"
>关联</el-button
>
<el-button
link
type="primary"
:disabled="!!row.root || row.iam.state === 'verified' || !can('create')"
:disabled="
(!!row.root && !reviewAllowed) ||
row.iam.state === 'verified' ||
!can('create') ||
(row.iam.state === 'revoked' && !can('rebind'))
"
@click="open(row, 'create_and_bind')"
>创建并关联</el-button
>
@@ -98,12 +107,12 @@
><el-dropdown-menu>
<el-dropdown-item
command="rebind"
:disabled="!!row.root || !can('rebind')"
:disabled="(!!row.root && !reviewAllowed) || !can('rebind')"
>换绑</el-dropdown-item
>
<el-dropdown-item
command="unbind"
:disabled="!!row.root || !can('unbind')"
:disabled="(!!row.root && !reviewAllowed) || !can('unbind')"
>解绑</el-dropdown-item
>
<el-dropdown-item command="audit">审计记录</el-dropdown-item>
@@ -219,6 +228,7 @@
><el-input
class="mt-4"
v-model="operation.reason"
:disabled="!!submittedPayload"
type="textarea"
:rows="3"
maxlength="500"
@@ -237,10 +247,22 @@
<el-alert
v-if="result"
class="mt-4"
:title="`操作 ${result.id}:${result.status}`"
:title="`操作 ${result.id}:${result.status}${result.error?.code ? ' · ' + identityError(result) : ''}`"
:type="result.status === 'succeeded' ? 'success' : 'warning'"
:closable="false"
/>
<div v-if="result?.reviewUrl" class="mt-4">
<a
:href="result.reviewUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary"
>中央复核 ↗</a
>
<p class="text-gray-500 mt-2">
由中央管理员完成近期验证并批准后,重试原操作;原目标、版本和原因保持不变。
</p>
</div>
<el-button
v-if="result && result.status !== 'succeeded'"
class="mt-4"
@@ -250,7 +272,8 @@
</section>
<template #footer
><el-button @click="drawer = false">关闭</el-button
><el-button v-if="step > 0" @click="step--">上一步</el-button
><el-button v-if="step > 0" :disabled="!!submittedPayload" @click="step--"
>上一步</el-button
><el-button
v-if="step < 2"
type="primary"
@@ -265,7 +288,11 @@
!confirmed || !operation.reason.trim() || result?.status === 'succeeded'
"
@click="submit"
>确认{{ titles[operation.kind] }}</el-button
>{{
result?.reviewUrl
? '完成中央复核后重试原操作'
: '确认' + titles[operation.kind]
}}</el-button
></template
>
</el-drawer>
@@ -387,6 +414,7 @@ const titles: Record<string, string> = {
reconcile: '对账'
}
const enabled = ref(false),
reviewAllowed = ref(false),
caps = ref<string[]>([]),
rows = ref<any[]>([]),
total = ref(0),
@@ -403,6 +431,7 @@ const drawer = ref(false),
confirmed = ref(false),
drawerError = ref(''),
result = ref<any>(null)
const submittedPayload = ref<Record<string, unknown> | null>(null)
const operation = reactive<IdentityOperation>({ kind: 'bind', user_id: 0, reason: '' }),
newEmployee = reactive({ username: '', display_name: '', email: '' })
let operationKey = crypto.randomUUID(),
@@ -443,6 +472,7 @@ function reload() {
void load()
}
function open(row: any, kind: IdentityOperation['kind']) {
if (kind === 'bind' && row.iam.state === 'revoked') kind = 'rebind'
selected.value = row
Object.assign(operation, {
kind,
@@ -457,6 +487,7 @@ function open(row: any, kind: IdentityOperation['kind']) {
confirmed.value = false
drawerError.value = ''
result.value = null
submittedPayload.value = null
chosenIdentity.value = null
identities.value = []
operationKey = crypto.randomUUID()
@@ -489,16 +520,17 @@ function choose(row: any) {
async function submit() {
busy.value = true
try {
result.value = await iamPost(
'/operations',
{
...operation,
...(operation.kind === 'create_and_bind'
? { new_employee: { ...newEmployee } }
: {})
},
operationKey
)
if (!submittedPayload.value) {
submittedPayload.value = JSON.parse(
JSON.stringify({
...operation,
...(operation.kind === 'create_and_bind'
? { new_employee: { ...newEmployee } }
: {})
})
)
}
result.value = await iamPost('/operations', submittedPayload.value!, operationKey)
drawerError.value = ''
await load()
} catch (e) {
@@ -585,6 +617,7 @@ onMounted(async () => {
const s = await iamGet('/status')
enabled.value = s.enabled
caps.value = s.management_capabilities || []
reviewAllowed.value = s.protected_review_operator === true
if (enabled.value && can('read')) await load()
} catch (e) {
error.value = identityError(e)
+47 -12
View File
@@ -1,25 +1,28 @@
# IAM v2 关联管理与 ZYT → ERP 快捷登录
# IAM v2 关联管理与本人跨平台快捷登录
## 默认状态与部署次序
所有新功能由 `iam.v2_enabled` 控制,默认 **false**。关闭时不写新表、不登记来源会话,原密码、企微、IAM 入站登录保持原路径。本文只描述源码;生产开关、迁移、真实浏览器及三端联调需另行验收。
1. 备份部署版本与配置,审查并执行 `server/database/migrations/20261029_iam_v2.sql`(按实际表前缀调整)。此迁移只加六张表及独立菜单/权限,不给现有角色自动授予权限,不改账号与角色。
1. 备份部署版本与配置,审查并执行 `server/database/migrations/20261029_iam_v2.sql`(按实际表前缀调整)。此迁移只加七张表及独立菜单/权限,不给现有角色自动授予权限,不改账号与角色。
2. 中央先部署 v2 绑定墓碑、幂等恢复、固定来源回查、版本检查、源/目标访问授权、密码设置协议与签名事件。旧 v1 token 不得获得新管理权限。
3. 向 IAM 注册 application `zyt` 的固定 HTTPS 回调;固定目标仅 `zmxy-erp`。OIDC client 追加精确 self-link 回调,禁用任意重定向。
3. 向 IAM 注册 application `zyt` 的固定 HTTPS 回调;ZYT→ERP 固定目标 `zmxy-erp`;IAM 管理后台/员工工作台可进入本人 ZYT 或 ERP,不增加 ERP→ZYT 按钮。OIDC client 追加精确 self-link 回调,禁用任意重定向。
4. 用一次性合成账号完成三端测试后再开启 v2。配置放服务器私密环境,不写仓库或日志。
```ini
[IAM]
V2_ENABLED = false
V2_TOKEN = <dedicated-application-v2-credential-at-least-32-chars>
V2_TOKEN = <dedicated-login-v2-credential-at-least-32-chars>
V2_MANAGEMENT_TOKEN = <different-management-v2-credential-at-least-32-chars>
V2_INTROSPECTION_TOKEN = <distinct-callback-credential-at-least-32-chars>
V2_EVENT_SECRET = <distinct-HMAC-event-key-at-least-32-chars>
ERP_URL = https://<ERP-origin>
ERP_CALLBACK_URL = https://<ERP-origin>/basic-api/api/erp/iam/v2/launch/callback
PORTAL_URL = https://<IAM-portal-origin>
V2_SOURCES_JSON = {"iam-admin":"https://<IAM-origin>/admin/v1/iam/v2/launch/continue","iam-portal":"https://<IAM-origin>/portal/v2/launch/continue"}
```
中央专用凭据 scopes:`identity:search,identity:create,binding:read,binding:manage,binding:self,password:setup,launch:issue`,ZYT 不授予 target 的 `launch:accept`。
中央credentials数组分别注册:登录凭据 `V2_TOKEN` 仅 `binding:read,binding:self,password:setup,launch:issue,launch:accept`;管理凭据 `V2_MANAGEMENT_TOKEN` 仅 `identity:search,identity:create,binding:read,binding:manage`。identities/operations/audit/batch/jobs固定使用管理凭据,缺失时不fallback登录或v1凭据;target `zyt` 只接受 `iam-admin` / `iam-portal`。
`API_URL`、`PUBLIC_URL`、`ISSUER`、OIDC client 配置沿用原配置。`ERP_URL/PUBLIC_URL/PORTAL_URL` 是固定 origin,不接受浏览器 URL。新功能的 v2 凭据与原 `APPLICATION_TOKEN` 分离。
@@ -28,9 +31,9 @@ PORTAL_URL = https://<IAM-portal-origin>
源应用所有路径前缀 `/adminapi/iam/v2`:
- `POST /proofs/introspect`、`POST /sessions/check`:专用 Bearer callback credential,不接受业务 token 代替。回包为中央约定的原生 JSON,不包业务 envelope。
- `POST /events/revocation`:`X-IAM-Signature=sha256=<HMAC-SHA256(raw-body)>`,`X-IAM-Event-ID` 必须等于 `body.id`;RFC3339 occurredAt、未来最多 60 秒、5 分钟重放窗口,持久化 event ID + digest 幂等;旧 bindingVersion 不撤销较新关联。
- `POST /events/revocation`:`X-IAM-Signature=sha256=<HMAC-SHA256(raw-body)>`,`X-IAM-Event-ID` 必须等于 `body.id`;RFC3339 occurredAt、未来最多 60 秒,持久化 event ID + digest 幂等;耐久outbox旧时间事件仍可重试,不因超过5分钟被拒绝。旧 bindingVersion 只撤销旧/未知来源代际,不覆盖较新关联或另一员工的新会话;同业务token仍有新代际SID时保留token。历史本地无镜像时按签名事件externalAccountId或中央employeeId精确查询补齐映射,不从姓名/手机号猜测;无代际人员/授权事件在event handler回查当前中央状态后决定撤销,避免晚到事件踢掉重新授权会话。
- `POST /launch/start`:同源、当前业务 token + 独立 HttpOnly source SID;只返回目标 `/iam/launch?source=zyt&intent=...`,在新标签页打开。
- `GET /launch/continue?transaction_id=...&intent=...`:IAM 注册的固定来源 continuation;目标 prepare 必须原样附加 opaque intent。比对源 HttpOnly intent cookie、源 SID、源 sessionVersion、固定两应用 ID 后,隐藏表单 POST 到 **`ERP_URL/api/erp/iam/v2/launch/callback`**。grant 和业务 token 均不进 URL。
- `GET /launch/continue?transaction_id=...&intent=...`:IAM 注册的固定来源 continuation;目标 prepare 必须原样附加 opaque intent。比对源 HttpOnly intent cookie、源 SID、源 sessionVersion、固定两应用 ID 后,隐藏表单 POST 到 **`ERP_CALLBACK_URL`**(生产公开反代前缀为 `/basic-api/api/erp/...`,必须与中央注册精确一致)。grant 和业务 token 均不进 URL。
- `POST /self-link/start` → `GET /self-link/callback`:当前本地近期认证 + OIDC code/PKCE/nonce 证明 IAM 同一用户;后端提交已验证 ID token,中央独立验证。前端没有指定个人 employee ID 的能力。
- `POST /password-setup` → `GET /password-continue?request_id=...&state=...`:只对本人已验证且待设置密码的 IAM 账号;中央初始返回 url/expiry/requestId,源 cookie + SID 绑定请求;continuation 隐藏表单 POST 到固定 `PORTAL_URL/portal/password-setup/authorize`。实际密码只在 IAM 页面输入。
@@ -38,18 +41,30 @@ PORTAL_URL = https://<IAM-portal-origin>
## 账号、会话与权限
- 新建来源会话保存独立浏览器 SID、原数据库 session ID、业务 token 的 SHA-256、账号凭据版本指纹、不可被续期更新的认证方法/时间、撤销版本。数据库会话轮换/到期、账号停用/删除、密码/企微关联/管理员标记改变立即使来源证明失效。
- 新建来源会话保存独立浏览器 SID、原数据库 session ID、业务 token 的 SHA-256、账号凭据版本指纹、绑定employee/version快照、不可被续期更新的认证方法/时间、撤销版本。数据库会话轮换/到期、账号停用/删除、密码/企微关联/管理员标记改变立即使来源证明失效。
- 密码登录登记前再次真正核对密码 hash,不接受开发免密开关。企微入口的 TLS 必须校验后才登记 `wecom`。旧会话首次通过密码验证,或用原企微入口重新登录后登记;普通未关联业务使用不受影响。OIDC 使用原始 `auth_time`,缺失时不算近期认证。
- 每次管理写要求 5 分钟内认证、独立动作权限、现有 DataScope。权限:`iamV2/read|bind|create|unbind|rebind|batch`;不存在“未登记路由默认许可”的依赖。
- root、管理员/系统管理员/超级管理员角色或持有 IAM 管理写权限的目标视为敏感账号;没有双侧近期管理员证明则明确返回 `PROTECTED_ACCOUNT_REVIEW_REQUIRED`。批量预检将其列为 review,不发送中央,不静默跳过核验。
- root、管理员/系统管理员/超级管理员角色或持有 IAM 管理写权限的目标视为敏感账号;仅本地 root/管理员类角色具备对应动作权限并完成近期核验时,可提交单人待审操作。中央返回 pending + `PROTECTED_ACCOUNT_REVIEW_REQUIRED` + 固定 `/admin/iam/review?request=...`;界面新标签中央复核,批准后保持相同请求体/幂等键、新 proof 重试。无中央批准仍不执行,普通 IAM 管理角色不具备此特权。批量预检将其列为 review,不发送中央,不静默跳过核验。
- 中央身份和授权是真源;本地绑定表是最长 30 秒快照。已关联账号的活跃业务请求在快照超时后回查;失权、版本变更、签名事件撤销来源 SID 和业务会话。服务不可用时相关受保护请求失败关闭。不宣称空闲客户端会在 30 秒内主动收到推送。
- 解绑/换绑保留本地业务账号和历史记录。`iam_local_identity` 是开户幂等账本,不是历史绑定全量镜像,永不删除。v2 撤销墓碑拦截旧账本自动重绑;中央旧版 v1 必须同样维护墓碑。较旧或相同版本的 verified 状态不能覆盖本地 revoked。
- 解绑/换绑保留本地业务账号和历史记录。`iam_local_identity` 是开户幂等账本,不是历史绑定全量镜像,永不删除。v2 撤销墓碑拦截旧账本自动重绑;中央旧版 v1 必须同样维护墓碑。较旧或相同版本的 verified 状态不能覆盖本地 revoked。`binding_revoked.bindingVersion` 是旧代际撤销上界;人员停用/应用失权事件只撤销会话并过期快照,不建立永久绑定墓碑。中央审核的新显式绑定可恢复登录,但自动开户分支仍拒绝墓碑。
- 管理列表来自所有可见本地账号并回查中央,首次展示历史绑定;按状态筛选基于已对账快照,未读取过的历史行应先在“全部”中分页核验。姓名/手机号不是自动合并依据。批量用明确 local ID/employee ID 映射预检。
## IAM 管理后台/员工工作台 → ZYT target
中央注册 `zyt.launchUrl=PUBLIC_URL/admin/iam/launch`、`targetDeliveryUrl=PUBLIC_URL/adminapi/iam/v2/launch/callback`;`V2_SOURCES_JSON` 与中央两条 sourceContinueUrl 必须相同,来源只允许 iam-admin/iam-portal。
前端 `/admin/iam/launch` 先清除 URL 中的 intent/ticket,再调用 `POST /launch/prepare`。后端保存固定来源、PKCE、浏览器 hash 与已有本地账号,设置 HttpOnly Secure SameSite=None target cookie。中央 sourceContinueUrl 的 transaction_id 必须一致,后端追加原 opaque intent;已有 KC cookie 不是必要条件。
`POST /launch/callback` 核验固定来源 Origin、target cookie、单次事务后兑换 grant;已登录用户冲突在任何自动开户前拒绝。仍通过旧 IamAccountProvisioner 的持久账本/角色2开户,已有账号角色/密码不改。只读回查中央绑定完成后产生60秒一次票据。`POST /launch/exchange` 再比对当前 token 和独立 source cookie,成功才调用 LoginLogic 签发本地token,认证方法标记 iam_launch 并保留真实原authTime。账号不一致只显示显式退出当前浏览器并返回IAM重新发起,不静默替换。
目标来源代际保存在 `iam_v2_target_session`。活跃业务请求最长30秒调用中央 `/launch-authorizations/check`,仅检查employee/target授权与来源绑定代际,不把普通source logout当作全局登出。源 introspect/sessions-check 回调只查本地数据,绝不递归中央,避免中央state行锁死锁。
## 本地验证
```sh
php server/tests/IamV2SourceTest.php
php server/tests/IamV2TargetTest.php
php server/tests/IamV2RouteContractTest.php
NODE_PATH=admin/node_modules node server/tests/IamV2UiTest.mjs
php server/tests/IamLoginTransactionTest.php
@@ -58,8 +73,28 @@ php server/tests/IamOidcClientTest.php
NODE_PATH=admin/node_modules node server/tests/IamLoginUiContractTest.mjs
```
SQLite 合成测试不加载生产 `.env`、不发网络、不修改真实用户、角色或授权。UI 测试编译三个组件并执行新标签页处理逻辑,不能替代三端真实浏览器(Cookie/SameSite/新标签页)验收。原前端全量 typecheck 存量 60 条诊断,需按基线比较,不能批量掩盖。
SQLite 合成测试不加载生产 `.env`、不发网络、不修改真实用户、角色或授权。UI 测试编译四个组件并执行新标签页处理逻辑,不能替代三端真实浏览器(Cookie/SameSite/新标签页)验收。原前端全量 typecheck 存量 60 条诊断,需按基线比较,不能批量掩盖。
## 回退
先将 `V2_ENABLED=false`,恢复上一已验证代码/静态构建。保留六张新表、旧开户账本、事件与审计记录、所有真实账号和业务数据。关闭 v2 停止委托登录/新证明,不撤回中央已执行的人事操作;错误关联通过有审计的显式解绑/换绑恢复,而非删表或恢复旧数据库。
先将 `V2_ENABLED=false`,恢复上一已验证代码/静态构建。保留七张新表、旧开户账本、事件与审计记录、所有真实账号和业务数据。关闭 v2 停止委托登录/新证明,不撤回中央已执行的人事操作;错误关联通过有审计的显式解绑/换绑恢复,而非删表或恢复旧数据库。
## 2026-10-29 只读生产入口核对与发布步骤
本轮只读核验(未部署、未迁移、未开关):
- SSH 别名 `zyt`;Nginx vhost `/www/server/panel/vhost/nginx/admin.zhenyangtang.com.cn.conf` 的实际 root 是 `/www/wwwroot/zyt/server/public`,PHP include 为 `enable-php-82.conf`,现有 rewrite 经 `index.php?s=...` 进入 ThinkPHP。
- 运行仓库 `/www/wwwroot/zyt` 的 HEAD 是 `e183e1cc62d3205576d5dfabd555e78bb86654c8`;它与开发基线 `bcd42a5c399904ec3f9454c6fe1a74bde511ca2c` 不同,禁止整树覆盖。首版9个原文件哈希与开发基线完全一致,17个新增路径未碰撞;本轮追加改动仍须对最终发布manifest重新核对。
- PHP 二进制 `/www/server/php/82/bin/php` 为 8.2.35;MySQL CLI 为5.7.44(此处不是数据库服务器版本声明)。服务私密配置留在 `/www/wwwroot/zyt/server/.env`,本轮未读取或输出凭据。
- 公网只读 `GET https://admin.zhenyangtang.com.cn/adminapi/iam/config` 返回 `code=1`、`enabled=true`、既有 `/adminapi/iam/start` 登录地址;v2 仍未发布。
- `admin/scripts/release.mjs` 会先删除整个 `server/public/admin`。不要在生产直接运行 `npm run build`;它与 `docs/iam-quick-login.md` 要求的保留旧哈希资源冲突。仓库中的 Docker 示例不是当前 Nginx/PHP-FPM 运行入口,ERP ACR 发布脚本也不是 ZYT 发布入口。
得到发布授权后的受控步骤:
1. 从指定已提交版本的干净副本构建,运行完整 `scripts/test-iam-v2.sh`、基线差分与 `vite build`;只打包本次明确变更清单和前端构建,附各文件SHA256。不要携带 `.env`、`.local`、上传目录或开发库。
2. 只读核对线上所有被替换文件与发布前manifest哈希、新增路径无碰撞;若不一致先重审,不覆盖他人的新发布。保留服务器原文件/静态index及独立备份目录,保存私密配置备份与正常数据库备份。
3. 在确认实际表前缀后执行 `20261029_iam_v2.sql`,使用既有受保护的MySQL凭据方式,不能把口令放命令行/日志。MySQL DDL不因后面的事务自动回退;逐表验证七表/索引和菜单节点,保留旧开户账本。菜单不自动给业务角色授权。
4. 中央先完成v2迁移、凭据/固定URL/回查注册、原生IAM本人入口和中央复核部署;ZYT新代码先保持 `V2_ENABLED=false`。配置完整的API/portal/ERP公开callback URL及两条来源映射,尤其ERP公开 `/basic-api` 前缀。
5. 后端按审核清单更新;静态资源先增量加入旧资源目录,最后原子切换index.html,不删旧哈希文件。按实际OPcache配置处理源码重载,不把CLI `opcache_reset()`当成FPM已刷新,不重启无关服务。
6. 先验证开关关闭时原登录/业务行为,再用授权测试账号开启v2验收:密码/企微→ERP、IAM管理后台本地session无KC cookie→ZYT/ERP、员工工作台→两端、同人/异人多标签、已有账号/角色2新开户、密码owner双阶段、敏感单人中央审批及批量跳过、签名事件/30秒补偿。逐项核对真实页面与后端会话,不以HTTP200代替登录验收。
7. 回退先关闭v2,恢复发布前的本次后端文件与静态index;保留七表、事件、绑定历史、中央操作与所有业务数据。不要用整库恢复或删除IAM员工来回退代码。普通source logout按约定不是全局登出。
+1
View File
@@ -2,6 +2,7 @@
set -eu
cd "$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
php server/tests/IamV2SourceTest.php
php server/tests/IamV2TargetTest.php
php server/tests/IamV2RouteContractTest.php
NODE_PATH=admin/node_modules node server/tests/IamV2UiTest.mjs
php server/tests/IamLoginTransactionTest.php
@@ -7,17 +7,17 @@ use RuntimeException;
use think\facade\Config;
use think\facade\Db;
use app\adminapi\service\iam\IamOidcClient;
use app\adminapi\service\iam\v2\{IamV2Client, IamV2Source, IamV2Manager, IamV2Flow};
use app\adminapi\service\iam\v2\{IamV2Client, IamV2Source, IamV2Manager, IamV2Flow, IamV2Target};
/** Every endpoint authorizes itself. Never relies on legacy unregistered-route allow rules. */
final class IamV2Controller extends BaseAdminController
{
public array $notNeedLogin = ['status', 'introspect', 'checkSession', 'revocation', 'launchContinue', 'selfCallback', 'passwordContinue'];
public array $notNeedLogin = ['status', 'introspect', 'checkSession', 'revocation', 'launchContinue', 'selfCallback', 'passwordContinue', 'targetConfig', 'targetPrepare', 'targetCallback', 'targetExchange', 'targetReset'];
private function invokeV2(callable $fn)
{
try {
if (!IamV2Client::enabled()) { throw new RuntimeException('FEATURE_DISABLED'); }
$reads = ['status','accounts','identities','operationresult','audit','job'];
$reads = ['status','accounts','identities','operationresult','audit','job','targetconfig'];
$read = in_array(strtolower((string) $this->request->action()), $reads, true);
if (($read && !$this->request->isGet()) || (!$read && !$this->request->isPost())) { throw new RuntimeException('METHOD_NOT_ALLOWED'); }
return $this->data($fn())->header(['Cache-Control' => 'no-store', 'Referrer-Policy' => 'no-referrer']);
@@ -57,7 +57,9 @@ final class IamV2Controller extends BaseAdminController
$fresh = false; try { $s = IamV2Source::current($token); $fresh = $s['auth_time'] >= time() - 300; } catch (\Throwable $e) { $s = null; }
$b = (new IamV2Manager())->binding((int) $legacy['admin_id']);
return ['enabled' => true, 'session_ready' => $s !== null, 'reauth_required' => !$fresh,
'management_capabilities' => IamV2Source::capabilities((int) $legacy['admin_id'])] + $b;
'management_capabilities' => IamV2Source::capabilities((int) $legacy['admin_id']),
'protected_review_operator' => IamV2Source::reviewOperator((int) $legacy['admin_id']),
'protected_account' => IamV2Source::protectedAccount((int) $legacy['admin_id'])] + $b;
});
}
public function reauth()
@@ -100,8 +102,9 @@ final class IamV2Controller extends BaseAdminController
}
public function checkSession()
{
return $this->serviceResult(function () { $this->serviceAuth(); $b = $this->input(); $s = IamV2Source::check((string) ($b['sessionId'] ?? ''), (int) ($b['sessionVersion'] ?? 0));
return ['active' => true, 'actorLocalId' => (string) $s['admin_id'], 'authMethod' => $s['auth_method'], 'authTime' => (int) $s['auth_time'], 'expiresAt' => (int) $s['expires_at']]; });
return $this->serviceResult(function () { $this->serviceAuth(); $b = $this->input();
return IamV2Source::liveStatus((string) ($b['sessionId'] ?? ''), (int) ($b['sessionVersion'] ?? 0),
(string) ($b['purpose'] ?? ''), (array) ($b['targetLocalIds'] ?? [])); });
}
public function launchStart()
{
@@ -123,7 +126,9 @@ final class IamV2Controller extends BaseAdminController
if (($transaction['sourceApplicationId'] ?? '') !== 'zyt' || ($transaction['targetApplicationId'] ?? '') !== 'zmxy-erp') { throw new RuntimeException('ACCOUNT_MISMATCH'); }
(new IamV2Manager($client))->assertLinked((int) $s['admin_id']);
$grant = $client->call('POST', '/launch-grants', ['transactionId' => $tx, 'proof' => IamV2Source::proof($s, 'launch', [(int) $s['admin_id']], [], $tx)]);
$delivery = IamV2Client::origin((string) Config::get('iam.erp_url')) . '/api/erp/iam/v2/launch/callback';
$erpOrigin = IamV2Client::origin((string) Config::get('iam.erp_url'));
$delivery = IamV2Client::https((string) Config::get('iam.erp_callback_url') ?: $erpOrigin . '/api/erp/iam/v2/launch/callback');
if (!in_array($delivery, [$erpOrigin . '/api/erp/iam/v2/launch/callback', $erpOrigin . '/basic-api/api/erp/iam/v2/launch/callback'], true)) { throw new RuntimeException('INVALID_FIXED_URL'); }
if (($grant['deliveryUrl'] ?? '') !== $delivery) { throw new RuntimeException('INVALID_FIXED_URL'); }
return response(IamV2Flow::form($delivery, ['transaction_id' => $tx, 'grant' => $grant['grant']]))->header(['Content-Type' => 'text/html; charset=utf-8', 'Cache-Control' => 'no-store', 'Referrer-Policy' => 'no-referrer']);
} catch (\Throwable $e) { return redirect('/admin/user/setting?iam_result=launch_failed')->header(['Cache-Control' => 'no-store']); }
@@ -176,6 +181,55 @@ final class IamV2Controller extends BaseAdminController
return response(IamV2Flow::form($url, ['request_id' => $id, 'state' => $state, 'proof' => $proof]))->header(['Content-Type' => 'text/html; charset=utf-8', 'Cache-Control' => 'no-store', 'Referrer-Policy' => 'no-referrer']);
} catch (\Throwable $e) { return redirect('/admin/user/setting?iam_result=password_failed'); }
}
private function targetOrigin(): void
{
if (!$this->request->isPost() || (string) $this->request->header('origin') !== IamV2Client::origin((string) Config::get('iam.public_url'))) { throw new RuntimeException('INVALID_ORIGIN'); }
}
public function targetConfig()
{
if (!IamV2Client::enabled()) { return $this->data(['enabled' => false]); }
return $this->invokeV2(function () {
$source = (string) $this->request->get('source', 'iam-portal');
return ['enabled' => true, 'restart_url' => IamV2Client::origin((string) Config::get('iam.portal_url')) . ($source === 'iam-admin' ? '/admin' : '/portal')];
});
}
public function targetPrepare()
{
return $this->invokeV2(function () { $this->targetOrigin();
$out = (new IamV2Target())->prepare((string) $this->request->post('source', ''), (string) $this->request->post('intent', ''),
(string) $this->request->header('token', ''), (string) ($_COOKIE[IamV2Source::COOKIE] ?? ''));
setcookie(IamV2Target::COOKIE, $out['browser'], ['expires' => time() + 300, 'path' => '/adminapi/iam/v2/launch', 'secure' => true, 'httponly' => true, 'samesite' => 'None']);
return ['url' => $out['url']];
});
}
public function targetCallback()
{
try {
if (!$this->request->isPost()) { throw new RuntimeException('METHOD_NOT_ALLOWED'); }
$out = (new IamV2Target())->callback((string) $this->request->post('transaction_id', ''), (string) $this->request->post('grant', ''),
(string) ($_COOKIE[IamV2Target::COOKIE] ?? ''), (string) $this->request->header('origin', ''));
return redirect('/admin/iam/launch?' . http_build_query($out))->header(['Cache-Control' => 'no-store', 'Referrer-Policy' => 'no-referrer']);
} catch (\Throwable $e) {
$code = preg_match('/^[A-Z_]{3,64}$/D', $e->getMessage()) ? $e->getMessage() : 'SERVICE_UNAVAILABLE';
return redirect('/admin/iam/launch?iam_error=' . rawurlencode($code))->header(['Cache-Control' => 'no-store', 'Referrer-Policy' => 'no-referrer']);
}
}
public function targetExchange()
{
return $this->invokeV2(function () { $this->targetOrigin();
return (new IamV2Target())->exchange((string) $this->request->post('ticket', ''), (string) ($_COOKIE[IamV2Target::COOKIE] ?? ''),
(string) $this->request->header('token', ''), (string) ($_COOKIE[IamV2Source::COOKIE] ?? ''));
});
}
public function targetReset()
{
return $this->invokeV2(function () { $this->targetOrigin();
IamV2Source::revokeSession((string) ($_COOKIE[IamV2Source::COOKIE] ?? ''));
setcookie(IamV2Source::COOKIE, '', ['expires' => 1, 'path' => '/adminapi', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax']);
setcookie(IamV2Target::COOKIE, '', ['expires' => 1, 'path' => '/adminapi/iam/v2/launch', 'secure' => true, 'httponly' => true, 'samesite' => 'None']);
return ['reset' => true];
});
}
public function revocation()
{
return $this->serviceResult(fn() => \app\adminapi\service\iam\v2\IamV2Events::accept(
@@ -72,6 +72,8 @@ class LoginMiddleware
// Active requests compensate missed central revocations within a 30-second cache window.
// Local-only unlinked accounts and disabled installations keep their original behavior.
if ($adminInfo && !$isNotNeedLogin && \app\adminapi\service\iam\v2\IamV2Client::enabled()) {
try { \app\adminapi\service\iam\v2\IamV2Target::compensate((string) $token); }
catch (\Throwable $e) { return JsonService::fail('统一身份授权已失效或暂时不可用,请重新验证', [], 0); }
$binding = \think\facade\Db::name('iam_v2_binding')->where('admin_id', $adminInfo['admin_id'])->find();
if ($binding && $binding['state'] !== 'unlinked') {
try {
+7
View File
@@ -21,3 +21,10 @@ Route::post('iam/v2/self-link/start', 'IamV2/selfStart');
Route::get('iam/v2/self-link/callback', 'IamV2/selfCallback');
Route::post('iam/v2/password-setup', 'IamV2/passwordSetup');
Route::get('iam/v2/password-continue', 'IamV2/passwordContinue');
// ZYT as a target for the same person's IAM admin/portal session only.
Route::get('iam/v2/launch/config', 'IamV2/targetConfig');
Route::post('iam/v2/launch/prepare', 'IamV2/targetPrepare');
Route::post('iam/v2/launch/callback', 'IamV2/targetCallback');
Route::post('iam/v2/launch/exchange', 'IamV2/targetExchange');
Route::post('iam/v2/launch/reset', 'IamV2/targetReset');
@@ -36,15 +36,16 @@ final class IamAccountProvisioner
public function resolve(IamOidcClient $client, array $config, string $subject): int
{
if (\app\adminapi\service\iam\v2\IamV2Client::enabled()) {
$tombstone = Db::name('iam_v2_binding')->where('subject', $subject)->where('state', 'revoked')->find();
if ($tombstone) { throw new RuntimeException('IAM_BINDING_REVOKED'); }
}
$context = $client->provisioning($subject);
self::validateContext($context, $subject, $config['application_id']);
if (($context['shouldCreateLocalAccount'] ?? null) !== true) {
return IamLoginService::boundAdminId($context, $subject, $config['application_id']);
}
// A local tombstone blocks auto-creation, not an explicitly reviewed newer central binding.
if (\app\adminapi\service\iam\v2\IamV2Client::enabled()) {
$tombstone = Db::name('iam_v2_binding')->where('subject', $subject)->where('state', 'revoked')->find();
if ($tombstone) { throw new RuntimeException('IAM_BINDING_REVOKED'); }
}
if (($context['externalAccountId'] ?? null) !== '') {
throw new RuntimeException('统一账号绑定状态不一致');
}
@@ -79,9 +79,12 @@ final class IamLoginService
$transaction = $this->transactions->consume('ticket', $ticket, $browser);
// Recheck entitlement, binding and local status immediately before issuing the existing business token.
$admin = $this->verifiedAdmin($transaction['subject']);
$result = (array) (new LoginLogic())->login(['account' => $admin->account, 'terminal' => AdminTerminalEnum::PC]);
// Reconcile/revoke the previous binding generation before minting the new token.
if (\app\adminapi\service\iam\v2\IamV2Client::enabled()) {
(new \app\adminapi\service\iam\v2\IamV2Manager())->assertLinked((int) $admin->id);
}
$result = (array) (new LoginLogic())->login(['account' => $admin->account, 'terminal' => AdminTerminalEnum::PC]);
if (\app\adminapi\service\iam\v2\IamV2Client::enabled()) {
\app\adminapi\service\iam\v2\IamV2Source::enroll($result['token'], 'oidc', true, (int) ($transaction['auth_time'] ?? 0));
}
return $result;
@@ -39,7 +39,11 @@ final class IamV2Client
{
if (!preg_match('#^/[a-zA-Z0-9/_?=&%.:-]+$#D', $path) || str_contains($path, '..')) { throw new RuntimeException('INVALID_PATH'); }
$url = rtrim($this->settings['api_url'], '/') . '/internal/v2/applications/' . rawurlencode($this->settings['application_id'] ?? 'zyt') . $path;
$headers = ['Accept: application/json', 'Content-Type: application/json', 'Authorization: Bearer ' . $this->settings['v2_token']];
$management = preg_match('#^/(identities|operations|audit|batch|jobs)(/|[?]|$)#D', $path) === 1;
$credential = (string) ($this->settings[$management ? 'v2_management_token' : 'v2_token'] ?? '');
if (strlen($credential) < 32 || preg_match('/[\r\n]/', $credential)
|| ($management && hash_equals((string) $this->settings['v2_token'], $credential))) { throw new RuntimeException('SERVICE_UNAVAILABLE'); }
$headers = ['Accept: application/json', 'Content-Type: application/json', 'Authorization: Bearer ' . $credential];
if ($method !== 'GET') {
$headers[] = 'X-Request-ID: ' . bin2hex(random_bytes(16));
$headers[] = 'Idempotency-Key: ' . ($key ?? bin2hex(random_bytes(24)));
@@ -59,7 +63,10 @@ final class IamV2Client
}
$result = json_decode($r['body'] ?? '', true);
if (!is_array($result)) { throw new RuntimeException('SERVICE_UNAVAILABLE'); }
if (($r['status'] ?? 0) < 200 || $r['status'] >= 300 || isset($result['error'])) {
$operationEnvelope = isset($result['id']) && is_string($result['id'])
&& in_array($result['status'] ?? '', ['pending', 'succeeded', 'failed', 'conflict'], true)
&& preg_match('#^/(operations|jobs|batch)(/|$)#D', $path);
if (($r['status'] ?? 0) < 200 || $r['status'] >= 300 || (isset($result['error']) && !$operationEnvelope)) {
$code = $result['error']['code'] ?? 'SERVICE_UNAVAILABLE';
throw new RuntimeException(preg_match('/^[A-Z_]{3,64}$/D', $code) ? $code : 'SERVICE_UNAVAILABLE');
}
@@ -7,7 +7,7 @@ use think\facade\Db;
final class IamV2Events
{
public static function accept(string $raw, string $signature, string $eventId, string $secret, ?callable $revoke = null): array
public static function accept(string $raw, string $signature, string $eventId, string $secret, ?callable $revoke = null, ?callable $refresh = null, ?callable $locate = null, ?callable $generation = null): array
{
if (strlen($raw) > 65536 || strlen($secret) < 32 || !hash_equals('sha256=' . hash_hmac('sha256', $raw, $secret), $signature)) { throw new RuntimeException('FORBIDDEN'); }
$event = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
@@ -15,9 +15,28 @@ final class IamV2Events
$occurredAt = (string) ($event['occurredAt'] ?? '');
if (!preg_match('/^\d{4}-\d{2}-\d{2}T.*(Z|[+-][0-9]{2}:[0-9]{2})$/D', $occurredAt)) { throw new RuntimeException('INVALID_EVENT'); }
$stamp = strtotime($occurredAt);
if (!$stamp || $stamp > time() + 60 || time() - $stamp > 300) { throw new RuntimeException('TRANSACTION_EXPIRED'); }
if (!$stamp || $stamp > time() + 60) { throw new RuntimeException('TRANSACTION_EXPIRED'); }
$customRevoke = $revoke !== null;
$revoke ??= fn(int $id) => IamV2Manager::revoke($id);
return Db::transaction(function () use ($event, $eventId, $raw, $revoke) {
$refresh ??= fn(int $id) => (new IamV2Manager())->binding($id, true);
$generation ??= $customRevoke ? fn(int $id, int $upper) => $revoke($id) : fn(int $id, int $upper) => IamV2Source::revokeGeneration($id, $upper, (string) $event['employeeId']);
$locate ??= static function (array $event): array {
if (($event['eventType'] ?? '') === 'binding_revoked' && isset($event['externalAccountId'])) {
if (!is_string($event['externalAccountId']) || !preg_match('/^[1-9][0-9]{0,9}$/D', $event['externalAccountId'])) { throw new RuntimeException('INVALID_EVENT'); }
return [(int) $event['externalAccountId']];
}
$reply = (new IamV2Client())->call('GET', '/bindings?' . http_build_query(['employeeId' => $event['employeeId'], 'page' => 1, 'pageSize' => 100], '', '&', PHP_QUERY_RFC3986));
if (($reply['total'] ?? 0) > 100) { throw new RuntimeException('SERVICE_UNAVAILABLE'); }
$ids = [];
foreach ($reply['items'] ?? [] as $item) {
if (($item['binding']['employeeId'] ?? '') !== $event['employeeId'] || ($item['binding']['applicationId'] ?? '') !== 'zyt') { throw new RuntimeException('INVALID_RESPONSE'); }
$external = (string) ($item['binding']['externalAccountId'] ?? '');
if (!preg_match('/^[1-9][0-9]{0,9}$/D', $external)) { throw new RuntimeException('INVALID_RESPONSE'); }
$ids[] = (int) $external;
}
return $ids;
};
return Db::transaction(function () use ($event, $eventId, $raw, $revoke, $refresh, $locate, $generation) {
$existing = Db::name('iam_v2_event')->where('event_id', $eventId)->lock(true)->find();
if ($existing) {
if (!hash_equals($existing['digest'], hash('sha256', $raw))) { throw new RuntimeException('INVALID_EVENT'); }
@@ -25,11 +44,36 @@ final class IamV2Events
}
Db::name('iam_v2_event')->insert(['event_id' => $eventId, 'digest' => hash('sha256', $raw), 'received_at' => time()]);
$rows = Db::name('iam_v2_binding')->where('employee_id', (string) $event['employeeId'])->lock(true)->select()->toArray();
if (!$rows && ($event['eventType'] ?? '') !== 'source_binding_revoked') {
foreach ($locate($event) as $id) {
$row = Db::name('iam_v2_binding')->where('admin_id', $id)->find();
if (!$row) { $refresh($id); $row = Db::name('iam_v2_binding')->where('admin_id', $id)->find(); }
if ($row) { $rows[] = $row; }
}
}
foreach ($rows as $row) {
if (isset($event['bindingVersion']) && (int) $event['bindingVersion'] < (int) $row['version']) { continue; }
Db::name('iam_v2_binding')->where('admin_id', $row['admin_id'])->update(['state' => 'revoked', 'checked_at' => 0,
'version' => max((int) $row['version'], (int) ($event['bindingVersion'] ?? 0))]);
$revoke((int) $row['admin_id']);
$bindingEvent = ($event['eventType'] ?? '') === 'binding_revoked'
|| in_array($event['reason'] ?? '', ['iam.v2.unbind', 'iam.v2.rebind'], true);
// ZYT is source-only in this rollout; target-derived revocation belongs to ERP.
if (($event['eventType'] ?? '') === 'source_binding_revoked') { continue; }
$upper = isset($event['bindingVersion']) ? (int) $event['bindingVersion'] : null;
if ($bindingEvent && $upper !== null && ($upper < (int) $row['version'] || $row['employee_id'] !== $event['employeeId'])) { $generation((int) $row['admin_id'], $upper); continue; }
if (!$bindingEvent || $upper === null) {
// Durable outbox delivery may be old. Current central state wins over a stale generic event.
// A failed lookup rolls back event receipt, so the durable outbox can retry.
$current = $refresh((int) $row['admin_id']);
if (($current['binding']['state'] ?? '') === 'verified' && ($current['employee']['status'] ?? '') === 'active'
&& ($current['access']['allowed'] ?? false)) { continue; }
}
$updates = ['checked_at' => 0];
// Personnel disable/entitlement events invalidate sessions, not the binding itself.
// A versioned binding event carries the OLD generation as its revocation upper bound.
if ($bindingEvent && $upper !== null && $upper > 0) {
$updates['state'] = 'revoked'; $updates['version'] = max((int) $row['version'], $upper);
}
Db::name('iam_v2_binding')->where('admin_id', $row['admin_id'])->update($updates);
if ($bindingEvent && $upper !== null) { $generation((int) $row['admin_id'], $upper); }
else { $revoke((int) $row['admin_id']); }
}
return ['accepted' => true];
});
@@ -12,6 +12,14 @@ final class IamV2Flow
Db::name('iam_v2_flow')->insert(['flow_key' => hash('sha256', $kind . ':' . $secret), 'kind' => $kind,
'payload' => json_encode($payload, JSON_THROW_ON_ERROR), 'expires_at' => time() + $ttl, 'consumed' => 0]);
}
public static function peek(string $kind, string $secret, string $sid): array
{
$row = Db::name('iam_v2_flow')->where('flow_key', hash('sha256', $kind . ':' . $secret))->find();
if (!$row || $row['expires_at'] <= time() || (int) $row['consumed'] !== 0) { throw new RuntimeException('TRANSACTION_EXPIRED'); }
$p = json_decode($row['payload'], true, 32, JSON_THROW_ON_ERROR);
if (($p['sessionId'] ?? '') !== $sid) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
return $p;
}
public static function take(string $kind, string $secret, string $sid): array
{
return Db::transaction(function () use ($kind, $secret, $sid) {
@@ -17,6 +17,13 @@ final class IamV2Manager
if (!$fresh && $row && $row['checked_at'] > time() - 30) { return json_decode($row['payload'], true, 32, JSON_THROW_ON_ERROR); }
$result = $this->client->call('GET', '/bindings/by-external/' . $id);
if (($result['binding']['externalAccountId'] ?? (string) $id) !== (string) $id) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
if ($result['binding'] ?? null) {
$b = $result['binding']; $employee = $result['employee'] ?? [];
if (($b['applicationId'] ?? '') !== (string) Config::get('iam.application_id', 'zyt')
|| !is_int($b['version'] ?? null) || $b['version'] < 1
|| !in_array($b['state'] ?? '', ['verified','revoked','pending','conflict'], true)
|| empty($employee['id']) || ($b['employeeId'] ?? '') !== $employee['id'] || empty($employee['oidcSubject'])) { throw new RuntimeException('INVALID_RESPONSE'); }
}
$state = $result['binding']['state'] ?? 'unlinked';
$version = (int) ($result['binding']['version'] ?? 0);
if ($row && $row['state'] === 'revoked' && $state === 'verified' && $version <= (int) $row['version']) { throw new RuntimeException('BINDING_CONFLICT'); }
@@ -25,9 +32,11 @@ final class IamV2Manager
'state' => $state, 'version' => $version, 'payload' => json_encode($result, JSON_THROW_ON_ERROR), 'checked_at' => time()];
if ($row) { Db::name('iam_v2_binding')->where('admin_id', $id)->update($values); }
else { Db::name('iam_v2_binding')->insert($values); }
$changed = $row && (($row['employee_id'] ?? '') !== ($values['employee_id'] ?? '') || (int) $row['version'] !== $version);
if ($state === 'revoked' || $changed || (($result['employee'] ?? null) && (($result['employee']['status'] ?? '') !== 'active' || !($result['access']['allowed'] ?? false)))) {
$changed = $row && $row['state'] === 'verified' && (($row['employee_id'] ?? '') !== ($values['employee_id'] ?? '') || (int) $row['version'] !== $version);
if ($state === 'revoked' || (($result['employee'] ?? null) && (($result['employee']['status'] ?? '') !== 'active' || !($result['access']['allowed'] ?? false)))) {
self::revoke($id);
} elseif ($changed) {
IamV2Source::revokeGeneration($id, (int) $row['version'], (string) $row['employee_id']);
}
return $result + ['checked_at' => time()];
}
@@ -68,8 +77,8 @@ final class IamV2Manager
$kind = (string) ($input['kind'] ?? '');
if (!in_array($kind, ['bind', 'create_and_bind', 'unbind', 'rebind', 'reconcile'], true)) { throw new RuntimeException('INVALID_OPERATION'); }
$id = (int) ($input['user_id'] ?? 0);
if ($id < 1 || trim((string) ($input['reason'] ?? '')) === '') { throw new RuntimeException('INVALID_INPUT'); }
$out = ['kind' => $kind, 'externalAccountId' => (string) $id, 'reason' => mb_substr(trim($input['reason']), 0, 500)];
if ($id < 1 || strlen(trim((string) ($input['reason'] ?? ''))) < 3) { throw new RuntimeException('INVALID_INPUT'); }
$out = ['kind' => $kind, 'externalAccountId' => (string) $id, 'reason' => mb_strcut(trim($input['reason']), 0, 500, 'UTF-8')];
if (isset($input['employee_id'])) { $out['employeeId'] = (string) $input['employee_id']; }
if (isset($input['expected_version'])) { $out['expectedVersion'] = (int) $input['expected_version']; }
if (in_array($kind, ['unbind','rebind'], true) && !isset($out['expectedVersion'])) { throw new RuntimeException('BINDING_CONFLICT'); }
@@ -84,18 +93,36 @@ final class IamV2Manager
}
return $out;
}
public static function cap(string $kind): string { return ['create_and_bind' => 'create', 'reconcile' => 'read'][$kind] ?? $kind; }
public static function cap(string $kind): string { return ['create_and_bind' => 'create', 'reconcile' => 'bind'][$kind] ?? $kind; }
public function operation(array $session, array $input, string $requestId): array
{
$body = self::input($input); $target = (int) $body['externalAccountId']; $cap = self::cap($body['kind']);
IamV2Source::authorize($session, $cap, [$target]);
$this->binding($target, true);
$body['proof'] = IamV2Source::proof($session, 'management', [$target], ['iam:' . $cap]);
IamV2Source::authorize($session, $cap, [$target], true, true);
$current = $this->binding($target, true); $caps = ['iam:' . $cap];
if ($body['kind'] === 'create_and_bind' && ($current['binding']['state'] ?? '') === 'revoked') {
if (!isset($body['expectedVersion'])) { throw new RuntimeException('BINDING_CONFLICT'); }
IamV2Source::authorize($session, 'rebind', [$target], true, true); $caps[] = 'iam:rebind';
}
$body['proof'] = IamV2Source::proof($session, 'management', [$target], $caps, null, ['protectedReviewEligible' => IamV2Source::reviewOperator((int) $session['admin_id'])]);
$op = $this->client->call('POST', '/operations', $body, $requestId);
$this->remember('operation', (string) $op['id'], $session, [$target], ['iam:' . $cap]);
$op = $this->reviewLink($op);
$this->remember('operation', (string) $op['id'], $session, [$target], $caps);
if (($op['status'] ?? '') === 'succeeded') { $this->binding($target, true); }
return $op;
}
private function reviewLink(array $operation): array
{
if (!isset($operation['reviewUrl'])) { return $operation; }
$origin = IamV2Client::origin((string) Config::get('iam.portal_url'));
$parts = parse_url((string) $operation['reviewUrl']);
$fixed = parse_url($origin);
if (!$parts || ($parts['scheme'] ?? '') !== 'https' || ($parts['host'] ?? '') !== $fixed['host']
|| ($parts['port'] ?? 443) !== ($fixed['port'] ?? 443) || ($parts['path'] ?? '') !== '/admin/iam/review'
|| isset($parts['user']) || isset($parts['pass']) || isset($parts['fragment'])) { throw new RuntimeException('INVALID_FIXED_URL'); }
parse_str($parts['query'] ?? '', $query);
if (count($query) !== 1 || ($query['request'] ?? null) !== ($operation['id'] ?? null)) { throw new RuntimeException('INVALID_FIXED_URL'); }
return $operation;
}
public function remember(string $kind, string $id, array $session, array $targets, array $caps = []): void
{
$key = hash('sha256', $kind . ':' . $id);
@@ -115,12 +142,17 @@ final class IamV2Manager
public function preflight(array $session, array $items): array
{
if (!$items || count($items) > 100) { throw new RuntimeException('INVALID_INPUT'); }
IamV2Source::authorize($session, 'batch'); $mapped = []; $targets = []; $caps = ['iam:batch']; $indexMap = []; $review = [];
IamV2Source::authorize($session, 'batch'); $mapped = []; $targets = []; $caps = ['iam:batch']; $indexMap = []; $review = []; $itemCaps = [];
foreach ($items as $index => $input) {
try {
$item = self::input($input); $id = (int) $item['externalAccountId']; $cap = self::cap($item['kind']);
IamV2Source::authorize($session, $cap, [$id]);
$indexMap[] = (int) $index; $mapped[] = $item; $targets[] = $id; $caps[] = 'iam:' . $cap;
$held = ['iam:' . $cap];
if ($item['kind'] === 'create_and_bind' && (($this->binding($id, true)['binding']['state'] ?? '') === 'revoked')) {
if (!isset($item['expectedVersion'])) { throw new RuntimeException('BINDING_CONFLICT'); }
IamV2Source::authorize($session, 'rebind', [$id]); $held[] = 'iam:rebind';
}
$indexMap[] = (int) $index; $mapped[] = $item; $targets[] = $id; $itemCaps[] = $held; $caps = array_merge($caps, $held);
} catch (RuntimeException $e) {
$review[] = ['index' => (int) $index, 'externalAccountId' => (string) ($input['user_id'] ?? ''),
'status' => $e->getMessage() === 'PROTECTED_ACCOUNT_REVIEW_REQUIRED' ? 'review' : 'invalid', 'message' => $e->getMessage()];
@@ -140,29 +172,60 @@ final class IamV2Manager
}
unset($item);
Db::name('iam_v2_resource')->where('resource_key', $resource)->update(['payload' => json_encode([
'targets' => $targets, 'capabilities' => $caps, 'indexMap' => $indexMap, 'ready' => $ready])]);
'targets' => $targets, 'capabilities' => $caps, 'indexMap' => $indexMap, 'ready' => $ready, 'itemCaps' => $itemCaps])]);
$r['items'] = array_merge($r['items'], $review);
usort($r['items'], fn($a, $b) => $a['index'] <=> $b['index']); return $r;
}
public function batch(array $session, string $preview, array $indexes, string $key): array
{
$scope = $this->owned('preview', $preview, $session); IamV2Source::authorize($session, 'batch', $scope['targets']);
foreach ($scope['capabilities'] as $cap) { IamV2Source::authorize($session, substr($cap, 4), $scope['targets']); }
$scope = $this->owned('preview', $preview, $session);
$indexes = array_values(array_unique(array_map('intval', $indexes)));
if (!$indexes || array_diff($indexes, $scope['ready'] ?? [])) { throw new RuntimeException('INVALID_INPUT'); }
$indexes = array_map(fn($i) => array_search($i, $scope['indexMap'], true), $indexes);
$targets = array_map(fn($i) => $scope['targets'][$i], $indexes); $caps = ['iam:batch']; $itemCaps = [];
foreach ($indexes as $i) { $itemCaps[$i] = $scope['itemCaps'][$i] ?? $scope['capabilities']; $caps = array_merge($caps, $itemCaps[$i]); }
$caps = array_values(array_unique($caps));
foreach ($caps as $cap) { IamV2Source::authorize($session, substr($cap, 4), $targets); }
$r = $this->client->call('POST', '/batch', ['previewId' => $preview, 'indexes' => $indexes,
'proof' => IamV2Source::proof($session, 'management', $scope['targets'], $scope['capabilities'])], $key);
$this->remember('job', (string) $r['id'], $session, $scope['targets'], $scope['capabilities']); return $r;
'proof' => IamV2Source::proof($session, 'management', $targets, $caps)], $key);
$this->remember('job', (string) $r['id'], $session, $targets, $caps);
Db::name('iam_v2_resource')->where('resource_key', hash('sha256', 'job:' . $r['id']))->update(['payload' => json_encode([
'targets' => $targets, 'capabilities' => $caps, 'itemCaps' => $itemCaps, 'centralIndexes' => $indexes])]);
return $r;
}
/** Public retry indexes are job.items positions, not original central preview indexes. */
public static function retrySelection(array $job, array $positions = []): array
{
$items = $job['items'] ?? []; $central = $job['indexes'] ?? [];
if ($positions === []) { $positions = array_keys($items); }
$indexes = []; $targets = []; $caps = ['iam:batch'];
foreach (array_unique($positions) as $position) {
if (!is_int($position) || !isset($items[$position], $central[$position])) { throw new RuntimeException('INVALID_INPUT'); }
$item = $items[$position];
if (!in_array($item['status'] ?? '', ['failed', 'pending'], true)) { continue; }
$indexes[] = $central[$position]; $targets[] = (int) $item['externalAccountId']; $caps[] = 'iam:' . self::cap($item['kind']);
}
return ['indexes' => $indexes, 'targets' => $targets, 'capabilities' => array_values(array_unique($caps))];
}
public function result(array $session, string $kind, string $id, bool $retry = false, array $indexes = []): array
{
$scope = $this->owned($kind, $id, $session); IamV2Source::authorize($session, 'read', [], false);
$path = ($kind === 'job' ? '/jobs/' : '/operations/') . rawurlencode($id);
if ($retry) {
foreach ($scope['capabilities'] as $cap) { IamV2Source::authorize($session, substr($cap, 4), $scope['targets']); }
$r = $this->client->call('POST', $path . '/retry', ['indexes' => $indexes, 'proof' => IamV2Source::proof($session, 'management', $scope['targets'], $scope['capabilities'])]);
$job = $this->client->call('GET', $path);
// The saved mapping prevents the central endpoint changing this operation's target set.
if (!isset($job['indexes'])) { $job['indexes'] = $scope['centralIndexes'] ?? []; }
$selected = self::retrySelection($job, $indexes);
if (!$selected['indexes']) { return $job; }
if (array_diff($selected['targets'], array_map('intval', $scope['targets']))) { throw new RuntimeException('FORBIDDEN'); }
foreach ($selected['indexes'] as $i) { $selected['capabilities'] = array_merge($selected['capabilities'], $scope['itemCaps'][$i] ?? []); }
$selected['capabilities'] = array_values(array_unique($selected['capabilities']));
foreach ($selected['capabilities'] as $cap) { IamV2Source::authorize($session, substr($cap, 4), $selected['targets']); }
$r = $this->client->call('POST', $path . '/retry', ['indexes' => $selected['indexes'],
'proof' => IamV2Source::proof($session, 'management', $selected['targets'], $selected['capabilities'])]);
$scope['targets'] = $selected['targets'];
} else { $r = $this->client->call('GET', $path); }
foreach ($scope['targets'] as $target) { $this->binding((int) $target, true); } return $r;
foreach ($scope['targets'] as $target) { $this->binding((int) $target, true); }
return $kind === 'operation' ? $this->reviewLink($r) : $r;
}
}
@@ -15,16 +15,18 @@ final class IamV2Source
public const COOKIE = 'ZYT_IAM_V2_SESSION';
public static function enroll(string $token, string $method, bool $cookie = true, ?int $authTime = null): array
{
if (!in_array($method, ['password', 'wecom', 'oidc'], true)) { throw new RuntimeException('REAUTH_REQUIRED'); }
if (!in_array($method, ['password', 'wecom', 'oidc', 'iam_launch'], true)) { throw new RuntimeException('REAUTH_REQUIRED'); }
if (in_array($method, ['oidc', 'iam_launch'], true) && $authTime !== null && ($authTime < 0 || $authTime > time() + 60)) { throw new RuntimeException('INVALID_AUTHENTICATION'); }
$legacy = Db::name('admin_session')->where('token', $token)->where('expire_time', '>', time())->find();
if (!$legacy) { throw new RuntimeException('REAUTH_REQUIRED'); }
$admin = self::admin((int) $legacy['admin_id']);
$old = (string) ($_COOKIE[self::COOKIE] ?? '');
if ($old !== '') { self::revokeSession($old); }
$binding = Db::name('iam_v2_binding')->where('admin_id', $legacy['admin_id'])->where('state', 'verified')->find();
$sid = bin2hex(random_bytes(32));
$row = ['session_id' => $sid, 'admin_id' => (int) $legacy['admin_id'], 'business_session_id' => $legacy['id'],
'token_hash' => hash('sha256', $token), 'account_stamp' => self::accountStamp($admin), 'auth_method' => $method, 'auth_time' => $method === 'oidc' ? max(0, min(time(), $authTime ?? 0)) : time(),
'version' => 1, 'revoked' => 0, 'expires_at' => min((int) $legacy['expire_time'], time() + 43200)];
'token_hash' => hash('sha256', $token), 'account_stamp' => self::accountStamp($admin), 'auth_method' => $method, 'auth_time' => in_array($method, ['oidc', 'iam_launch'], true) ? max(0, $authTime ?? 0) : time(),
'version' => 1, 'revoked' => 0, 'binding_version' => (int) ($binding['version'] ?? 0), 'employee_id' => $binding['employee_id'] ?? '', 'expires_at' => min((int) $legacy['expire_time'], time() + 43200)];
Db::name('iam_v2_source_session')->insert($row);
if ($cookie) {
setcookie(self::COOKIE, $sid, ['expires' => $row['expires_at'], 'path' => '/adminapi', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax']);
@@ -57,8 +59,36 @@ final class IamV2Source
if (!hash_equals($s['account_stamp'], self::accountStamp($admin))) { self::revokeSession($id); throw new RuntimeException('REAUTH_REQUIRED'); }
$legacy = Db::name('admin_session')->where('id', $s['business_session_id'])->find();
if (!$legacy || $legacy['expire_time'] <= time() || (int) $legacy['admin_id'] !== (int) $s['admin_id'] || !hash_equals($s['token_hash'], hash('sha256', $legacy['token']))) { throw new RuntimeException('REAUTH_REQUIRED'); }
if ((int) ($s['binding_version'] ?? 0) > 0) {
$binding = Db::name('iam_v2_binding')->where('admin_id', $s['admin_id'])->find();
if ($binding && ($binding['state'] === 'revoked' || (int) $binding['version'] > (int) $s['binding_version'] || $binding['employee_id'] !== $s['employee_id'])) {
self::revokeSession($id); throw new RuntimeException('REAUTH_REQUIRED');
}
}
return $s;
}
/** Service callback: local-only live authorization, safe while central holds its state lock. */
public static function liveStatus(string $id, int $version, string $purpose = '', array $targets = []): array
{
$s = self::check($id, $version);
$out = ['active' => true, 'actorLocalId' => (string) $s['admin_id'], 'sessionId' => $s['session_id'], 'sessionVersion' => (int) $s['version'],
'authMethod' => $s['auth_method'], 'authTime' => (int) $s['auth_time'], 'expiresAt' => (int) $s['expires_at']];
if ($purpose !== 'management') { return $out; }
if ($s['auth_time'] < time() - 300) { throw new RuntimeException('REAUTH_REQUIRED'); }
if (!$targets || count($targets) > 100) { throw new RuntimeException('INVALID_INPUT'); }
foreach ($targets as $target) {
if (!is_string($target) || !preg_match('/^[1-9][0-9]{0,9}$/D', $target)) { throw new RuntimeException('INVALID_INPUT'); }
}
$caps = self::capabilities((int) $s['admin_id']);
if (!$caps) { throw new RuntimeException('FORBIDDEN'); }
self::scope((int) $s['admin_id'], $targets);
$protected = false;
foreach ($targets as $target) { self::admin((int) $target); $protected = self::protectedAccount((int) $target) || $protected; }
$eligible = count($targets) === 1 && self::reviewOperator((int) $s['admin_id']);
if ($protected && !$eligible) { throw new RuntimeException('PROTECTED_ACCOUNT_REVIEW_REQUIRED'); }
return $out + ['purpose' => 'management', 'capabilities' => array_map(fn($c) => 'iam:' . $c, $caps),
'targetLocalIds' => $targets, 'protectedAccount' => $protected, 'protectedReviewEligible' => $eligible];
}
public static function reauth(string $token, string $password): array
{
$legacy = Db::name('admin_session')->where('token', $token)->where('expire_time', '>', time())->find();
@@ -80,13 +110,16 @@ final class IamV2Source
$perms = AuthLogic::getAuthByAdminId($id);
return array_values(array_filter($all, fn($p) => in_array('iamV2/' . $p, $perms, true)));
}
public static function protectedAccount(int $id): bool
public static function reviewOperator(int $id): bool
{
$a = self::admin($id);
if ((int) $a['root'] === 1) { return true; }
$roles = Db::name('admin_role')->where('admin_id', $id)->column('role_id');
if ($roles && Db::name('system_role')->whereIn('id', $roles)->whereIn('name', ['管理员', '系统管理员', '超级管理员'])->whereNull('delete_time')->count() > 0) { return true; }
return count(array_intersect(self::capabilities($id), ['bind','create','unbind','rebind','batch'])) > 0;
return $roles && Db::name('system_role')->whereIn('id', $roles)->whereIn('name', ['管理员', '系统管理员', '超级管理员'])->whereNull('delete_time')->count() > 0;
}
public static function protectedAccount(int $id): bool
{
return self::reviewOperator($id) || count(array_intersect(self::capabilities($id), ['bind','create','unbind','rebind','batch'])) > 0;
}
public static function visibleIds(int $id): ?array
{
@@ -99,15 +132,15 @@ final class IamV2Source
$visible = self::visibleIds($actor);
if ($visible !== null && array_diff(array_map('intval', $targets), $visible)) { throw new RuntimeException('FORBIDDEN'); }
}
public static function authorize(array $session, string $capability, array $targets = [], bool $recent = true): void
public static function authorize(array $session, string $capability, array $targets = [], bool $recent = true, bool $protectedReview = false): void
{
if (!in_array($capability, self::capabilities((int) $session['admin_id']), true)) { throw new RuntimeException('FORBIDDEN'); }
if ($recent && $session['auth_time'] < time() - 300) { throw new RuntimeException('REAUTH_REQUIRED'); }
self::scope((int) $session['admin_id'], $targets);
foreach ($targets as $id) {
$target = self::admin((int) $id);
// v1 deliberately fails closed: no dual-side central-admin reauth protocol yet.
if (self::protectedAccount((int) $id)) {
// Single-target high-privilege requests may reach central review, never bypass it.
if (self::protectedAccount((int) $id) && !($protectedReview && count($targets) === 1 && self::reviewOperator((int) $session['admin_id']))) {
throw new RuntimeException('PROTECTED_ACCOUNT_REVIEW_REQUIRED');
}
}
@@ -120,13 +153,15 @@ final class IamV2Source
'sessionId' => $session['session_id'], 'sessionVersion' => (int) $session['version'], 'authMethod' => $session['auth_method'],
'authTime' => (int) $session['auth_time'], 'expiresAt' => time() + 60, 'capabilities' => $capabilities,
'targetLocalIds' => array_map('strval', $targets), 'protectedAccount' => count(array_filter($targets, fn($id) => self::protectedAccount((int) $id))) > 0, 'transactionId' => $transaction];
$payload += array_intersect_key($extra, array_flip(['oidcNonce', 'state']));
$payload += array_intersect_key($extra, array_flip(['oidcNonce', 'state', 'protectedReviewEligible']));
Db::name('iam_v2_proof')->insert(['proof_hash' => hash('sha256', $secret), 'purpose' => $purpose, 'session_id' => $session['session_id'],
'payload' => json_encode($payload, JSON_THROW_ON_ERROR), 'expires_at' => time() + 60, 'consumed' => 0]);
return $secret;
}
public static function introspect(string $proof, string $purpose, ?string $transaction = null): array
{
// The central Go attestor serializes an absent transaction as an empty string.
$transaction = $transaction === '' ? null : $transaction;
return Db::transaction(function () use ($proof, $purpose, $transaction) {
$hash = hash('sha256', $proof);
$r = Db::name('iam_v2_proof')->where('proof_hash', $hash)->lock(true)->find();
@@ -134,10 +169,14 @@ final class IamV2Source
$p = json_decode($r['payload'], true, 32, JSON_THROW_ON_ERROR);
if (($p['transactionId'] ?? null) !== $transaction) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
$session = self::check($p['sessionId'], (int) $p['sessionVersion']);
$p['protectedReviewEligible'] = ($p['protectedReviewEligible'] ?? false) === true
&& count($p['targetLocalIds']) === 1 && self::reviewOperator((int) $session['admin_id']);
foreach ($p['capabilities'] as $cap) {
if (!str_starts_with($cap, 'iam:')) { throw new RuntimeException('FORBIDDEN'); }
self::authorize($session, substr($cap, 4), $p['targetLocalIds']);
self::authorize($session, substr($cap, 4), $p['targetLocalIds'], true, ($p['protectedReviewEligible'] ?? false) === true && count($p['targetLocalIds']) === 1);
}
// Target protection can change during the proof's short lifetime. Recompute it.
$p['protectedAccount'] = count(array_filter($p['targetLocalIds'], fn($id) => self::protectedAccount((int) $id))) > 0;
if (Db::name('iam_v2_proof')->where('proof_hash', $hash)->where('consumed', 0)->update(['consumed' => 1]) !== 1) { throw new RuntimeException('TRANSACTION_EXPIRED'); }
return ['active' => true] + $p;
});
@@ -146,6 +185,19 @@ final class IamV2Source
{
Db::name('iam_v2_source_session')->where('session_id', $id)->inc('version')->update(['revoked' => 1]);
}
public static function revokeGeneration(int $id, int $upper, ?string $employeeId = null): void
{
$query = Db::name('iam_v2_source_session')->where('admin_id', $id)->where('binding_version', '<=', $upper);
if ($employeeId !== null) { $query->where(function ($q) use ($employeeId) { $q->where('employee_id', $employeeId)->whereOr('binding_version', 0); }); }
$sessions = $query->select()->toArray();
foreach ($sessions as $s) { self::revokeSession($s['session_id']); }
foreach ($sessions as $s) {
$newer = Db::name('iam_v2_source_session')->where('token_hash', $s['token_hash'])->where('revoked', 0)->where('expires_at', '>', time())->find();
if ($newer) { continue; }
$legacy = Db::name('admin_session')->where('id', $s['business_session_id'])->find();
if ($legacy && hash_equals($s['token_hash'], hash('sha256', $legacy['token']))) { \app\adminapi\logic\auth\AdminLogic::expireToken($legacy['token']); }
}
}
public static function revokeAdmin(int $id): void
{
Db::name('iam_v2_source_session')->where('admin_id', $id)->where('revoked', 0)->inc('version')->update(['revoked' => 1]);
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace app\adminapi\service\iam\v2;
use RuntimeException;
use think\facade\Config;
use think\facade\Db;
use app\adminapi\logic\LoginLogic;
use app\adminapi\logic\auth\AdminLogic;
use app\adminapi\service\iam\{IamAccountProvisioner, IamOidcClient};
use app\common\enum\AdminTerminalEnum;
/** ZYT target accepts only the person's own IAM admin/portal source, never ERP. */
final class IamV2Target
{
public const COOKIE = 'ZYT_IAM_V2_TARGET';
private array $settings;
public function __construct(private ?IamV2Client $client = null, private $resolve = null, private $login = null)
{
$this->settings = (array) Config::get('iam');
$this->client ??= new IamV2Client();
$this->resolve ??= fn(array $result) => $this->resolveAdmin($result);
$this->login ??= function (int $id, array $authentication): array {
$admin = IamV2Source::admin($id);
$result = (array) (new LoginLogic())->login(['account' => $admin['account'], 'terminal' => AdminTerminalEnum::PC]);
IamV2Source::enroll($result['token'], 'iam_launch', true, self::authTime($authentication['authTime'] ?? null));
return $result;
};
}
public static function authTime($value): int
{
$time = is_int($value) ? $value : (is_string($value) ? strtotime($value) : false);
if (!$time || $time < 0 || $time > time() + 60) { throw new RuntimeException('INVALID_AUTHENTICATION'); }
return $time;
}
private function registered(string $source): string
{
$sources = $this->settings['v2_sources'] ?? [];
if (!in_array($source, ['iam-admin', 'iam-portal'], true) || !is_array($sources) || !isset($sources[$source])) { throw new RuntimeException('NOT_AUTHORIZED'); }
return IamV2Client::https((string) $sources[$source]);
}
public static function existing(string $token, string $sourceSession = ''): ?int
{
$id = null;
if ($token !== '') {
$legacy = Db::name('admin_session')->where('token', $token)->where('expire_time', '>', time())->find();
if (!$legacy) { throw new RuntimeException('REAUTH_REQUIRED'); }
IamV2Source::admin((int) $legacy['admin_id']); $id = (int) $legacy['admin_id'];
}
if ($sourceSession !== '') {
try { $session = IamV2Source::check($sourceSession); } catch (RuntimeException $e) { $session = null; }
if ($session) {
if ($id !== null && $id !== (int) $session['admin_id']) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
$id = (int) $session['admin_id'];
}
}
return $id;
}
public function prepare(string $source, string $intent, string $token, string $sourceSession = ''): array
{
$registered = $this->registered($source);
if (!preg_match('/^[A-Za-z0-9_-]{32,256}$/D', $intent)) { throw new RuntimeException('INVALID_REQUEST'); }
$existing = self::existing($token, $sourceSession);
$browser = bin2hex(random_bytes(32)); $state = bin2hex(random_bytes(32)); $verifier = bin2hex(random_bytes(32));
$tx = $this->client->call('POST', '/launch-transactions', ['sourceApplicationId' => $source, 'stateHash' => hash('sha256', $state),
'codeChallenge' => rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=')]);
$id = $tx['transactionId'] ?? '';
if (!is_string($id) || !preg_match('/^[A-Za-z0-9_-]{1,128}$/D', $id)) { throw new RuntimeException('INVALID_RESPONSE'); }
$actual = parse_url((string) ($tx['sourceContinueUrl'] ?? '')); $fixed = parse_url($registered);
if (!$actual || ($actual['scheme'] ?? '') !== $fixed['scheme'] || ($actual['host'] ?? '') !== $fixed['host']
|| ($actual['port'] ?? 443) !== ($fixed['port'] ?? 443) || ($actual['path'] ?? '') !== ($fixed['path'] ?? '')
|| isset($actual['user']) || isset($actual['pass']) || isset($actual['fragment'])) { throw new RuntimeException('INVALID_FIXED_URL'); }
parse_str($actual['query'] ?? '', $query);
if (count($query) !== 1 || ($query['transaction_id'] ?? null) !== $id) { throw new RuntimeException('INVALID_RESPONSE'); }
IamV2Flow::put('target_launch', $id, ['sessionId' => hash('sha256', $browser), 'source' => $source, 'verifier' => $verifier,
'stateHash' => hash('sha256', $state), 'expectedAdminId' => $existing]);
return ['url' => $registered . '?' . http_build_query(['transaction_id' => $id, 'intent' => $intent], '', '&', PHP_QUERY_RFC3986), 'browser' => $browser];
}
public function callback(string $transaction, string $grant, string $browser, string $origin): array
{
if (!preg_match('/^[a-f0-9]{64}$/D', $browser) || !preg_match('/^[A-Za-z0-9_-]{43}$/D', $grant)) { throw new RuntimeException('TRANSACTION_EXPIRED'); }
$flow = IamV2Flow::peek('target_launch', $transaction, hash('sha256', $browser));
$source = parse_url($this->registered($flow['source']));
$expectedOrigin = $source['scheme'] . '://' . $source['host'] . (isset($source['port']) ? ':' . $source['port'] : '');
if ($origin !== $expectedOrigin) { throw new RuntimeException('INVALID_ORIGIN'); }
IamV2Flow::take('target_launch', $transaction, hash('sha256', $browser));
$result = $this->client->call('POST', '/launch-grants/exchange', ['transactionId' => $transaction, 'grant' => $grant, 'codeVerifier' => $flow['verifier']]);
$employee = $result['employee'] ?? []; $auth = $result['authentication'] ?? [];
if (($employee['status'] ?? '') !== 'active' || !is_string($employee['oidcSubject'] ?? null) || $employee['oidcSubject'] === ''
|| empty($employee['id']) || !($result['access']['allowed'] ?? false) || ($auth['sourceApplicationId'] ?? '') !== $flow['source']
|| empty($auth['sessionId']) || !is_string($auth['authMethod'] ?? null) || $auth['authMethod'] === '' || !is_int($auth['sessionVersion'] ?? null) || $auth['sessionVersion'] < 1) { throw new RuntimeException('NOT_AUTHORIZED'); }
self::authTime($auth['authTime'] ?? null);
if ($flow['expectedAdminId'] !== null && (string) ($result['binding']['externalAccountId'] ?? '') !== (string) $flow['expectedAdminId']) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
$id = (int) ($this->resolve)($result);
if ($id < 1 || ($flow['expectedAdminId'] !== null && $id !== (int) $flow['expectedAdminId'])) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
IamV2Source::admin($id);
$current = (new IamV2Manager($this->client))->assertLinked($id);
if (($current['employee']['id'] ?? '') !== $employee['id'] || ($current['employee']['oidcSubject'] ?? '') !== $employee['oidcSubject']) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
$ticket = bin2hex(random_bytes(32));
IamV2Flow::put('target_ticket', $ticket, ['sessionId' => hash('sha256', $browser), 'adminId' => $id, 'expectedAdminId' => $flow['expectedAdminId'],
'employeeId' => $employee['id'], 'bindingVersion' => (int) $current['binding']['version'], 'source' => $flow['source'], 'authentication' => $auth], 60);
return ['ticket' => $ticket, 'source' => $flow['source']];
}
private function resolveAdmin(array $result): int
{
$employee = $result['employee']; $subject = $employee['oidcSubject'];
$client = new IamOidcClient($this->settings); $context = $client->provisioning($subject);
if (($context['employee']['id'] ?? '') !== $employee['id']) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
$binding = $result['binding'] ?? null;
if ($binding && (($binding['applicationId'] ?? '') !== 'zyt' || ($binding['state'] ?? '') !== 'verified'
|| (string) ($context['externalAccountId'] ?? '') !== (string) $binding['externalAccountId']
|| (int) ($context['bindingVersion'] ?? 0) !== (int) $binding['version'])) { throw new RuntimeException('BINDING_CONFLICT'); }
if (!$binding && ($context['externalAccountId'] ?? '') !== '') { throw new RuntimeException('BINDING_CONFLICT'); }
$id = (new IamAccountProvisioner())->resolve($client, $this->settings, $subject);
$current = (new IamV2Manager($this->client))->assertLinked($id);
if (($current['employee']['id'] ?? '') !== $employee['id'] || ($current['employee']['oidcSubject'] ?? '') !== $subject
|| ($binding && (int) $current['binding']['version'] !== (int) $binding['version'])) { throw new RuntimeException('BINDING_CONFLICT'); }
return $id;
}
public function exchange(string $ticket, string $browser, string $token, string $sourceSession = ''): array
{
$flow = IamV2Flow::peek('target_ticket', $ticket, hash('sha256', $browser));
$existing = self::existing($token, $sourceSession);
if (($flow['expectedAdminId'] !== null && $existing !== (int) $flow['expectedAdminId']) || ($existing !== null && $existing !== (int) $flow['adminId'])) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
$current = (new IamV2Manager($this->client))->assertLinked((int) $flow['adminId']);
if (($current['employee']['id'] ?? '') !== $flow['employeeId']) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
if ((int) $current['binding']['version'] !== (int) $flow['bindingVersion']) { throw new RuntimeException('BINDING_CONFLICT'); }
IamV2Flow::take('target_ticket', $ticket, hash('sha256', $browser));
$result = ($this->login)((int) $flow['adminId'], $flow['authentication']);
$hash = hash('sha256', $result['token']);
$values = ['token_hash' => $hash, 'admin_id' => (int) $flow['adminId'], 'employee_id' => $flow['employeeId'],
'source_application_id' => $flow['source'], 'source_binding_version' => (int) ($flow['authentication']['sourceBindingVersion'] ?? 0), 'checked_at' => time()];
$old = Db::name('iam_v2_target_session')->where('token_hash', $hash)->find();
if ($old) { Db::name('iam_v2_target_session')->where('token_hash', $hash)->update($values); }
else { Db::name('iam_v2_target_session')->insert($values); }
return $result;
}
public static function compensate(string $token): void
{
$hash = hash('sha256', $token); $row = Db::name('iam_v2_target_session')->where('token_hash', $hash)->find();
if (!$row || $row['checked_at'] > time() - 30) { return; }
$query = http_build_query(['employeeId' => $row['employee_id'], 'sourceApplicationId' => $row['source_application_id'],
'sourceBindingVersion' => (int) $row['source_binding_version']], '', '&', PHP_QUERY_RFC3986);
$check = (new IamV2Client())->call('GET', '/launch-authorizations/check?' . $query);
if (!($check['allowed'] ?? false)) {
Db::name('iam_v2_source_session')->where('token_hash', $hash)->where('revoked', 0)->inc('version')->update(['revoked' => 1]);
AdminLogic::expireToken($token); throw new RuntimeException('NOT_AUTHORIZED');
}
Db::name('iam_v2_target_session')->where('token_hash', $hash)->update(['checked_at' => time()]);
}
}
+3
View File
@@ -2,10 +2,13 @@
// Optional integration: disabled installations never touch IAM or its transaction store.
return [
'v2_sources' => json_decode((string) env('iam.v2_sources_json', '{}'), true) ?: [],
'v2_enabled' => env('iam.v2_enabled', false),
'v2_management_token' => env('iam.v2_management_token', ''),
'v2_token' => env('iam.v2_token', ''),
'v2_introspection_token' => env('iam.v2_introspection_token', ''),
'v2_event_secret' => env('iam.v2_event_secret', ''),
'erp_callback_url' => env('iam.erp_callback_url', ''),
'erp_url' => env('iam.erp_url', ''),
'portal_url' => env('iam.portal_url', ''),
'enabled' => env('iam.enabled', false),
@@ -1,4 +1,4 @@
-- Additive only. Preserve all six tables and the legacy iam_local_identity ledger on rollback.
-- Additive only. Preserve all seven tables and the legacy iam_local_identity ledger on rollback.
-- Apply with configured table prefix (default zyt_). No existing roles/accounts are changed.
CREATE TABLE IF NOT EXISTS zyt_iam_v2_source_session (
session_id CHAR(64) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS zyt_iam_v2_source_session (
account_stamp CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
auth_method VARCHAR(16) NOT NULL, auth_time INT UNSIGNED NOT NULL,
version INT UNSIGNED NOT NULL DEFAULT 1, revoked TINYINT NOT NULL DEFAULT 0,
binding_version BIGINT UNSIGNED NOT NULL DEFAULT 0, employee_id VARCHAR(128) NOT NULL DEFAULT '',
expires_at INT UNSIGNED NOT NULL, INDEX idx_admin (admin_id), INDEX idx_expiry (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS zyt_iam_v2_proof (
@@ -34,6 +35,13 @@ CREATE TABLE IF NOT EXISTS zyt_iam_v2_event (
event_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY, digest CHAR(64) NOT NULL, received_at INT UNSIGNED NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS zyt_iam_v2_target_session (
token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
admin_id INT UNSIGNED NOT NULL, employee_id VARCHAR(128) NOT NULL,
source_application_id VARCHAR(64) NOT NULL, source_binding_version BIGINT UNSIGNED NOT NULL DEFAULT 0,
checked_at INT UNSIGNED NOT NULL DEFAULT 0, INDEX idx_admin(admin_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
START TRANSACTION;
SET @iam_parent := (SELECT pid FROM zyt_system_menu WHERE component='permission/admin/index' LIMIT 1);
INSERT INTO zyt_system_menu (pid,type,name,icon,sort,perms,paths,component,selected,params,is_cache,is_show,is_disable,create_time,update_time)
+8 -1
View File
@@ -7,7 +7,7 @@ $controller = file_get_contents($root . '/app/adminapi/controller/IamV2Controlle
$source = file_get_contents($root . '/app/adminapi/service/iam/v2/IamV2Source.php');
$client = file_get_contents($root . '/app/adminapi/service/iam/v2/IamV2Client.php');
$config = file_get_contents($root . '/config/iam.php');
foreach (['proofs/introspect','sessions/check','events/revocation','operations','batch/preflight','batch','launch/start','self-link/start','password-setup'] as $route) {
foreach (['proofs/introspect','sessions/check','events/revocation','operations','batch/preflight','batch','launch/start','self-link/start','password-setup','launch/prepare','launch/callback','launch/exchange','launch/reset'] as $route) {
requireContract(str_contains($routes,"Route::post('iam/v2/".$route."'"), 'POST registration ' . $route);
}
requireContract(str_contains($controller, "METHOD_NOT_ALLOWED"), 'default auto-route verbs checked');
@@ -21,4 +21,11 @@ requireContract(str_contains($config,"'v2_enabled' => env('iam.v2_enabled', fals
requireContract(!str_contains($controller,"'token' =>"), 'no business token callback');
requireContract(str_contains($controller,"verifiedIdToken()") && str_contains($controller,"'oidcNonce' =>"), 'self-link independently verifiable OIDC token and nonce');
requireContract(!str_contains($controller,"request->post('employeeId'"), 'no browser chosen personal employee');
$callback = substr($controller, strpos($controller, 'public function checkSession()'), strpos($controller, 'public function launchStart()') - strpos($controller, 'public function checkSession()'));
requireContract(str_contains($callback, 'IamV2Source::liveStatus') && str_contains($source, "'sessionId' => \$s['session_id']") && str_contains($source, "'sessionVersion' => (int) \$s['version']"), 'live callback echoes actual SID/version');
requireContract(!str_contains($callback, 'IamV2Manager') && !str_contains($callback, 'IamV2Client'), 'source callback never recursively calls central under state lock');
$login = file_get_contents($root . '/app/adminapi/service/iam/IamLoginService.php');
requireContract(strpos($login, '->assertLinked(') < strpos($login, '$result = (array) (new LoginLogic())->login'), 'binding sync precedes new token issuance');
$provisioner = file_get_contents($root . '/app/adminapi/service/iam/IamAccountProvisioner.php');
requireContract(strpos($provisioner, 'return IamLoginService::boundAdminId') < strpos($provisioner, 'IAM_BINDING_REVOKED'), 'explicit central binding precedes local auto-create tombstone');
echo "PASS IamV2RouteContractTest: fixed POST callbacks, auto-route method gate, origin/service authentication, live proof authorization, target scope, dedicated TLS client, default-off, personal OIDC proof\n";
+78 -9
View File
@@ -2,6 +2,7 @@
/** Disposable SQLite only: no .env, provider calls or real identities. */
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
require dirname(__DIR__) . '/app/common.php';
use think\facade\Db;
use app\adminapi\service\iam\v2\{IamV2Source, IamV2Flow, IamV2Client, IamV2Manager, IamV2Events};
@@ -9,19 +10,21 @@ function ok(bool $x, string $m): void { if (!$x) { throw new RuntimeException('F
function denies(callable $fn, string $message): void { try { $fn(); } catch (RuntimeException $e) { ok($e->getMessage() === $message, $message . ' got ' . $e->getMessage()); return; } throw new RuntimeException('accepted ' . $message); }
$app = new think\App(dirname(__DIR__) . '/'); think\Container::setInstance($app);
$file = tempnam(sys_get_temp_dir(), 'zyt-iamv2-');
$cachePath = $file . '-cache/';
$app->config->set(['default'=>'file','stores'=>['file'=>['type'=>'File','path'=>$cachePath]]], 'cache');
$app->config->set(['default'=>'fixture','connections'=>['fixture'=>['type'=>'sqlite','database'=>$file,'prefix'=>'','fields_strict'=>true]]], 'database');
$manager = new think\DbManager(); $manager->setConfig($app->config->get('database')); $app->instance('think\Db', $manager);
$app->config->set(['data_scope'=>['enabled'=>false], 'unique_identification'=>'fixture'], 'project');
$app->config->set(['application_id'=>'zyt'], 'iam');
$app->config->set(['application_id'=>'zyt','portal_url'=>'https://iam.example.test','v2_enabled'=>true,'v2_introspection_token'=>str_repeat('c',32)], 'iam');
try {
foreach ([
'CREATE TABLE admin (id INTEGER PRIMARY KEY,account TEXT,name TEXT,password TEXT,disable INTEGER,root INTEGER,delete_time INTEGER)',
'CREATE TABLE admin (id INTEGER PRIMARY KEY,account TEXT,name TEXT,password TEXT,disable INTEGER,root INTEGER,multipoint_login INTEGER DEFAULT 1,delete_time INTEGER)',
'CREATE TABLE admin_session (id INTEGER PRIMARY KEY,admin_id INTEGER,terminal INTEGER,token TEXT,expire_time INTEGER,update_time INTEGER)',
'CREATE TABLE admin_role (admin_id INTEGER,role_id INTEGER)',
'CREATE TABLE system_role (id INTEGER PRIMARY KEY,name TEXT,delete_time INTEGER)',
'CREATE TABLE system_role_menu (role_id INTEGER,menu_id INTEGER)',
'CREATE TABLE system_menu (id INTEGER PRIMARY KEY,is_disable INTEGER,perms TEXT)',
'CREATE TABLE iam_v2_source_session (session_id TEXT PRIMARY KEY,admin_id INTEGER,business_session_id INTEGER,token_hash TEXT,account_stamp TEXT,auth_method TEXT,auth_time INTEGER,version INTEGER,revoked INTEGER,expires_at INTEGER)',
'CREATE TABLE iam_v2_source_session (session_id TEXT PRIMARY KEY,admin_id INTEGER,business_session_id INTEGER,token_hash TEXT,account_stamp TEXT,auth_method TEXT,auth_time INTEGER,version INTEGER,revoked INTEGER,binding_version INTEGER,employee_id TEXT,expires_at INTEGER)',
'CREATE TABLE iam_v2_proof (proof_hash TEXT PRIMARY KEY,purpose TEXT,session_id TEXT,payload TEXT,expires_at INTEGER,consumed INTEGER)',
'CREATE TABLE iam_v2_flow (flow_key TEXT PRIMARY KEY,kind TEXT,payload TEXT,expires_at INTEGER,consumed INTEGER)',
'CREATE TABLE iam_v2_binding (admin_id INTEGER PRIMARY KEY,employee_id TEXT,subject TEXT,state TEXT,version INTEGER,payload TEXT,checked_at INTEGER)',
@@ -56,19 +59,40 @@ try {
Db::name('admin')->where('id',1)->update(['disable'=>1]); denies(fn()=>IamV2Source::check($s1['session_id']),'ACCOUNT_DISABLED'); Db::name('admin')->where('id',1)->update(['disable'=>0]);
Db::name('admin_session')->where('id',1)->update(['token'=>'rotated']); denies(fn()=>IamV2Source::check($s1['session_id']),'REAUTH_REQUIRED'); Db::name('admin_session')->where('id',1)->update(['token'=>'fixture-token-1']);
IamV2Source::revokeSession($s1['session_id']); denies(fn()=>IamV2Source::check($s1['session_id']),'REAUTH_REQUIRED'); ok(IamV2Source::check($s2['session_id'])['revoked']===0,'revoking one browser preserves other independent session');
$calls=[];$cfg=['v2_enabled'=>true,'api_url'=>'https://iam.example.test/api','public_url'=>'https://zyt.example.test','erp_url'=>'https://erp.example.test','v2_token'=>str_repeat('v',32),'application_id'=>'zyt'];
$calls=[];$cfg=['v2_enabled'=>true,'api_url'=>'https://iam.example.test/api','public_url'=>'https://zyt.example.test','erp_url'=>'https://erp.example.test','v2_token'=>str_repeat('v',32),'v2_management_token'=>str_repeat('m',32),'application_id'=>'zyt'];
$client=new IamV2Client($cfg,function($method,$url,$headers,$body)use(&$calls){$calls[]=compact('method','url','headers','body');return ['status'=>200,'body'=>json_encode(['binding'=>null,'employee'=>null,'access'=>['allowed'=>false]])];});
$service=new IamV2Manager($client);$service->binding(2);$service->binding(2);ok(count($calls)===1,'30 second read cache');
Db::name('iam_v2_binding')->where('admin_id',2)->update(['checked_at'=>time()-31]);$service->binding(2);ok(count($calls)===2,'missed revocation compensation after 30 seconds');
$binding=['binding'=>['state'=>'verified','version'=>2,'externalAccountId'=>'2'],'employee'=>['id'=>'employee-fixture','status'=>'active','oidcSubject'=>'subject-fixture'],'access'=>['allowed'=>true]];
$invokeCallback=function($action,$body,$bearer=null,$method='POST')use($app){
$request=(new think\Request())->withHeader(['authorization'=>$bearer??('Bearer '.str_repeat('c',32))])->withPost($body);
$request->setMethod($method);$request->setAction($action);$request->setController('IamV2');$app->instance('request',$request);
return (new app\adminapi\controller\IamV2Controller($app))->{$action}();
};
$live=$invokeCallback('checkSession',['sessionId'=>$s2['session_id'],'sessionVersion'=>1]);$liveBody=$live->getData();
ok($live->getCode()===200 && $liveBody['active'] && $liveBody['sessionId']===$s2['session_id'] && $liveBody['sessionVersion']===1,'actual source callback raw HTTP shape/SID/version');
ok(!isset($liveBody['code']) && !isset($liveBody['data']),'callback is not business response envelope');
$managed=$invokeCallback('checkSession',['sessionId'=>$s2['session_id'],'sessionVersion'=>1,'purpose'=>'management','targetLocalIds'=>['2']]);
ok($managed->getData()['targetLocalIds']===['2'] && in_array('iam:bind',$managed->getData()['capabilities'],true),'live callback returns current management capabilities and scope');
$invalid=$invokeCallback('checkSession',['sessionId'=>$s2['session_id'],'sessionVersion'=>1],'Bearer wrong');ok($invalid->getCode()===403 && !$invalid->getData()['active'],'callback dedicated credential required');
$wireHTTP=IamV2Source::proof($s2,'management',[2],['iam:bind']);
$actual=$invokeCallback('introspect',['proof'=>$wireHTTP,'purpose'=>'management','transactionId'=>'']);ok($actual->getCode()===200 && $actual->getData()['active'],'Go HTTP attestor body reaches actual controller');
$replay=$invokeCallback('introspect',['proof'=>$wireHTTP,'purpose'=>'management','transactionId'=>'']);ok($replay->getCode()===403,'actual callback replay fails');
$wire=IamV2Source::proof($s2,'management',[2],['iam:bind']);
ok(IamV2Source::introspect($wire,'management','')['active'],'Go callback empty transactionId matches absent transaction');
ok(IamV2Manager::cap('reconcile')==='bind','central reconcile capability contract');
$normalSession=IamV2Source::enroll('fixture-token-2','password',false);
$binding=['binding'=>['applicationId'=>'zyt','employeeId'=>'employee-fixture','state'=>'verified','version'=>2,'externalAccountId'=>'2'],'employee'=>['id'=>'employee-fixture','status'=>'active','oidcSubject'=>'subject-fixture'],'access'=>['allowed'=>true]];
$firstLinkService=new IamV2Manager(new IamV2Client($cfg,fn()=>['status'=>200,'body'=>json_encode($binding)]));
$firstLinkService->binding(2,true);
ok(IamV2Source::check($normalSession['session_id'])['revoked']===0,'first association preserves authenticated local session');
Db::name('iam_v2_binding')->where('admin_id',2)->update(['employee_id'=>'employee-fixture','state'=>'verified','version'=>2,'payload'=>json_encode($binding)]);
$secret=str_repeat('e',32);$revoked=[];
$event=['id'=>'evt-fixture','employeeId'=>'employee-fixture','applicationIds'=>['zyt'],'reason'=>'revoked','occurredAt'=>gmdate('Y-m-d\TH:i:s\Z'),'bindingVersion'=>1];
$event=['eventType'=>'binding_revoked','id'=>'evt-fixture','employeeId'=>'employee-fixture','applicationIds'=>['zyt'],'reason'=>'revoked','occurredAt'=>gmdate('Y-m-d\TH:i:s\Z'),'bindingVersion'=>1];
$raw=json_encode($event);$sig='sha256='.hash_hmac('sha256',$raw,$secret);
denies(fn()=>IamV2Events::accept($raw,'invalid','evt-fixture',$secret),'FORBIDDEN');
denies(fn()=>IamV2Events::accept($raw,$sig,'another-event',$secret),'INVALID_EVENT');
$revoke=function($id)use(&$revoked){$revoked[]=$id;};
IamV2Events::accept($raw,$sig,'evt-fixture',$secret,$revoke);ok($revoked===[],'older event cannot revoke newer binding');
IamV2Events::accept($raw,$sig,'evt-fixture',$secret,$revoke,null,null,fn($id,$version)=>null);ok($revoked===[],'older event cannot revoke newer binding');
ok(IamV2Events::accept($raw,$sig,'evt-fixture',$secret,$revoke)['duplicate'],'signed event idempotence');
$event['id']='evt-new';$event['bindingVersion']=3;$raw=json_encode($event);$sig='sha256='.hash_hmac('sha256',$raw,$secret);
IamV2Events::accept($raw,$sig,'evt-new',$secret,$revoke);ok($revoked===[2],'valid event revokes exact identity');
@@ -77,6 +101,15 @@ try {
$binding['binding']['version']=3;
$stale=new IamV2Manager(new IamV2Client($cfg,fn()=>['status'=>200,'body'=>json_encode($binding)]));
denies(fn()=>$stale->binding(2,true),'BINDING_CONFLICT');
Db::name('iam_v2_binding')->where('admin_id',2)->update(['state'=>'verified','version'=>3]);
$statusEvent=['id'=>'evt-disabled','employeeId'=>'employee-fixture','applicationIds'=>['zyt'],'reason'=>'employee.disabled','occurredAt'=>gmdate('Y-m-d\TH:i:s\Z')];
$statusRaw=json_encode($statusEvent);IamV2Events::accept($statusRaw,'sha256='.hash_hmac('sha256',$statusRaw,$secret),'evt-disabled',$secret,$revoke,fn()=>['binding'=>['state'=>'verified'],'employee'=>['status'=>'disabled'],'access'=>['allowed'=>false]]);
ok(Db::name('iam_v2_binding')->where('admin_id',2)->value('state')==='verified','personnel/access revoke never creates permanent binding tombstone');
$stale->binding(2,true); // restored access at same binding version is recoverable.
$late=$statusEvent;$late['id']='evt-delayed';$late['occurredAt']=gmdate('Y-m-d\TH:i:s\Z',time()-86400);$lateRaw=json_encode($late);$revocationsBefore=count($revoked);
IamV2Events::accept($lateRaw,'sha256='.hash_hmac('sha256',$lateRaw,$secret),'evt-delayed',$secret,$revoke,fn()=>['binding'=>['state'=>'verified'],'employee'=>['status'=>'active'],'access'=>['allowed'=>true]]);
ok(count($revoked)===$revocationsBefore,'old durable generic event accepted without revoking reauthorized sessions');
$beforeUsers=Db::name('admin')->select()->toArray();$batchCalls=[];
$batchClient=new IamV2Client($cfg,function($method,$url,$headers,$body)use(&$batchCalls){
$data=json_decode($body,true);$batchCalls[]=$data;
@@ -95,10 +128,46 @@ try {
ok(Db::name('admin')->select()->toArray()===$beforeUsers,'preflight/job orchestration never changes local accounts/roles');
denies(fn()=>IamV2Manager::input(['kind'=>'rebind','user_id'=>2,'reason'=>'fixture']),'BINDING_CONFLICT');
denies(fn()=>IamV2Manager::input(['kind'=>'create_and_bind','user_id'=>2,'reason'=>'fixture','new_employee'=>['username'=>'invalid /','display_name'=>'Name']]),'INVALID_INPUT');
$newerSession=IamV2Source::enroll('fixture-token-2','password',false);
IamV2Source::revokeGeneration(2,2);
ok(IamV2Source::check($newerSession['session_id'])['binding_version']===3,'late older binding generation preserves newer source SID and shared business token');
$normalSession=$newerSession;
$reviewClient=new IamV2Client($cfg,function($method,$url,$headers,$body){
if($method==='GET')return ['status'=>200,'body'=>json_encode(['binding'=>null,'employee'=>null,'access'=>['allowed'=>false]])];
$data=json_decode($body,true);$claim=IamV2Source::introspect($data['proof'],'management','');
ok($claim['protectedAccount']===true,'single protected source request remains marked for central review');
return ['status'=>200,'body'=>json_encode(['id'=>'review-fixture','status'=>'pending','error'=>['code'=>'PROTECTED_ACCOUNT_REVIEW_REQUIRED'],'reviewUrl'=>'https://iam.example.test/admin/iam/review?request=review-fixture'])];
});
$review=(new IamV2Manager($reviewClient))->operation($s2,['kind'=>'bind','user_id'=>1,'employee_id'=>'employee-admin','reason'=>'verified fixture'],'stable-review-key');
ok($review['status']==='pending' && isset($review['reviewUrl']),'root single operation reaches central pending review, not completion');
$raceProof=IamV2Source::proof($s2,'management',[2],['iam:bind'],null,['protectedReviewEligible'=>true]);
Db::name('system_menu')->insert(['id'=>7,'is_disable'=>0,'perms'=>'iamV2/bind']);
Db::name('system_role_menu')->insert(['role_id'=>7,'menu_id'=>7]);Db::name('admin_role')->insert(['admin_id'=>2,'role_id'=>7]);
ok(IamV2Source::introspect($raceProof,'management','')['protectedAccount']===true,'protection recomputed at callback after target privilege change');
denies(fn()=>IamV2Source::authorize($normalSession,'bind',[1],true,true),'PROTECTED_ACCOUNT_REVIEW_REQUIRED');
denies(fn()=>IamV2Source::liveStatus($normalSession['session_id'],1,'management',['1']),'PROTECTED_ACCOUNT_REVIEW_REQUIRED');
Db::name('system_role_menu')->where('role_id',7)->delete();
denies(fn()=>IamV2Source::liveStatus($normalSession['session_id'],1,'management',['2']),'FORBIDDEN');
Db::name('admin')->where('id',1)->update(['password'=>'new-credential']);
denies(fn()=>IamV2Source::check($s2['session_id']),'REAUTH_REQUIRED');
ok((int) Db::name('iam_v2_source_session')->where('session_id',$s2['session_id'])->value('version')===2,'credential change increments source revocation version');
$managementMissing=$cfg;unset($managementMissing['v2_management_token']);
denies(fn()=>(new IamV2Client($managementMissing))->call('GET','/identities'),'SERVICE_UNAVAILABLE');
$credentialCalls=[];$credentials=new IamV2Client($cfg,function($method,$url,$headers,$body)use(&$credentialCalls){$credentialCalls[]=$headers;return ['status'=>200,'body'=>'{}'];});
$credentials->call('GET','/identities');$credentials->call('GET','/bindings/by-external/2');
ok(in_array('Authorization: Bearer '.str_repeat('m',32),$credentialCalls[0],true) && in_array('Authorization: Bearer '.str_repeat('v',32),$credentialCalls[1],true),'management and login credentials strictly separated');
Db::name('iam_v2_binding')->where('admin_id',2)->delete();
$missing=$statusEvent;$missing['id']='evt-history';$missing['eventType']='binding_revoked';$missing['bindingVersion']=3;$missing['externalAccountId']='2';$missingRaw=json_encode($missing);$generations=[];
$refreshHistory=function($id){$row=['admin_id'=>$id,'employee_id'=>'new-employee','subject'=>'new-subject','state'=>'verified','version'=>4,'payload'=>'{}','checked_at'=>time()];Db::name('iam_v2_binding')->insert($row);return ['binding'=>['state'=>'verified','version'=>4],'employee'=>['id'=>'new-employee','status'=>'active'],'access'=>['allowed'=>true]];};
IamV2Events::accept($missingRaw,'sha256='.hash_hmac('sha256',$missingRaw,$secret),'evt-history',$secret,$revoke,$refreshHistory,fn($event)=>[(int)$event['externalAccountId']],function($id,$upper)use(&$generations){$generations[]=[$id,$upper];});
ok($generations===[[2,3]] && Db::name('iam_v2_binding')->where('admin_id',2)->value('state')==='verified','historical unmapped rebind locates external ID without overwriting new generation');
Db::name('admin')->insert(['id'=>4,'account'=>'old-generation','name'=>'Old generation','password'=>'fixture','disable'=>0,'root'=>0]);
Db::name('admin_session')->insert(['id'=>4,'admin_id'=>4,'terminal'=>1,'token'=>'old-generation-token','expire_time'=>time()+3600]);
$oldGeneration=IamV2Source::enroll('old-generation-token','password',false);
IamV2Source::revokeGeneration(4,1,'old-employee');
ok((int) Db::name('admin_session')->where('id',4)->value('expire_time')<=time(),'old generation without newer holder expires actual business session row');
$legacy=$cfg; unset($legacy['v2_token']); $legacy['application_token']=str_repeat('l',32); denies(fn()=>new IamV2Client($legacy),'SERVICE_UNAVAILABLE');
denies(fn()=>IamV2Client::https('https://iam.example.test/path?override=1'),'INVALID_FIXED_URL');
} finally { @unlink($file); }
echo "PASS IamV2SourceTest: independent browser SID, legacy reauth, live DB, scope, protected accounts, single-use proof/intent, expiry, immutable authTime, disabled/rotated/revoked sessions, 30s compensation, signed event replay/digest/future/version, revoked tombstone, batch protected filtering/index mapping, selected-only execution, no local account mutation, credential-change revocation\n";
} finally { @unlink($file); if (is_dir($cachePath)) { foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($cachePath, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $path) { $path->isDir() ? rmdir($path->getPathname()) : unlink($path->getPathname()); } rmdir($cachePath); } }
echo "PASS IamV2SourceTest: independent browser SID, legacy reauth, live DB, scope, protected accounts, single-use proof/intent, expiry, immutable authTime, disabled/rotated/revoked sessions, 30s compensation, signed event replay/digest/future/version, revoked tombstone, batch protected filtering/index mapping, selected-only execution, no local account mutation, credential-change revocation, central empty-tx/reconcile contract, first-link/session preservation, access restoration, protected single-review/ordinary denial/live target protection, separate credentials, durable events, historical mapping, generation-specific business expiry\n";
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/app/common.php';
use think\facade\Db;
use app\adminapi\service\iam\v2\{IamV2Client,IamV2Target,IamV2Source,IamV2Manager};
function targetOK(bool $ok,string $message):void { if(!$ok)throw new RuntimeException('FAIL '.$message); }
function targetReject(callable $fn,string $code):void { try{$fn();}catch(RuntimeException $e){targetOK($e->getMessage()===$code,$code.' got '.$e->getMessage());return;}throw new RuntimeException('accepted '.$code); }
$app=new think\App(dirname(__DIR__).'/');think\Container::setInstance($app);$file=tempnam(sys_get_temp_dir(),'zyt-target-');
$app->config->set(['default'=>'fixture','connections'=>['fixture'=>['type'=>'sqlite','database'=>$file,'prefix'=>'','fields_strict'=>true]]],'database');
$db=new think\DbManager();$db->setConfig($app->config->get('database'));$app->instance('think\Db',$db);
$config=['v2_enabled'=>true,'api_url'=>'https://iam.example.test/api','public_url'=>'https://zyt.example.test','erp_url'=>'https://erp.example.test','application_id'=>'zyt','v2_token'=>str_repeat('v',32),
'v2_sources'=>['iam-admin'=>'https://iam.example.test/admin/iam/launch/continue','iam-portal'=>'https://iam.example.test/portal/launch/continue']];$app->config->set($config,'iam');
try {
foreach([
'CREATE TABLE admin (id INTEGER PRIMARY KEY,account TEXT,name TEXT,password TEXT,disable INTEGER,root INTEGER,delete_time INTEGER)',
'CREATE TABLE admin_session (id INTEGER PRIMARY KEY,admin_id INTEGER,token TEXT,expire_time INTEGER)',
'CREATE TABLE iam_v2_source_session (session_id TEXT PRIMARY KEY,admin_id INTEGER,business_session_id INTEGER,token_hash TEXT,account_stamp TEXT,auth_method TEXT,auth_time INTEGER,version INTEGER,revoked INTEGER,binding_version INTEGER,employee_id TEXT,expires_at INTEGER)',
'CREATE TABLE iam_v2_flow (flow_key TEXT PRIMARY KEY,kind TEXT,payload TEXT,expires_at INTEGER,consumed INTEGER)',
'CREATE TABLE iam_v2_binding (admin_id INTEGER PRIMARY KEY,employee_id TEXT,subject TEXT,state TEXT,version INTEGER,payload TEXT,checked_at INTEGER)',
'CREATE TABLE iam_v2_target_session (token_hash TEXT PRIMARY KEY,admin_id INTEGER,employee_id TEXT,source_application_id TEXT,source_binding_version INTEGER,checked_at INTEGER)'
]as$sql)Db::execute($sql);
Db::name('admin')->insertAll([['id'=>1,'account'=>'other','name'=>'Other','password'=>'synthetic','disable'=>0,'root'=>0],['id'=>2,'account'=>'employee','name'=>'Employee','password'=>'synthetic','disable'=>0,'root'=>0]]);
Db::name('admin_session')->insertAll([['id'=>1,'admin_id'=>1,'token'=>'token-other','expire_time'=>time()+3600],['id'=>2,'admin_id'=>2,'token'=>'token-matching','expire_time'=>time()+3600]]);
$count=0;$tx=[];$evil=false;$exchangeCalls=0;$resolveCalls=0;$loginCalls=0;$source='iam-admin';$authTime=time()-20;
$employee=['id'=>'emp-fixture','status'=>'active','oidcSubject'=>'subject-fixture'];$binding=['id'=>'binding','applicationId'=>'zyt','externalAccountId'=>'2','employeeId'=>'emp-fixture','state'=>'verified','version'=>1];
$client=new IamV2Client($config,function($method,$url,$headers,$body)use(&$count,&$tx,&$evil,&$exchangeCalls,&$source,$employee,$binding,$authTime){
$b=json_decode($body,true);
if(str_ends_with($url,'/launch-transactions')){$id='tx-'.(++$count);$source=$b['sourceApplicationId'];$tx[$id]=$b;return ['status'=>200,'body'=>json_encode(['transactionId'=>$id,'sourceContinueUrl'=>($evil?'https://evil.example.test': 'https://iam.example.test').($source==='iam-admin'?'/admin/iam/launch/continue':'/portal/launch/continue').'?transaction_id='.$id,'expiresAt'=>gmdate(DATE_RFC3339,time()+300)])];}
if(str_ends_with($url,'/launch-grants/exchange')){$exchangeCalls++;targetOK(rtrim(strtr(base64_encode(hash('sha256',$b['codeVerifier'],true)),'+/','-_'),'=')===$tx[$b['transactionId']]['codeChallenge'],'target PKCE is consistent');return ['status'=>200,'body'=>json_encode(['employee'=>$employee,'binding'=>$binding,'access'=>['allowed'=>true],'authentication'=>['sourceApplicationId'=>$source,'sessionId'=>'native-source-session','sessionVersion'=>1,'authMethod'=>'password','authTime'=>gmdate(DATE_RFC3339,$authTime),'sourceBindingVersion'=>0]])];}
if(str_contains($url,'/bindings/by-external/'))return ['status'=>200,'body'=>json_encode(['employee'=>$employee,'binding'=>$binding,'access'=>['allowed'=>true]])];
throw new RuntimeException('UNEXPECTED_NETWORK');
});
$target=new IamV2Target($client,function($result)use(&$resolveCalls){$resolveCalls++;return 2;},function($id,$auth)use(&$loginCalls){$loginCalls++;IamV2Source::enroll('token-matching','iam_launch',false,IamV2Target::authTime($auth['authTime']));return ['token'=>'token-matching','is_paw'=>1,'need_bind_work_wechat'=>false];});
$intent=str_repeat('i',43);targetReject(fn()=>$target->prepare('zmxy-erp',$intent,''),'NOT_AUTHORIZED');targetReject(fn()=>$target->prepare('iam-admin',$intent,'invalid'),'REAUTH_REQUIRED');
$evil=true;targetReject(fn()=>$target->prepare('iam-admin',$intent,''),'INVALID_FIXED_URL');$evil=false;
$start=$target->prepare('iam-admin',$intent,'');parse_str(parse_url($start['url'],PHP_URL_QUERY),$q);targetOK($q['intent']===$intent,'intent preserved in fixed continuation');$id=$q['transaction_id'];
targetReject(fn()=>$target->callback($id,str_repeat('g',43),str_repeat('f',64),'https://iam.example.test'),'ACCOUNT_MISMATCH');
targetReject(fn()=>$target->callback($id,str_repeat('g',43),$start['browser'],'https://evil.example.test'),'INVALID_ORIGIN');
$ticket=$target->callback($id,str_repeat('g',43),$start['browser'],'https://iam.example.test');targetOK($resolveCalls===1,'same-identity resolver called once');
targetReject(fn()=>$target->callback($id,str_repeat('g',43),$start['browser'],'https://iam.example.test'),'TRANSACTION_EXPIRED');
targetReject(fn()=>$target->exchange($ticket['ticket'],$start['browser'],'token-other'),'ACCOUNT_MISMATCH');targetOK($loginCalls===0,'midflow account change does not issue token');
$session=IamV2Source::enroll('token-other','password',false);
targetReject(fn()=>$target->exchange($ticket['ticket'],$start['browser'],'',$session['session_id']),'ACCOUNT_MISMATCH');
$result=$target->exchange($ticket['ticket'],$start['browser'],'token-matching');targetOK($result['token']==='token-matching'&&$loginCalls===1,'IAM local admin grant without KC cookie establishes target login');
$local=Db::name('iam_v2_source_session')->where('auth_method','iam_launch')->find();targetOK((int)$local['auth_time']===$authTime,'original RFC3339 authentication age preserved');
targetReject(fn()=>$target->exchange($ticket['ticket'],$start['browser'],'token-matching'),'TRANSACTION_EXPIRED');
$other=$target->prepare('iam-portal',$intent,'token-other');parse_str(parse_url($other['url'],PHP_URL_QUERY),$q);
targetReject(fn()=>$target->callback($q['transaction_id'],str_repeat('g',43),$other['browser'],'https://iam.example.test'),'ACCOUNT_MISMATCH');targetOK($resolveCalls===1,'preexisting mismatch rejects before local auto-provision');
$selection=IamV2Manager::retrySelection(['indexes'=>[0,2],'items'=>[['status'=>'succeeded','externalAccountId'=>'1','kind'=>'bind'],['status'=>'failed','externalAccountId'=>'2','kind'=>'bind']]],[1]);
targetOK($selection['indexes']===[2] && $selection['targets']===[2],'job item position translated to original noncontiguous preview index');
targetReject(fn()=>IamV2Target::authTime(gmdate(DATE_RFC3339,time()+120)),'INVALID_AUTHENTICATION');
echo "PASS IamV2TargetTest: native IAM admin/portal to ZYT without KC cookie, strict source allowlist, fixed continuation+intent, PKCE, browser/origin/replay checks, existing and midflow account mismatch, no duplicate provisioning, RFC3339 age, target lineage, noncontiguous retry index\n";
} finally {@unlink($file);}
+22 -2
View File
@@ -4,7 +4,7 @@ import vm from 'node:vm'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url), ts = require('typescript'), sfc = require('@vue/compiler-sfc')
const root = new URL('../../',import.meta.url)
const files=['admin/src/components/iam-v2/launch.vue','admin/src/components/iam-v2/personal.vue','admin/src/views/permission/identity/index.vue']
const files=['admin/src/components/iam-v2/launch.vue','admin/src/components/iam-v2/personal.vue','admin/src/views/permission/identity/index.vue','admin/src/views/account/iam-launch.vue']
for(const file of files){
const source=fs.readFileSync(new URL(file,root),'utf8'), {descriptor,errors}=sfc.parse(source,{filename:file})
assert.deepEqual(errors,[])
@@ -28,4 +28,24 @@ assert.match(management,/核对本地账号/);assert.match(management,/核对 IA
assert.match(management,/row.status === 'ready'/);assert.match(management,/batchConfirmed/);assert.match(management,/operationKey/)
assert.match(management,/查询同一操作状态/);assert.match(management,/只重试失败\/待处理项/)
const api=fs.readFileSync(new URL('admin/src/api/iam-v2.ts',root),'utf8');assert.match(api,/isOpenRetry: false/);assert.match(api,/Idempotency-Key/)
console.log('PASS IamV2UiTest: 3 Vue components compile; default-off, explicit new tab, opener isolation, source-only POST, error closes tab, three-step confirm, batch ready-only, stable operation key')
assert.match(management,/中央复核/);assert.match(management,/noopener noreferrer/);assert.match(management,/submittedPayload/)
const managerScript=management.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
const managerJS=ts.transpileModule(managerScript,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText.replace(/^.*require\(.+\);?$/gm,'').replace(/^Object.defineProperty\(exports.*$/gm,'')
const sent=[],ref=value=>({value}),managerContext={exports:{},console,crypto:{randomUUID:()=> 'stable-fixture-key'},vue_1:{ref,reactive:v=>v,computed:fn=>({get value(){return fn()}}),onMounted(){}},element_plus_1:{ElMessage:{success(){}}},iam_v2_1:{iamGet:async()=>({items:[],total:0}),identityError:()=> 'error',iamPost:async(path,body,key)=>{sent.push({body:JSON.stringify(body),key});return{id:'review',status:'pending',reviewUrl:'https://iam.example.test/admin/iam/review?request=review'}}}}
vm.createContext(managerContext);vm.runInContext(managerJS,managerContext)
vm.runInContext("operation.user_id=1; operation.employee_id='employee'; operation.reason='original reason'",managerContext)
await vm.runInContext('submit()',managerContext);vm.runInContext("operation.reason='changed after review'",managerContext);await vm.runInContext('submit()',managerContext)
assert.equal(sent[0].body,sent[1].body);assert.equal(sent[0].key,sent[1].key)
const targetSource=fs.readFileSync(new URL(files[3],root),'utf8'),targetScript=targetSource.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
const targetJS=ts.transpileModule(targetScript,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText.replace(/^.*require\(.+\);?$/gm,'').replace(/^Object.defineProperty\(exports.*$/gm,'')
async function targetScenario(query,loginResult={is_paw:1}){
const events=[];let mounted
const c={exports:{},console,vue_1:{ref,onMounted:fn=>mounted=fn},vue_router_1:{useRoute:()=>({query}),useRouter:()=>({replace:async path=>events.push(['route',path])})},iam_v2_1:{identityError:e=>e.error.code,iamGet:async()=>{events.push(['config']);return{enabled:true,restart_url:'https://iam.example.test/admin'}},iamPost:async(path,body)=>{events.push(['post',path,body]);return{url:'https://iam.example.test/admin/v1/iam/v2/launch/continue?transaction_id=fixture&intent=opaque'}}},user_1:{default:()=>({iamLaunchLogin:async ticket=>{events.push(['login',ticket]);return loginResult}})},auth_1:{clearAuthInfo:()=>events.push(['clear'])},feedback_1:{default:{confirm:async()=>true}},window:{location:{pathname:'/admin/iam/launch',assign:url=>events.push(['assign',url])},history:{state:{},replaceState:()=>events.push(['clean'])}}}
vm.createContext(c);vm.runInContext(targetJS,c);await mounted();return{events,c}
}
let targetFlow=await targetScenario({ticket:'one-use',source:'iam-admin'},{is_paw:0});assert.equal(targetFlow.events[0][0],'clean');assert.ok(targetFlow.events.some(x=>x[0]==='route'&&x[1]==='/change-password'))
targetFlow=await targetScenario({ticket:'one-use',source:'iam-portal'},{is_paw:1,need_bind_work_wechat:true});assert.ok(targetFlow.events.some(x=>x[0]==='route'&&x[1]==='/bind-work-wechat'))
targetFlow=await targetScenario({source:'iam-admin',intent:'i'.repeat(43)});assert.ok(targetFlow.events.some(x=>x[0]==='post'&&x[1]==='/launch/prepare'));assert.ok(targetFlow.events.some(x=>x[0]==='assign'))
targetFlow=await targetScenario({source:'zmxy-erp',intent:'i'.repeat(43)});assert.ok(!targetFlow.events.some(x=>x[0]==='post'))
targetFlow=await targetScenario({ticket:'one-use',iam_error:'ACCOUNT_MISMATCH'});assert.ok(!targetFlow.events.some(x=>x[0]==='login'));await vm.runInContext('switchAccount()',targetFlow.c);assert.ok(targetFlow.events.some(x=>x[0]==='post'&&x[1]==='/launch/reset'));assert.ok(targetFlow.events.some(x=>x[0]==='clear'))
console.log('PASS IamV2UiTest: 4 Vue components compile; default-off, explicit new tab, opener isolation, source-only POST, error closes tab, three-step confirm, batch ready-only, stable operation key, protected review retry snapshot, target URL cleanup/account mismatch/password-WeCom gates')