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