diff --git a/admin/src/components/iam-v2/personal.vue b/admin/src/components/iam-v2/personal.vue index 68960a9c8..495fab025 100644 --- a/admin/src/components/iam-v2/personal.vue +++ b/admin/src/components/iam-v2/personal.vue @@ -16,11 +16,18 @@

关联只确认同一人的身份,不会复制业务角色或自动授予其他平台权限。

+
验证当前身份 企业微信重新登录 { document.title = to.meta.title ?? config.title const userStore = useUserStore() diff --git a/admin/src/router/routes.ts b/admin/src/router/routes.ts index 73a7d805f..20d8a77de 100644 --- a/admin/src/router/routes.ts +++ b/admin/src/router/routes.ts @@ -23,6 +23,11 @@ export const LAYOUT = () => Promise.resolve(Layout) export const INDEX_ROUTE_NAME = Symbol() export const constantRoutes: Array = [ + { + path: '/iam/launch', + component: () => import('@/views/account/iam-launch.vue'), + meta: { title: '统一身份进入甄养堂', hideTab: true } + }, { path: '/:pathMatch(.*)*', component: () => import('@/views/error/404.vue') diff --git a/admin/src/stores/modules/user.ts b/admin/src/stores/modules/user.ts index 9ba1da04d..521834577 100644 --- a/admin/src/stores/modules/user.ts +++ b/admin/src/stores/modules/user.ts @@ -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 diff --git a/admin/src/views/account/iam-launch.vue b/admin/src/views/account/iam-launch.vue new file mode 100644 index 000000000..a585a2a7c --- /dev/null +++ b/admin/src/views/account/iam-launch.vue @@ -0,0 +1,93 @@ + + diff --git a/admin/src/views/permission/identity/index.vue b/admin/src/views/permission/identity/index.vue index 0d9f61236..8f605604b 100644 --- a/admin/src/views/permission/identity/index.vue +++ b/admin/src/views/permission/identity/index.vue @@ -81,14 +81,23 @@ 关联 创建并关联 @@ -98,12 +107,12 @@ > 换绑 解绑 审计记录 @@ -219,6 +228,7 @@ > +
+ 中央复核 ↗ +

+ 由中央管理员完成近期验证并批准后,重试原操作;原目标、版本和原因保持不变。 +

+
@@ -387,6 +414,7 @@ const titles: Record = { reconcile: '对账' } const enabled = ref(false), + reviewAllowed = ref(false), caps = ref([]), rows = ref([]), total = ref(0), @@ -403,6 +431,7 @@ const drawer = ref(false), confirmed = ref(false), drawerError = ref(''), result = ref(null) +const submittedPayload = ref | null>(null) const operation = reactive({ 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) diff --git a/docs/iam-v2.md b/docs/iam-v2.md index e2c9ae032..5e6191f75 100644 --- a/docs/iam-v2.md +++ b/docs/iam-v2.md @@ -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 = +V2_TOKEN = +V2_MANAGEMENT_TOKEN = V2_INTROSPECTION_TOKEN = V2_EVENT_SECRET = ERP_URL = https:// +ERP_CALLBACK_URL = https:///basic-api/api/erp/iam/v2/launch/callback PORTAL_URL = https:// +V2_SOURCES_JSON = {"iam-admin":"https:///admin/v1/iam/v2/launch/continue","iam-portal":"https:///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:// 源应用所有路径前缀 `/adminapi/iam/v2`: - `POST /proofs/introspect`、`POST /sessions/check`:专用 Bearer callback credential,不接受业务 token 代替。回包为中央约定的原生 JSON,不包业务 envelope。 -- `POST /events/revocation`:`X-IAM-Signature=sha256=`,`X-IAM-Event-ID` 必须等于 `body.id`;RFC3339 occurredAt、未来最多 60 秒、5 分钟重放窗口,持久化 event ID + digest 幂等;旧 bindingVersion 不撤销较新关联。 +- `POST /events/revocation`:`X-IAM-Signature=sha256=`,`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:// ## 账号、会话与权限 -- 新建来源会话保存独立浏览器 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按约定不是全局登出。 diff --git a/scripts/test-iam-v2.sh b/scripts/test-iam-v2.sh index 449376164..855171df9 100755 --- a/scripts/test-iam-v2.sh +++ b/scripts/test-iam-v2.sh @@ -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 diff --git a/server/app/adminapi/controller/IamV2Controller.php b/server/app/adminapi/controller/IamV2Controller.php index 7ec9f717a..7b6c115c4 100644 --- a/server/app/adminapi/controller/IamV2Controller.php +++ b/server/app/adminapi/controller/IamV2Controller.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( diff --git a/server/app/adminapi/http/middleware/LoginMiddleware.php b/server/app/adminapi/http/middleware/LoginMiddleware.php index 474e91482..0615e1304 100755 --- a/server/app/adminapi/http/middleware/LoginMiddleware.php +++ b/server/app/adminapi/http/middleware/LoginMiddleware.php @@ -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 { diff --git a/server/app/adminapi/route/app.php b/server/app/adminapi/route/app.php index 5bfbee1ba..789fe505b 100644 --- a/server/app/adminapi/route/app.php +++ b/server/app/adminapi/route/app.php @@ -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'); diff --git a/server/app/adminapi/service/iam/IamAccountProvisioner.php b/server/app/adminapi/service/iam/IamAccountProvisioner.php index c299001e7..f7b150f92 100644 --- a/server/app/adminapi/service/iam/IamAccountProvisioner.php +++ b/server/app/adminapi/service/iam/IamAccountProvisioner.php @@ -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('统一账号绑定状态不一致'); } diff --git a/server/app/adminapi/service/iam/IamLoginService.php b/server/app/adminapi/service/iam/IamLoginService.php index 43f2203f0..2e97c1a06 100644 --- a/server/app/adminapi/service/iam/IamLoginService.php +++ b/server/app/adminapi/service/iam/IamLoginService.php @@ -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; diff --git a/server/app/adminapi/service/iam/v2/IamV2Client.php b/server/app/adminapi/service/iam/v2/IamV2Client.php index af1dda773..4c5660090 100644 --- a/server/app/adminapi/service/iam/v2/IamV2Client.php +++ b/server/app/adminapi/service/iam/v2/IamV2Client.php @@ -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'); } diff --git a/server/app/adminapi/service/iam/v2/IamV2Events.php b/server/app/adminapi/service/iam/v2/IamV2Events.php index d9e024325..5392dac33 100644 --- a/server/app/adminapi/service/iam/v2/IamV2Events.php +++ b/server/app/adminapi/service/iam/v2/IamV2Events.php @@ -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]; }); diff --git a/server/app/adminapi/service/iam/v2/IamV2Flow.php b/server/app/adminapi/service/iam/v2/IamV2Flow.php index 88ac7b647..f49eaecd2 100644 --- a/server/app/adminapi/service/iam/v2/IamV2Flow.php +++ b/server/app/adminapi/service/iam/v2/IamV2Flow.php @@ -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) { diff --git a/server/app/adminapi/service/iam/v2/IamV2Manager.php b/server/app/adminapi/service/iam/v2/IamV2Manager.php index 089a97934..a009ef43c 100644 --- a/server/app/adminapi/service/iam/v2/IamV2Manager.php +++ b/server/app/adminapi/service/iam/v2/IamV2Manager.php @@ -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; } } diff --git a/server/app/adminapi/service/iam/v2/IamV2Source.php b/server/app/adminapi/service/iam/v2/IamV2Source.php index f403c5ba2..354e81021 100644 --- a/server/app/adminapi/service/iam/v2/IamV2Source.php +++ b/server/app/adminapi/service/iam/v2/IamV2Source.php @@ -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]); diff --git a/server/app/adminapi/service/iam/v2/IamV2Target.php b/server/app/adminapi/service/iam/v2/IamV2Target.php new file mode 100644 index 000000000..c927a378c --- /dev/null +++ b/server/app/adminapi/service/iam/v2/IamV2Target.php @@ -0,0 +1,152 @@ +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()]); + } +} diff --git a/server/config/iam.php b/server/config/iam.php index 4abcc79c2..edb386578 100644 --- a/server/config/iam.php +++ b/server/config/iam.php @@ -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), diff --git a/server/database/migrations/20261029_iam_v2.sql b/server/database/migrations/20261029_iam_v2.sql index 4cbdeb9ef..b7f1d91e1 100644 --- a/server/database/migrations/20261029_iam_v2.sql +++ b/server/database/migrations/20261029_iam_v2.sql @@ -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) diff --git a/server/tests/IamV2RouteContractTest.php b/server/tests/IamV2RouteContractTest.php index 6f3483b13..09295c5ae 100644 --- a/server/tests/IamV2RouteContractTest.php +++ b/server/tests/IamV2RouteContractTest.php @@ -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"; diff --git a/server/tests/IamV2SourceTest.php b/server/tests/IamV2SourceTest.php index 9f66efbef..66ad631e6 100644 --- a/server/tests/IamV2SourceTest.php +++ b/server/tests/IamV2SourceTest.php @@ -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"; diff --git a/server/tests/IamV2TargetTest.php b/server/tests/IamV2TargetTest.php new file mode 100644 index 000000000..98aba068d --- /dev/null +++ b/server/tests/IamV2TargetTest.php @@ -0,0 +1,54 @@ +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);} diff --git a/server/tests/IamV2UiTest.mjs b/server/tests/IamV2UiTest.mjs index 4d9d4a4a2..5510e677e 100644 --- a/server/tests/IamV2UiTest.mjs +++ b/server/tests/IamV2UiTest.mjs @@ -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(/