feat(iam): add guarded identity management and ERP launch source
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import request from '@/utils/request'
|
||||
export type IdentityOperation = {
|
||||
kind: 'bind' | 'create_and_bind' | 'unbind' | 'rebind' | 'reconcile'
|
||||
user_id: number
|
||||
employee_id?: string
|
||||
expected_version?: number
|
||||
new_employee?: { username: string; display_name: string; email?: string }
|
||||
reason: string
|
||||
}
|
||||
const prefix = '/iam/v2'
|
||||
const opts = { isOpenRetry: false, ignoreCancelToken: true }
|
||||
export const iamGet = (path: string, params?: Record<string, unknown>): Promise<any> =>
|
||||
request.get({ url: prefix + path, params, withCredentials: true }, opts)
|
||||
export const iamPost = (
|
||||
path: string,
|
||||
params: Record<string, unknown> = {},
|
||||
key = crypto.randomUUID()
|
||||
): Promise<any> =>
|
||||
request.post(
|
||||
{ url: prefix + path, params, headers: { 'Idempotency-Key': key }, withCredentials: true },
|
||||
opts
|
||||
)
|
||||
export const identityError = (e: any): string =>
|
||||
({
|
||||
REAUTH_REQUIRED: '请先验证当前账号密码,或通过企业微信重新登录。',
|
||||
ACCOUNT_MISMATCH: '两端账号不一致,请确认身份后重新发起。',
|
||||
NOT_LINKED: '当前账号还未关联 IAM。',
|
||||
NOT_AUTHORIZED: 'IAM 尚未授予目标应用访问权限。',
|
||||
BINDING_CONFLICT: '关联已变更,请刷新后重新确认。',
|
||||
PROTECTED_ACCOUNT_REVIEW_REQUIRED: '敏感账号需要双侧近期管理员核验,请由统一身份平台审核。',
|
||||
SERVICE_UNAVAILABLE: '统一身份服务暂时不可用,状态未确认,请稍后重试。',
|
||||
FEATURE_DISABLED: 'IAM 关联管理尚未启用。',
|
||||
FORBIDDEN: '没有当前操作权限或目标超出数据范围。',
|
||||
TRANSACTION_EXPIRED: '验证已过期或已经使用,请重新发起。'
|
||||
})[e?.error?.code as string] ||
|
||||
e?.error?.code ||
|
||||
'操作未完成,请刷新状态后重试。'
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<el-button v-if="enabled" text :loading="busy" @click="launch">进入甄美 ERP ↗</el-button>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { iamGet, iamPost, identityError } from '@/api/iam-v2'
|
||||
const enabled = ref(false),
|
||||
busy = ref(false)
|
||||
onMounted(async () => {
|
||||
try {
|
||||
enabled.value = !!(await iamGet('/status')).enabled
|
||||
} catch {
|
||||
enabled.value = false
|
||||
}
|
||||
})
|
||||
async function launch() {
|
||||
const tab = window.open('about:blank', '_blank')
|
||||
if (tab) tab.opener = null
|
||||
busy.value = true
|
||||
try {
|
||||
const r = await iamPost('/launch/start')
|
||||
if (tab) tab.location.href = r.url
|
||||
else ElMessage.warning('请允许新标签页后再试。')
|
||||
} catch (e) {
|
||||
tab?.close()
|
||||
ElMessage.warning(identityError(e))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<el-card v-if="status?.enabled || error" class="mt-4" shadow="never">
|
||||
<template #header
|
||||
><strong>统一身份 IAM</strong
|
||||
><el-button class="float-right" text @click="refresh">刷新状态</el-button></template
|
||||
>
|
||||
<el-alert v-if="error" :title="error" type="warning" :closable="false" class="mb-4" />
|
||||
<p>
|
||||
关联状态:<el-tag :type="linked ? 'success' : 'info'">{{
|
||||
linked ? '已关联' : status.binding?.state === 'revoked' ? '关联已撤销' : '未关联'
|
||||
}}</el-tag>
|
||||
</p>
|
||||
<p v-if="status.employee" class="my-3">
|
||||
IAM 员工:{{ status.employee.displayName }}({{ status.employee.username }})
|
||||
</p>
|
||||
<p class="my-3 text-gray-500">
|
||||
关联只确认同一人的身份,不会复制业务角色或自动授予其他平台权限。
|
||||
</p>
|
||||
<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"
|
||||
type="primary"
|
||||
:loading="busy"
|
||||
@click="navigate('/self-link/start')"
|
||||
>关联已有 IAM 账号</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="linked && status.employee?.passwordSetupPending"
|
||||
type="primary"
|
||||
:loading="busy"
|
||||
@click="navigate('/password-setup')"
|
||||
>设置 IAM 登录密码</el-button
|
||||
>
|
||||
<el-button v-if="linked" :loading="busy" @click="launch">进入甄美 ERP ↗</el-button>
|
||||
</div>
|
||||
<el-dialog
|
||||
v-model="reauthVisible"
|
||||
title="验证当前 ZYT 身份"
|
||||
width="420px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form @submit.prevent="reauth"
|
||||
><el-form-item label="当前密码"
|
||||
><el-input
|
||||
v-model="password"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="reauth" /></el-form-item
|
||||
></el-form>
|
||||
<p class="text-gray-500">
|
||||
仅验证当前账号,验证有效期 5 分钟。密码不会被保存或传给 IAM。
|
||||
</p>
|
||||
<template #footer
|
||||
><el-button
|
||||
@click="cancelReauth"
|
||||
>取消</el-button
|
||||
><el-button type="primary" :loading="busy" @click="reauth"
|
||||
>验证</el-button
|
||||
></template
|
||||
>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { iamGet, iamPost, identityError } from '@/api/iam-v2'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
const status = ref<any>({ enabled: false }),
|
||||
error = ref(''),
|
||||
busy = ref(false),
|
||||
reauthVisible = ref(false),
|
||||
password = ref('')
|
||||
const linked = computed(() => status.value?.binding?.state === 'verified')
|
||||
async function refresh() {
|
||||
try {
|
||||
status.value = await iamGet('/status')
|
||||
error.value = ''
|
||||
} catch (e) {
|
||||
error.value = identityError(e)
|
||||
}
|
||||
}
|
||||
function cancelReauth() { reauthVisible.value = false; password.value = '' }
|
||||
async function reauth() {
|
||||
busy.value = true
|
||||
try {
|
||||
await iamPost('/reauth', { password: password.value })
|
||||
reauthVisible.value = false
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
error.value = identityError(e)
|
||||
} finally {
|
||||
password.value = ''
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
async function navigate(path: string) {
|
||||
busy.value = true
|
||||
try {
|
||||
const r = await iamPost(path)
|
||||
window.location.assign(r.url)
|
||||
} catch (e) {
|
||||
error.value = identityError(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
async function launch() {
|
||||
const tab = window.open('about:blank', '_blank')
|
||||
if (tab) tab.opener = null
|
||||
busy.value = true
|
||||
try {
|
||||
const r = await iamPost('/launch/start')
|
||||
if (tab) tab.location.href = r.url
|
||||
else error.value = '请允许浏览器打开新标签页后再试。'
|
||||
} catch (e) {
|
||||
tab?.close()
|
||||
error.value = identityError(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
async function wecomRelogin() {
|
||||
await useUserStore().logout()
|
||||
}
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
@@ -22,6 +22,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<iam-launch />
|
||||
<div class="navbar-item" v-if="!isMobile">
|
||||
<el-tooltip
|
||||
class="box-item"
|
||||
@@ -52,6 +53,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IamLaunch from '@/components/iam-v2/launch.vue'
|
||||
import { useFullscreen } from '@vueuse/core'
|
||||
|
||||
import useAppStore from '@/stores/modules/app'
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
<template>
|
||||
<div class="identity-management">
|
||||
<el-card shadow="never">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">IAM 关联管理</h2>
|
||||
<p class="text-gray-500 mt-2">
|
||||
保留历史本地账号、业务角色与数据;只管理统一身份关联。
|
||||
</p>
|
||||
</div>
|
||||
<el-button @click="reauthVisible = true">验证管理员身份</el-button>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="error"
|
||||
:title="error"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="!enabled"
|
||||
title="IAM v2 尚未启用,原有业务与登录方式保持不变。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
/>
|
||||
<el-form v-if="enabled" inline class="mt-4" @submit.prevent="reload">
|
||||
<el-form-item label="本地账号"
|
||||
><el-input
|
||||
v-model="query.q"
|
||||
placeholder="账号或姓名"
|
||||
clearable
|
||||
@keyup.enter="reload"
|
||||
/></el-form-item>
|
||||
<el-form-item label="关联状态"
|
||||
><el-select v-model="query.state" clearable class="!w-40"
|
||||
><el-option
|
||||
v-for="(label, key) in states"
|
||||
:key="key"
|
||||
:label="label"
|
||||
:value="key" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item
|
||||
><el-button type="primary" @click="reload">查询</el-button
|
||||
><el-button :disabled="!can('batch')" @click="batchVisible = true"
|
||||
>批量预检</el-button
|
||||
></el-form-item
|
||||
>
|
||||
</el-form>
|
||||
<el-table v-if="enabled" v-loading="loading" :data="rows" row-key="id">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="account" label="本地账号" min-width="140" />
|
||||
<el-table-column prop="name" label="姓名" min-width="120" />
|
||||
<el-table-column label="本地状态" width="110"
|
||||
><template #default="{ row }"
|
||||
><el-tag :type="row.disable ? 'danger' : 'success'">{{
|
||||
row.disable ? '停用' : row.root ? '敏感账号' : '正常'
|
||||
}}</el-tag></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column label="IAM 员工" min-width="180"
|
||||
><template #default="{ row }"
|
||||
>{{ row.iam.employee?.displayName || '—' }}
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ row.iam.employee?.username }}
|
||||
</div></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column label="关联状态" width="130"
|
||||
><template #default="{ row }"
|
||||
><el-tag :type="row.iam.state === 'verified' ? 'success' : 'info'">{{
|
||||
states[row.iam.state] || row.iam.state
|
||||
}}</el-tag>
|
||||
<div class="text-xs">
|
||||
版本 {{ row.iam.binding?.version ?? '—' }}
|
||||
</div></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column label="操作" min-width="210"
|
||||
><template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:disabled="!!row.root || row.iam.state === 'verified' || !can('bind')"
|
||||
@click="open(row, 'bind')"
|
||||
>关联</el-button
|
||||
>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:disabled="!!row.root || row.iam.state === 'verified' || !can('create')"
|
||||
@click="open(row, 'create_and_bind')"
|
||||
>创建并关联</el-button
|
||||
>
|
||||
<el-dropdown @command="(kind: string) => more(row, kind)"
|
||||
><el-button link>更多</el-button
|
||||
><template #dropdown
|
||||
><el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
command="rebind"
|
||||
:disabled="!!row.root || !can('rebind')"
|
||||
>换绑</el-dropdown-item
|
||||
>
|
||||
<el-dropdown-item
|
||||
command="unbind"
|
||||
:disabled="!!row.root || !can('unbind')"
|
||||
>解绑</el-dropdown-item
|
||||
>
|
||||
<el-dropdown-item command="audit">审计记录</el-dropdown-item>
|
||||
</el-dropdown-menu></template
|
||||
></el-dropdown
|
||||
>
|
||||
</template></el-table-column
|
||||
>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-if="enabled"
|
||||
class="mt-4 justify-end"
|
||||
layout="total, prev, pager, next"
|
||||
:total="total"
|
||||
v-model:current-page="query.page"
|
||||
:page-size="20"
|
||||
@current-change="load"
|
||||
/>
|
||||
</el-card>
|
||||
<el-drawer
|
||||
v-model="drawer"
|
||||
:title="titles[operation.kind]"
|
||||
size="min(680px, 94vw)"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-steps :active="step" finish-status="success" align-center
|
||||
><el-step title="核对本地账号" /><el-step title="核对 IAM 身份" /><el-step
|
||||
title="确认变更"
|
||||
/></el-steps>
|
||||
<section class="mt-8">
|
||||
<template v-if="step === 0"
|
||||
><h3 class="text-lg mb-4">{{ selected?.name }} · {{ selected?.account }}</h3>
|
||||
<p>本地 ID:{{ selected?.id }}</p>
|
||||
<p class="my-3">
|
||||
当前关联:{{ selected?.iam.employee?.displayName || '未关联' }}(版本
|
||||
{{ selected?.iam.binding?.version ?? '无' }})
|
||||
</p>
|
||||
<el-alert
|
||||
title="本次操作不改变本地密码、角色、部门或历史数据;人员同名不代表同一身份。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
/></template>
|
||||
<template v-if="step === 1">
|
||||
<el-form v-if="operation.kind === 'create_and_bind'" label-position="top"
|
||||
><el-form-item label="IAM 登录名"
|
||||
><el-input
|
||||
v-model="newEmployee.username"
|
||||
maxlength="64" /></el-form-item
|
||||
><el-form-item label="姓名"
|
||||
><el-input
|
||||
v-model="newEmployee.display_name"
|
||||
maxlength="100" /></el-form-item
|
||||
><el-form-item label="邮箱(可选)"
|
||||
><el-input v-model="newEmployee.email" /></el-form-item
|
||||
><el-alert
|
||||
title="使用正常员工开户流程,默认应用授权遵循 IAM 当前策略。不会生成共享初始密码;本人首次使用需设置 IAM 密码。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
/></el-form>
|
||||
<template v-else-if="operation.kind !== 'unbind'"
|
||||
><el-input
|
||||
v-model="identityQuery"
|
||||
placeholder="搜索 IAM 登录名或姓名"
|
||||
@keyup.enter="searchIdentities"
|
||||
><template #append
|
||||
><el-button @click="searchIdentities">搜索</el-button></template
|
||||
></el-input
|
||||
><el-table
|
||||
class="mt-4"
|
||||
:data="identities"
|
||||
highlight-current-row
|
||||
@current-change="choose"
|
||||
><el-table-column label="选择" width="70"
|
||||
><template #default="{ row }"
|
||||
><el-radio
|
||||
:model-value="operation.employee_id"
|
||||
:value="row.id"
|
||||
@change="choose(row)"
|
||||
><span /></el-radio></template></el-table-column
|
||||
><el-table-column prop="username" label="IAM 登录名" /><el-table-column
|
||||
prop="displayName"
|
||||
label="姓名" /><el-table-column
|
||||
prop="status"
|
||||
label="状态" /></el-table
|
||||
></template>
|
||||
<el-alert
|
||||
v-else
|
||||
title="解绑后跨平台快捷登录停止,相关来源/业务会话撤销,历史数据和本地账号保留。自动开户不会恢复已撤销关联。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="step === 2"
|
||||
><el-descriptions :column="1" border
|
||||
><el-descriptions-item label="本地账号"
|
||||
>{{ selected?.account }} #{{ selected?.id }}</el-descriptions-item
|
||||
><el-descriptions-item label="变更类型">{{
|
||||
titles[operation.kind]
|
||||
}}</el-descriptions-item
|
||||
><el-descriptions-item label="原 IAM 员工">{{
|
||||
selected?.iam.employee?.username || '无'
|
||||
}}</el-descriptions-item
|
||||
><el-descriptions-item label="目标 IAM 员工">{{
|
||||
operation.kind === 'unbind'
|
||||
? '解除关联'
|
||||
: operation.kind === 'create_and_bind'
|
||||
? newEmployee.username
|
||||
: chosenIdentity?.username
|
||||
}}</el-descriptions-item
|
||||
><el-descriptions-item label="预期版本">{{
|
||||
operation.expected_version ?? '首次关联'
|
||||
}}</el-descriptions-item></el-descriptions
|
||||
><el-input
|
||||
class="mt-4"
|
||||
v-model="operation.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
placeholder="必填:核对方式和变更原因,将写入审计记录"
|
||||
/><el-checkbox class="mt-4" v-model="confirmed"
|
||||
>我已核对本地账号与 IAM 员工为同一人,理解会话撤销影响</el-checkbox
|
||||
></template
|
||||
>
|
||||
<el-alert
|
||||
v-if="drawerError"
|
||||
class="mt-4"
|
||||
:title="drawerError"
|
||||
type="error"
|
||||
:closable="false"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="result"
|
||||
class="mt-4"
|
||||
:title="`操作 ${result.id}:${result.status}`"
|
||||
:type="result.status === 'succeeded' ? 'success' : 'warning'"
|
||||
:closable="false"
|
||||
/>
|
||||
<el-button
|
||||
v-if="result && result.status !== 'succeeded'"
|
||||
class="mt-4"
|
||||
@click="refreshOperation"
|
||||
>查询同一操作状态</el-button
|
||||
>
|
||||
</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 < 2"
|
||||
type="primary"
|
||||
:disabled="step === 1 && !validIdentity"
|
||||
@click="step++"
|
||||
>下一步</el-button
|
||||
><el-button
|
||||
v-else
|
||||
type="primary"
|
||||
:loading="busy"
|
||||
:disabled="
|
||||
!confirmed || !operation.reason.trim() || result?.status === 'succeeded'
|
||||
"
|
||||
@click="submit"
|
||||
>确认{{ titles[operation.kind] }}</el-button
|
||||
></template
|
||||
>
|
||||
</el-drawer>
|
||||
<el-dialog v-model="reauthVisible" title="近期管理员身份验证" width="420px"
|
||||
><el-input
|
||||
v-model="password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="当前 ZYT 账号密码"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="reauth"
|
||||
/>
|
||||
<p class="mt-4 text-gray-500">
|
||||
有效期 5 分钟。敏感目标还需中央管理员审核,此处不会越过审核。
|
||||
</p>
|
||||
<template #footer
|
||||
><el-button type="primary" :loading="busy" @click="reauth"
|
||||
>验证</el-button
|
||||
></template
|
||||
></el-dialog
|
||||
>
|
||||
<el-drawer v-model="auditVisible" title="关联审计" size="min(760px, 94vw)"
|
||||
><el-table :data="audits"
|
||||
><el-table-column prop="occurredAt" label="时间" min-width="170" /><el-table-column
|
||||
prop="action"
|
||||
label="操作" /><el-table-column label="操作人"
|
||||
><template #default="{ row }">{{
|
||||
typeof row.actor === 'string' ? row.actor : JSON.stringify(row.actor)
|
||||
}}</template></el-table-column
|
||||
><el-table-column prop="reason" label="原因" /></el-table
|
||||
></el-drawer>
|
||||
<el-dialog
|
||||
v-model="batchVisible"
|
||||
title="批量预检与确认"
|
||||
width="min(920px, 94vw)"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-alert
|
||||
title="先预检,不会创建账号或修改绑定。仅选择 ready 项后显式确认;敏感账号与冲突项跳过。每批最多 100 条。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
/>
|
||||
<el-input
|
||||
v-model="batchText"
|
||||
class="mt-4"
|
||||
type="textarea"
|
||||
:rows="7"
|
||||
placeholder='[{"kind":"bind","user_id":42,"employee_id":"员工ID","reason":"核对后历史关联"}]'
|
||||
/>
|
||||
<el-button class="mt-4" :loading="busy" @click="preflight">只读预检</el-button>
|
||||
<el-table
|
||||
v-if="preview"
|
||||
class="mt-4"
|
||||
:data="preview.items"
|
||||
@selection-change="(items: any[]) => (selectedIndexes = items.map((x) => x.index))"
|
||||
><el-table-column
|
||||
type="selection"
|
||||
:selectable="(row: any) => row.status === 'ready'" /><el-table-column
|
||||
prop="externalAccountId"
|
||||
label="本地 ID" /><el-table-column prop="status" label="状态" /><el-table-column
|
||||
prop="message"
|
||||
label="结果"
|
||||
/></el-table>
|
||||
<el-checkbox v-if="preview" v-model="batchConfirmed" class="mt-4"
|
||||
>确认仅执行已选择项目({{ selectedIndexes.length }} 条)</el-checkbox
|
||||
>
|
||||
<section v-if="job" class="mt-4">
|
||||
<h3>任务 {{ job.id }} · {{ job.status }}</h3>
|
||||
<el-table :data="job.items"
|
||||
><el-table-column prop="externalAccountId" label="本地账号" /><el-table-column
|
||||
prop="status"
|
||||
label="状态"
|
||||
/><el-table-column label="结果"
|
||||
><template #default="{ row }">{{
|
||||
row.error?.message || row.error?.code || '—'
|
||||
}}</template></el-table-column
|
||||
></el-table
|
||||
><el-button @click="refreshJob">刷新任务</el-button
|
||||
><el-button
|
||||
:disabled="
|
||||
!job.items?.some((x: any) => ['failed', 'pending'].includes(x.status))
|
||||
"
|
||||
@click="retryJob"
|
||||
>只重试失败/待处理项</el-button
|
||||
>
|
||||
</section>
|
||||
<template #footer
|
||||
><el-button @click="batchVisible = false">关闭</el-button
|
||||
><el-button
|
||||
type="primary"
|
||||
:loading="busy"
|
||||
:disabled="
|
||||
!preview?.previewId || !batchConfirmed || !selectedIndexes.length || !!job
|
||||
"
|
||||
@click="submitBatch"
|
||||
>确认执行</el-button
|
||||
></template
|
||||
>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { iamGet, iamPost, identityError, type IdentityOperation } from '@/api/iam-v2'
|
||||
const states: Record<string, string> = {
|
||||
verified: '已关联',
|
||||
unlinked: '未关联',
|
||||
revoked: '已撤销',
|
||||
pending: '待确认',
|
||||
conflict: '冲突',
|
||||
unknown: '待核验'
|
||||
}
|
||||
const titles: Record<string, string> = {
|
||||
bind: '关联',
|
||||
create_and_bind: '创建并关联',
|
||||
rebind: '换绑',
|
||||
unbind: '解绑',
|
||||
reconcile: '对账'
|
||||
}
|
||||
const enabled = ref(false),
|
||||
caps = ref<string[]>([]),
|
||||
rows = ref<any[]>([]),
|
||||
total = ref(0),
|
||||
loading = ref(false),
|
||||
busy = ref(false),
|
||||
error = ref('')
|
||||
const query = reactive({ q: '', state: '', page: 1, pageSize: 20 })
|
||||
const drawer = ref(false),
|
||||
step = ref(0),
|
||||
selected = ref<any>(null),
|
||||
identities = ref<any[]>([]),
|
||||
identityQuery = ref(''),
|
||||
chosenIdentity = ref<any>(null),
|
||||
confirmed = ref(false),
|
||||
drawerError = ref(''),
|
||||
result = ref<any>(null)
|
||||
const operation = reactive<IdentityOperation>({ kind: 'bind', user_id: 0, reason: '' }),
|
||||
newEmployee = reactive({ username: '', display_name: '', email: '' })
|
||||
let operationKey = crypto.randomUUID(),
|
||||
batchKey = crypto.randomUUID()
|
||||
const reauthVisible = ref(false),
|
||||
password = ref(''),
|
||||
auditVisible = ref(false),
|
||||
audits = ref<any[]>([])
|
||||
const batchVisible = ref(false),
|
||||
batchText = ref(''),
|
||||
preview = ref<any>(null),
|
||||
selectedIndexes = ref<number[]>([]),
|
||||
batchConfirmed = ref(false),
|
||||
job = ref<any>(null)
|
||||
const can = (c: string) => caps.value.includes(c)
|
||||
const validIdentity = computed(
|
||||
() =>
|
||||
operation.kind === 'unbind' ||
|
||||
(operation.kind === 'create_and_bind'
|
||||
? !!newEmployee.username.trim() && !!newEmployee.display_name.trim()
|
||||
: !!operation.employee_id)
|
||||
)
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await iamGet('/accounts', query)
|
||||
rows.value = r.items
|
||||
total.value = r.total
|
||||
error.value = ''
|
||||
} catch (e) {
|
||||
error.value = identityError(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
function reload() {
|
||||
query.page = 1
|
||||
void load()
|
||||
}
|
||||
function open(row: any, kind: IdentityOperation['kind']) {
|
||||
selected.value = row
|
||||
Object.assign(operation, {
|
||||
kind,
|
||||
user_id: row.id,
|
||||
employee_id: undefined,
|
||||
expected_version: row.iam.binding?.version,
|
||||
reason: ''
|
||||
})
|
||||
Object.assign(newEmployee, { username: '', display_name: row.name, email: '' })
|
||||
drawer.value = true
|
||||
step.value = 0
|
||||
confirmed.value = false
|
||||
drawerError.value = ''
|
||||
result.value = null
|
||||
chosenIdentity.value = null
|
||||
identities.value = []
|
||||
operationKey = crypto.randomUUID()
|
||||
}
|
||||
async function more(row: any, kind: string) {
|
||||
if (kind === 'audit') {
|
||||
try {
|
||||
audits.value = (await iamGet(`/accounts/${row.id}/audit`)).items
|
||||
auditVisible.value = true
|
||||
} catch (e) {
|
||||
error.value = identityError(e)
|
||||
}
|
||||
} else open(row, kind as IdentityOperation['kind'])
|
||||
}
|
||||
async function searchIdentities() {
|
||||
try {
|
||||
identities.value = (
|
||||
await iamGet('/identities', { q: identityQuery.value, page: 1, pageSize: 20 })
|
||||
).items
|
||||
} catch (e) {
|
||||
drawerError.value = identityError(e)
|
||||
}
|
||||
}
|
||||
function choose(row: any) {
|
||||
if (row?.status === 'active') {
|
||||
chosenIdentity.value = row
|
||||
operation.employee_id = row.id
|
||||
}
|
||||
}
|
||||
async function submit() {
|
||||
busy.value = true
|
||||
try {
|
||||
result.value = await iamPost(
|
||||
'/operations',
|
||||
{
|
||||
...operation,
|
||||
...(operation.kind === 'create_and_bind'
|
||||
? { new_employee: { ...newEmployee } }
|
||||
: {})
|
||||
},
|
||||
operationKey
|
||||
)
|
||||
drawerError.value = ''
|
||||
await load()
|
||||
} catch (e) {
|
||||
drawerError.value = identityError(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
async function refreshOperation() {
|
||||
try {
|
||||
result.value = await iamGet(`/operations/${result.value.id}`)
|
||||
await load()
|
||||
} catch (e) {
|
||||
drawerError.value = identityError(e)
|
||||
}
|
||||
}
|
||||
async function reauth() {
|
||||
busy.value = true
|
||||
try {
|
||||
await iamPost('/reauth', { password: password.value })
|
||||
reauthVisible.value = false
|
||||
ElMessage.success('身份已验证,有效期 5 分钟')
|
||||
await load()
|
||||
} catch (e) {
|
||||
error.value = identityError(e)
|
||||
} finally {
|
||||
password.value = ''
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
async function preflight() {
|
||||
busy.value = true
|
||||
preview.value = null
|
||||
job.value = null
|
||||
batchConfirmed.value = false
|
||||
selectedIndexes.value = []
|
||||
try {
|
||||
const items = JSON.parse(batchText.value)
|
||||
if (!Array.isArray(items) || items.length < 1 || items.length > 100) throw Error('input')
|
||||
preview.value = await iamPost('/batch/preflight', { items })
|
||||
batchKey = crypto.randomUUID()
|
||||
} catch (e) {
|
||||
error.value = e instanceof SyntaxError ? '批量输入必须是 JSON 数组。' : identityError(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
async function submitBatch() {
|
||||
busy.value = true
|
||||
try {
|
||||
job.value = await iamPost(
|
||||
'/batch',
|
||||
{ preview_id: preview.value.previewId, indexes: selectedIndexes.value },
|
||||
batchKey
|
||||
)
|
||||
await load()
|
||||
} catch (e) {
|
||||
error.value = identityError(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
async function refreshJob() {
|
||||
try {
|
||||
job.value = await iamGet(`/jobs/${job.value.id}`)
|
||||
await load()
|
||||
} catch (e) {
|
||||
error.value = identityError(e)
|
||||
}
|
||||
}
|
||||
async function retryJob() {
|
||||
busy.value = true
|
||||
try {
|
||||
job.value = await iamPost(`/jobs/${job.value.id}/retry`)
|
||||
await load()
|
||||
} catch (e) {
|
||||
error.value = identityError(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const s = await iamGet('/status')
|
||||
enabled.value = s.enabled
|
||||
caps.value = s.management_capabilities || []
|
||||
if (enabled.value && can('read')) await load()
|
||||
} catch (e) {
|
||||
error.value = identityError(e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.identity-management p {
|
||||
line-height: 1.7;
|
||||
}
|
||||
.identity-management :deep(.el-steps) {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -79,6 +79,7 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<iam-personal />
|
||||
<footer-btns>
|
||||
<el-button type="primary" @click="handleSubmit">保存</el-button>
|
||||
</footer-btns>
|
||||
@@ -108,6 +109,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="userSetting">
|
||||
import IamPersonal from '@/components/iam-v2/personal.vue'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import { nextTick, onMounted, onUnmounted, ref, reactive } from 'vue'
|
||||
import { Loading, CircleCheck, Connection } from '@element-plus/icons-vue'
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# IAM v2 关联管理与 ZYT → ERP 快捷登录
|
||||
|
||||
## 默认状态与部署次序
|
||||
|
||||
所有新功能由 `iam.v2_enabled` 控制,默认 **false**。关闭时不写新表、不登记来源会话,原密码、企微、IAM 入站登录保持原路径。本文只描述源码;生产开关、迁移、真实浏览器及三端联调需另行验收。
|
||||
|
||||
1. 备份部署版本与配置,审查并执行 `server/database/migrations/20261029_iam_v2.sql`(按实际表前缀调整)。此迁移只加六张表及独立菜单/权限,不给现有角色自动授予权限,不改账号与角色。
|
||||
2. 中央先部署 v2 绑定墓碑、幂等恢复、固定来源回查、版本检查、源/目标访问授权、密码设置协议与签名事件。旧 v1 token 不得获得新管理权限。
|
||||
3. 向 IAM 注册 application `zyt` 的固定 HTTPS 回调;固定目标仅 `zmxy-erp`。OIDC client 追加精确 self-link 回调,禁用任意重定向。
|
||||
4. 用一次性合成账号完成三端测试后再开启 v2。配置放服务器私密环境,不写仓库或日志。
|
||||
|
||||
```ini
|
||||
[IAM]
|
||||
V2_ENABLED = false
|
||||
V2_TOKEN = <dedicated-application-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>
|
||||
PORTAL_URL = https://<IAM-portal-origin>
|
||||
```
|
||||
|
||||
中央专用凭据 scopes:`identity:search,identity:create,binding:read,binding:manage,binding:self,password:setup,launch:issue`,ZYT 不授予 target 的 `launch:accept`。
|
||||
|
||||
`API_URL`、`PUBLIC_URL`、`ISSUER`、OIDC client 配置沿用原配置。`ERP_URL/PUBLIC_URL/PORTAL_URL` 是固定 origin,不接受浏览器 URL。新功能的 v2 凭据与原 `APPLICATION_TOKEN` 分离。
|
||||
|
||||
## 固定端点
|
||||
|
||||
源应用所有路径前缀 `/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 /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。
|
||||
- `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 页面输入。
|
||||
|
||||
用户 API 使用原业务 `{code,data,msg}` envelope。管理:accounts/identities/operations/audit/batch/jobs。每次 mutation 使用前端稳定 `Idempotency-Key`,自动 HTTP retry 关闭;中央按同一业务 payload 和 key 恢复已执行结果(proof 可刷新,不重复副作用)。operation/job 查询限定创建操作者及当前目标数据范围。
|
||||
|
||||
## 账号、会话与权限
|
||||
|
||||
- 新建来源会话保存独立浏览器 SID、原数据库 session ID、业务 token 的 SHA-256、账号凭据版本指纹、不可被续期更新的认证方法/时间、撤销版本。数据库会话轮换/到期、账号停用/删除、密码/企微关联/管理员标记改变立即使来源证明失效。
|
||||
- 密码登录登记前再次真正核对密码 hash,不接受开发免密开关。企微入口的 TLS 必须校验后才登记 `wecom`。旧会话首次通过密码验证,或用原企微入口重新登录后登记;普通未关联业务使用不受影响。OIDC 使用原始 `auth_time`,缺失时不算近期认证。
|
||||
- 每次管理写要求 5 分钟内认证、独立动作权限、现有 DataScope。权限:`iamV2/read|bind|create|unbind|rebind|batch`;不存在“未登记路由默认许可”的依赖。
|
||||
- root、管理员/系统管理员/超级管理员角色或持有 IAM 管理写权限的目标视为敏感账号;没有双侧近期管理员证明则明确返回 `PROTECTED_ACCOUNT_REVIEW_REQUIRED`。批量预检将其列为 review,不发送中央,不静默跳过核验。
|
||||
- 中央身份和授权是真源;本地绑定表是最长 30 秒快照。已关联账号的活跃业务请求在快照超时后回查;失权、版本变更、签名事件撤销来源 SID 和业务会话。服务不可用时相关受保护请求失败关闭。不宣称空闲客户端会在 30 秒内主动收到推送。
|
||||
- 解绑/换绑保留本地业务账号和历史记录。`iam_local_identity` 是开户幂等账本,不是历史绑定全量镜像,永不删除。v2 撤销墓碑拦截旧账本自动重绑;中央旧版 v1 必须同样维护墓碑。较旧或相同版本的 verified 状态不能覆盖本地 revoked。
|
||||
- 管理列表来自所有可见本地账号并回查中央,首次展示历史绑定;按状态筛选基于已对账快照,未读取过的历史行应先在“全部”中分页核验。姓名/手机号不是自动合并依据。批量用明确 local ID/employee ID 映射预检。
|
||||
|
||||
## 本地验证
|
||||
|
||||
```sh
|
||||
php server/tests/IamV2SourceTest.php
|
||||
php server/tests/IamV2RouteContractTest.php
|
||||
NODE_PATH=admin/node_modules node server/tests/IamV2UiTest.mjs
|
||||
php server/tests/IamLoginTransactionTest.php
|
||||
php server/tests/IamProvisioningTest.php
|
||||
php server/tests/IamOidcClientTest.php
|
||||
NODE_PATH=admin/node_modules node server/tests/IamLoginUiContractTest.mjs
|
||||
```
|
||||
|
||||
SQLite 合成测试不加载生产 `.env`、不发网络、不修改真实用户、角色或授权。UI 测试编译三个组件并执行新标签页处理逻辑,不能替代三端真实浏览器(Cookie/SameSite/新标签页)验收。原前端全量 typecheck 存量 60 条诊断,需按基线比较,不能批量掩盖。
|
||||
|
||||
## 回退
|
||||
|
||||
先将 `V2_ENABLED=false`,恢复上一已验证代码/静态构建。保留六张新表、旧开户账本、事件与审计记录、所有真实账号和业务数据。关闭 v2 停止委托登录/新证明,不撤回中央已执行的人事操作;错误关联通过有审计的显式解绑/换绑恢复,而非删表或恢复旧数据库。
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
php server/tests/IamV2SourceTest.php
|
||||
php server/tests/IamV2RouteContractTest.php
|
||||
NODE_PATH=admin/node_modules node server/tests/IamV2UiTest.mjs
|
||||
php server/tests/IamLoginTransactionTest.php
|
||||
php server/tests/IamProvisioningTest.php
|
||||
php server/tests/IamOidcClientTest.php
|
||||
NODE_PATH=admin/node_modules node server/tests/IamLoginUiContractTest.mjs
|
||||
php server/tests/AdminDesktopAuthContractTest.php
|
||||
php server/tests/AdminDesktopSessionBehaviorTest.php
|
||||
php server/tests/AdminMultiRoleRegressionTest.php
|
||||
php server/tests/DataScopeMultiRoleTest.php
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace app\adminapi\controller;
|
||||
|
||||
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};
|
||||
|
||||
/** 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'];
|
||||
private function invokeV2(callable $fn)
|
||||
{
|
||||
try {
|
||||
if (!IamV2Client::enabled()) { throw new RuntimeException('FEATURE_DISABLED'); }
|
||||
$reads = ['status','accounts','identities','operationresult','audit','job'];
|
||||
$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']);
|
||||
} catch (\Throwable $e) {
|
||||
$code = preg_match('/^[A-Z_]{3,64}$/D', $e->getMessage()) ? $e->getMessage() : 'SERVICE_UNAVAILABLE';
|
||||
return $this->fail($code, ['error' => ['code' => $code]])->header(['Cache-Control' => 'no-store']);
|
||||
}
|
||||
}
|
||||
private function session(bool $recent = false): array
|
||||
{
|
||||
if (!$this->request->isGet()) {
|
||||
$expected = IamV2Client::origin((string) Config::get('iam.public_url'));
|
||||
if ((string) $this->request->header('origin') !== $expected) { throw new RuntimeException('INVALID_ORIGIN'); }
|
||||
}
|
||||
return IamV2Source::current((string) $this->request->header('token', ''), $recent);
|
||||
}
|
||||
private function input(): array { return (array) $this->request->post(); }
|
||||
private function requestId(): string
|
||||
{
|
||||
$key = (string) $this->request->header('idempotency-key', '');
|
||||
if (!preg_match('/^[A-Za-z0-9_-]{16,128}$/D', $key)) { throw new RuntimeException('INVALID_IDEMPOTENCY_KEY'); }
|
||||
return 'zyt-' . $key;
|
||||
}
|
||||
private function serviceAuth(): void
|
||||
{
|
||||
$expected = (string) Config::get('iam.v2_introspection_token', '');
|
||||
if (!$this->request->isPost() || strlen($expected) < 32 || !hash_equals('Bearer ' . $expected, (string) $this->request->header('authorization', ''))) { throw new RuntimeException('FORBIDDEN'); }
|
||||
}
|
||||
public function status()
|
||||
{
|
||||
if (!IamV2Client::enabled()) { return $this->data(['enabled' => false]); }
|
||||
return $this->invokeV2(function () {
|
||||
$token = (string) $this->request->header('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']);
|
||||
$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;
|
||||
});
|
||||
}
|
||||
public function reauth()
|
||||
{
|
||||
return $this->invokeV2(function () {
|
||||
if ((string) $this->request->header('origin') !== IamV2Client::origin((string) Config::get('iam.public_url'))) { throw new RuntimeException('INVALID_ORIGIN'); }
|
||||
$s = IamV2Source::reauth((string) $this->request->header('token', ''), (string) $this->request->post('password', ''));
|
||||
return ['expires_at' => $s['auth_time'] + 300];
|
||||
});
|
||||
}
|
||||
public function accounts() { return $this->invokeV2(function () { $s = $this->session(); IamV2Source::authorize($s, 'read', [], false); return (new IamV2Manager())->accounts((array) $this->request->get(), (int) $s['admin_id']); }); }
|
||||
public function identities()
|
||||
{
|
||||
return $this->invokeV2(function () {
|
||||
$s = $this->session(); IamV2Source::authorize($s, 'read', [], false);
|
||||
$q = http_build_query(['q' => mb_substr((string) $this->request->get('q', ''), 0, 64), 'page' => max(1, (int) $this->request->get('page', 1)), 'pageSize' => 20], '', '&', PHP_QUERY_RFC3986);
|
||||
return (new IamV2Client())->call('GET', '/identities?' . $q);
|
||||
});
|
||||
}
|
||||
public function operation() { return $this->invokeV2(fn() => (new IamV2Manager())->operation($this->session(true), $this->input(), $this->requestId())); }
|
||||
public function operationResult() { return $this->invokeV2(fn() => (new IamV2Manager())->result($this->session(), 'operation', (string) $this->request->param('id'))); }
|
||||
public function audit()
|
||||
{
|
||||
return $this->invokeV2(function () { $s = $this->session(); IamV2Source::authorize($s, 'read', [], false);
|
||||
IamV2Source::scope((int) $s['admin_id'], [(int) $this->request->param('id')]);
|
||||
return (new IamV2Client())->call('GET', '/audit?' . http_build_query(['externalAccountId' => (string) (int) $this->request->param('id'), 'page' => max(1, (int) $this->request->get('page', 1)), 'pageSize' => 20])); });
|
||||
}
|
||||
public function preflight() { return $this->invokeV2(fn() => (new IamV2Manager())->preflight($this->session(true), (array) $this->request->post('items', []))); }
|
||||
public function batch() { return $this->invokeV2(fn() => (new IamV2Manager())->batch($this->session(true), (string) $this->request->post('preview_id'), (array) $this->request->post('indexes', []), $this->requestId())); }
|
||||
public function job() { return $this->invokeV2(fn() => (new IamV2Manager())->result($this->session(), 'job', (string) $this->request->param('id'))); }
|
||||
public function retry() { return $this->invokeV2(fn() => (new IamV2Manager())->result($this->session(true), 'job', (string) $this->request->param('id'), true, (array) $this->request->post('indexes', []))); }
|
||||
public function introspect()
|
||||
{
|
||||
return $this->serviceResult(function () { $this->serviceAuth(); $b = $this->input(); return IamV2Source::introspect((string) ($b['proof'] ?? ''), (string) ($b['purpose'] ?? ''), $b['transactionId'] ?? null); });
|
||||
}
|
||||
private function serviceResult(callable $fn)
|
||||
{
|
||||
try { if (!IamV2Client::enabled()) { throw new RuntimeException('FEATURE_DISABLED'); } if (!$this->request->isPost()) { throw new RuntimeException('METHOD_NOT_ALLOWED'); } return json($fn())->header(['Cache-Control' => 'no-store']); }
|
||||
catch (\Throwable $e) { return json(['active' => false], 403)->header(['Cache-Control' => 'no-store']); }
|
||||
}
|
||||
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']]; });
|
||||
}
|
||||
public function launchStart()
|
||||
{
|
||||
return $this->invokeV2(function () { $s = $this->session(); (new IamV2Manager())->assertLinked((int) $s['admin_id']);
|
||||
IamV2Source::check($s['session_id'], (int) $s['version']);
|
||||
$intent = bin2hex(random_bytes(32)); IamV2Flow::put('launch', $intent, ['sessionId' => $s['session_id'], 'sessionVersion' => $s['version']]);
|
||||
setcookie('ZYT_IAM_V2_INTENT', $intent, ['expires' => time() + 300, 'path' => '/adminapi/iam/v2', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax']);
|
||||
return ['url' => IamV2Client::origin((string) Config::get('iam.erp_url')) . '/iam/launch?source=zyt&intent=' . $intent]; });
|
||||
}
|
||||
public function launchContinue()
|
||||
{
|
||||
try {
|
||||
$client = new IamV2Client(); $s = IamV2Source::check((string) ($_COOKIE[IamV2Source::COOKIE] ?? ''));
|
||||
$intent = (string) $this->request->get('intent', ''); $cookie = (string) ($_COOKIE['ZYT_IAM_V2_INTENT'] ?? '');
|
||||
if ($intent === '' || !hash_equals($cookie, $intent)) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
|
||||
$flow = IamV2Flow::take('launch', $intent, $s['session_id']); IamV2Source::check($s['session_id'], (int) $flow['sessionVersion']);
|
||||
$tx = (string) $this->request->get('transaction_id', '');
|
||||
$transaction = $client->call('GET', '/launch-transactions/' . rawurlencode($tx));
|
||||
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';
|
||||
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']); }
|
||||
}
|
||||
private function selfConfig(): array { $c = (array) Config::get('iam'); $c['redirect_uri'] = IamV2Client::origin($c['public_url']) . '/adminapi/iam/v2/self-link/callback'; return $c; }
|
||||
public function selfStart()
|
||||
{
|
||||
return $this->invokeV2(function () { $s = $this->session(true); $a = IamV2Source::admin((int) $s['admin_id']);
|
||||
if (IamV2Source::protectedAccount((int) $a['id'])) { throw new RuntimeException('PROTECTED_ACCOUNT_REVIEW_REQUIRED'); }
|
||||
$b = (new IamV2Manager())->binding((int) $s['admin_id'], true);
|
||||
if (($b['binding']['state'] ?? '') === 'verified') { throw new RuntimeException('BINDING_CONFLICT'); }
|
||||
$state = bin2hex(random_bytes(32)); $nonce = bin2hex(random_bytes(32)); $verifier = bin2hex(random_bytes(32));
|
||||
IamV2Flow::put('self_link', $state, ['sessionId' => $s['session_id'], 'nonce' => $nonce, 'verifier' => $verifier, 'version' => $b['binding']['version'] ?? null]);
|
||||
return ['url' => (new IamOidcClient($this->selfConfig()))->authorizationUrl($state, $nonce, $verifier) . '&prompt=login&max_age=300']; });
|
||||
}
|
||||
public function selfCallback()
|
||||
{
|
||||
try {
|
||||
$central = new IamV2Client(); $s = IamV2Source::check((string) ($_COOKIE[IamV2Source::COOKIE] ?? ''));
|
||||
$flow = IamV2Flow::take('self_link', (string) $this->request->get('state', ''), $s['session_id']);
|
||||
$oidc = new IamOidcClient($this->selfConfig()); $oidc->exchange((string) $this->request->get('code', ''), $flow['verifier'], $flow['nonce']);
|
||||
$proof = IamV2Source::proof($s, 'self_link', [(int) $s['admin_id']], [], null, ['oidcNonce' => $flow['nonce']]);
|
||||
$body = ['idToken' => $oidc->verifiedIdToken(), 'nonce' => $flow['nonce'], 'proof' => $proof, 'externalAccountId' => (string) $s['admin_id']];
|
||||
if ($flow['version'] !== null) { $body['expectedVersion'] = (int) $flow['version']; }
|
||||
$central->call('POST', '/self-link', $body);
|
||||
(new IamV2Manager($central))->binding((int) $s['admin_id'], true);
|
||||
return redirect('/admin/user/setting?iam_result=linked')->header(['Cache-Control' => 'no-store']);
|
||||
} catch (\Throwable $e) { return redirect('/admin/user/setting?iam_result=link_failed')->header(['Cache-Control' => 'no-store']); }
|
||||
}
|
||||
public function passwordSetup()
|
||||
{
|
||||
return $this->invokeV2(function () { $s = $this->session(true); $b = (new IamV2Manager())->assertLinked((int) $s['admin_id']);
|
||||
if (!($b['employee']['passwordSetupPending'] ?? false)) { throw new RuntimeException('PASSWORD_ALREADY_SET'); }
|
||||
$result = (new IamV2Client())->call('POST', '/password-setup', ['proof' => IamV2Source::proof($s, 'password_setup', [(int) $s['admin_id']])]);
|
||||
$url = parse_url((string) ($result['url'] ?? '')); $portal = parse_url(IamV2Client::origin((string) Config::get('iam.portal_url')));
|
||||
if (!$url || ($url['scheme'] ?? '') !== 'https' || ($url['host'] ?? '') !== $portal['host'] || ($url['port'] ?? 443) !== ($portal['port'] ?? 443) || isset($url['user']) || isset($url['pass']) || ($url['path'] ?? '') !== '/portal/password-setup') { throw new RuntimeException('INVALID_FIXED_URL'); }
|
||||
$intent = bin2hex(random_bytes(32)); IamV2Flow::put('password_setup', $intent, ['sessionId' => $s['session_id'], 'requestId' => $result['requestId']], 900);
|
||||
setcookie('ZYT_IAM_PASSWORD_INTENT', $intent, ['expires' => time() + 900, 'path' => '/adminapi/iam/v2', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax']);
|
||||
return $result; });
|
||||
}
|
||||
public function passwordContinue()
|
||||
{
|
||||
try {
|
||||
$client = new IamV2Client(); $s = IamV2Source::check((string) ($_COOKIE[IamV2Source::COOKIE] ?? ''));
|
||||
$f = IamV2Flow::take('password_setup', (string) ($_COOKIE['ZYT_IAM_PASSWORD_INTENT'] ?? ''), $s['session_id']);
|
||||
$id = (string) $this->request->get('request_id', ''); $state = (string) $this->request->get('state', '');
|
||||
if ($id !== $f['requestId'] || !preg_match('/^[A-Za-z0-9_-]{32,256}$/D', $state)) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
|
||||
$proof = IamV2Source::proof($s, 'password_setup', [(int) $s['admin_id']], [], $id, ['state' => $state]);
|
||||
$url = IamV2Client::origin((string) Config::get('iam.portal_url')) . '/portal/password-setup/authorize';
|
||||
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'); }
|
||||
}
|
||||
public function revocation()
|
||||
{
|
||||
return $this->serviceResult(fn() => \app\adminapi\service\iam\v2\IamV2Events::accept(
|
||||
(string) $this->request->getContent(), (string) $this->request->header('x-iam-signature', ''),
|
||||
(string) $this->request->header('x-iam-event-id', ''), (string) Config::get('iam.v2_event_secret', '')));
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,15 @@ class LoginController extends BaseAdminController
|
||||
public function account()
|
||||
{
|
||||
$params = (new LoginValidate())->post()->goCheck();
|
||||
return $this->data((new LoginLogic())->login($params));
|
||||
$result = (new LoginLogic())->login($params);
|
||||
// Enroll only a genuinely checked password, never the debug bypass.
|
||||
if (\app\adminapi\service\iam\v2\IamV2Client::enabled()) {
|
||||
$a = \think\facade\Db::name('admin')->where('account', $params['account'])->find();
|
||||
if ($a && hash_equals((string) $a['password'], create_password((string) $params['password'], \think\facade\Config::get('project.unique_identification')))) {
|
||||
\app\adminapi\service\iam\v2\IamV2Source::enroll($result['token'], 'password');
|
||||
}
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,6 +166,9 @@ class LoginController extends BaseAdminController
|
||||
public function logout()
|
||||
{
|
||||
//退出登录情况特殊,只有成功的情况,也不需要token验证
|
||||
if (\app\adminapi\service\iam\v2\IamV2Client::enabled()) {
|
||||
\app\adminapi\service\iam\v2\IamV2Source::revokeSession((string) ($_COOKIE[\app\adminapi\service\iam\v2\IamV2Source::COOKIE] ?? ''));
|
||||
}
|
||||
(new LoginLogic())->logout($this->adminInfo);
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
@@ -69,6 +69,20 @@ 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()) {
|
||||
$binding = \think\facade\Db::name('iam_v2_binding')->where('admin_id', $adminInfo['admin_id'])->find();
|
||||
if ($binding && $binding['state'] !== 'unlinked') {
|
||||
try {
|
||||
$b = (new \app\adminapi\service\iam\v2\IamV2Manager())->binding((int) $adminInfo['admin_id']);
|
||||
if (($b['binding']['state'] ?? '') !== 'verified' || !($b['access']['allowed'] ?? false) || ($b['employee']['status'] ?? '') !== 'active') {
|
||||
return JsonService::fail('统一账号关联已失效,请重新登录', [], -1);
|
||||
}
|
||||
} catch (\Throwable $e) { return JsonService::fail('统一身份服务暂时不可用,请稍后重试', [], 0); }
|
||||
}
|
||||
}
|
||||
|
||||
//给request赋值,用于控制器
|
||||
$request->adminInfo = $adminInfo;
|
||||
$request->adminId = $adminInfo['admin_id'] ?? 0;
|
||||
|
||||
@@ -222,6 +222,10 @@ class LoginLogic extends BaseLogic
|
||||
// 设置 token
|
||||
$adminInfo = AdminTokenService::setToken($admin->id, $terminal, $admin->multipoint_login);
|
||||
|
||||
if (\app\adminapi\service\iam\v2\IamV2Client::enabled()) {
|
||||
\app\adminapi\service\iam\v2\IamV2Source::enroll($adminInfo['token'], 'wecom');
|
||||
}
|
||||
|
||||
$avatar = $admin->avatar ?: Config::get('project.default_image.admin_avatar');
|
||||
$avatar = FileService::getFileUrl($avatar);
|
||||
|
||||
@@ -279,8 +283,8 @@ class LoginLogic extends BaseLogic
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
$result = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
use think\facade\Route;
|
||||
// Explicit method/path mapping; controller also fails closed for every v2 capability.
|
||||
Route::get('iam/v2/status', 'IamV2/status');
|
||||
Route::post('iam/v2/reauth', 'IamV2/reauth');
|
||||
Route::get('iam/v2/accounts', 'IamV2/accounts');
|
||||
Route::get('iam/v2/identities', 'IamV2/identities');
|
||||
Route::post('iam/v2/operations', 'IamV2/operation');
|
||||
Route::get('iam/v2/operations/:id', 'IamV2/operationResult');
|
||||
Route::get('iam/v2/accounts/:id/audit', 'IamV2/audit');
|
||||
Route::post('iam/v2/batch/preflight', 'IamV2/preflight');
|
||||
Route::post('iam/v2/batch', 'IamV2/batch');
|
||||
Route::get('iam/v2/jobs/:id', 'IamV2/job');
|
||||
Route::post('iam/v2/jobs/:id/retry', 'IamV2/retry');
|
||||
Route::post('iam/v2/proofs/introspect', 'IamV2/introspect');
|
||||
Route::post('iam/v2/sessions/check', 'IamV2/checkSession');
|
||||
Route::post('iam/v2/events/revocation', 'IamV2/revocation');
|
||||
Route::post('iam/v2/launch/start', 'IamV2/launchStart');
|
||||
Route::get('iam/v2/launch/continue', 'IamV2/launchContinue');
|
||||
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');
|
||||
@@ -36,6 +36,10 @@ 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) {
|
||||
|
||||
@@ -67,7 +67,7 @@ final class IamLoginService
|
||||
$claims = $this->client->exchange($code, $transaction['verifier'], $transaction['nonce']);
|
||||
$this->verifiedAdmin($claims['sub']);
|
||||
$ticket = bin2hex(random_bytes(32));
|
||||
$this->transactions->put('ticket', $ticket, $browser, ['subject' => $claims['sub']], 60);
|
||||
$this->transactions->put('ticket', $ticket, $browser, ['subject' => $claims['sub'], 'auth_time' => (int) ($claims['auth_time'] ?? 0)], 60);
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
@@ -79,7 +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']);
|
||||
return (array) (new LoginLogic())->login(['account' => $admin->account, 'terminal' => AdminTerminalEnum::PC]);
|
||||
$result = (array) (new LoginLogic())->login(['account' => $admin->account, 'terminal' => AdminTerminalEnum::PC]);
|
||||
if (\app\adminapi\service\iam\v2\IamV2Client::enabled()) {
|
||||
(new \app\adminapi\service\iam\v2\IamV2Manager())->assertLinked((int) $admin->id);
|
||||
\app\adminapi\service\iam\v2\IamV2Source::enroll($result['token'], 'oidc', true, (int) ($transaction['auth_time'] ?? 0));
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function boundAdminId(array $context, string $subject, string $applicationId): int
|
||||
|
||||
@@ -14,6 +14,13 @@ final class IamOidcClient
|
||||
private array $config;
|
||||
private $transport;
|
||||
private ?array $discovery = null;
|
||||
private ?string $verifiedToken = null;
|
||||
|
||||
public function verifiedIdToken(): string
|
||||
{
|
||||
if ($this->verifiedToken === null) { throw new RuntimeException("IAM token not verified"); }
|
||||
return $this->verifiedToken;
|
||||
}
|
||||
|
||||
/** Transport signature: (method, url, headers, body): ['status'=>int, 'body'=>string]. */
|
||||
public function __construct(array $config, ?callable $transport = null)
|
||||
@@ -102,6 +109,7 @@ final class IamOidcClient
|
||||
|| (isset($claims['nbf']) && (!is_int($claims['nbf']) || $claims['nbf'] > $now))) {
|
||||
throw new RuntimeException('IAM ID token claims are invalid');
|
||||
}
|
||||
$this->verifiedToken = $response['id_token'];
|
||||
return $claims;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace app\adminapi\service\iam\v2;
|
||||
|
||||
use RuntimeException;
|
||||
use think\facade\Config;
|
||||
|
||||
/** Dedicated v2 application credential; never falls back to the v1 provisioning token. */
|
||||
final class IamV2Client
|
||||
{
|
||||
public function __construct(private ?array $settings = null, private $transport = null)
|
||||
{
|
||||
$this->settings ??= (array) Config::get('iam', []);
|
||||
if (!self::enabled($this->settings)) { throw new RuntimeException('FEATURE_DISABLED'); }
|
||||
self::https((string) ($this->settings['api_url'] ?? ''));
|
||||
foreach (['public_url', 'erp_url'] as $field) { self::origin((string) ($this->settings[$field] ?? '')); }
|
||||
if (strlen((string) ($this->settings['v2_token'] ?? '')) < 32) { throw new RuntimeException('SERVICE_UNAVAILABLE'); }
|
||||
}
|
||||
public static function enabled(?array $settings = null): bool
|
||||
{
|
||||
$settings ??= (array) Config::get('iam', []);
|
||||
return filter_var($settings['v2_enabled'] ?? false, FILTER_VALIDATE_BOOL);
|
||||
}
|
||||
public static function https(string $url): string
|
||||
{
|
||||
$p = parse_url($url);
|
||||
if (!$p || ($p['scheme'] ?? '') !== 'https' || empty($p['host']) || isset($p['user']) || isset($p['pass'])
|
||||
|| isset($p['fragment']) || isset($p['query']) || preg_match('/[\x00-\x20\x7f\\\\]/', $url)) { throw new RuntimeException('INVALID_FIXED_URL'); }
|
||||
return rtrim($url, '/');
|
||||
}
|
||||
public static function origin(string $url): string
|
||||
{
|
||||
$url = self::https($url);
|
||||
if ((parse_url($url, PHP_URL_PATH) ?? '') !== '') { throw new RuntimeException('INVALID_FIXED_URL'); }
|
||||
return $url;
|
||||
}
|
||||
public function call(string $method, string $path, array $body = [], ?string $key = null): array
|
||||
{
|
||||
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']];
|
||||
if ($method !== 'GET') {
|
||||
$headers[] = 'X-Request-ID: ' . bin2hex(random_bytes(16));
|
||||
$headers[] = 'Idempotency-Key: ' . ($key ?? bin2hex(random_bytes(24)));
|
||||
}
|
||||
$data = $method === 'GET' ? '' : json_encode($body, JSON_THROW_ON_ERROR);
|
||||
if ($this->transport) { $r = ($this->transport)($method, $url, $headers, $data); }
|
||||
else {
|
||||
$c = curl_init($url); $response = '';
|
||||
curl_setopt_array($c, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_POSTFIELDS => $data, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_WRITEFUNCTION => static function ($c, $chunk) use (&$response) {
|
||||
if (strlen($response) + strlen($chunk) > 2097152) { return 0; } $response .= $chunk; return strlen($chunk);
|
||||
}]);
|
||||
$ok = curl_exec($c); $r = ['status' => curl_getinfo($c, CURLINFO_RESPONSE_CODE), 'body' => $ok === false ? '' : $response]; curl_close($c);
|
||||
}
|
||||
$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'])) {
|
||||
$code = $result['error']['code'] ?? 'SERVICE_UNAVAILABLE';
|
||||
throw new RuntimeException(preg_match('/^[A-Z_]{3,64}$/D', $code) ? $code : 'SERVICE_UNAVAILABLE');
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace app\adminapi\service\iam\v2;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
|
||||
final class IamV2Events
|
||||
{
|
||||
public static function accept(string $raw, string $signature, string $eventId, string $secret, ?callable $revoke = 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);
|
||||
if (empty($event['employeeId']) || $eventId === '' || strlen($eventId) > 128 || $eventId !== ($event['id'] ?? '') || !in_array('zyt', (array) ($event['applicationIds'] ?? []), true)) { throw new RuntimeException('INVALID_EVENT'); }
|
||||
$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'); }
|
||||
$revoke ??= fn(int $id) => IamV2Manager::revoke($id);
|
||||
return Db::transaction(function () use ($event, $eventId, $raw, $revoke) {
|
||||
$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'); }
|
||||
return ['accepted' => true, 'duplicate' => true];
|
||||
}
|
||||
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();
|
||||
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']);
|
||||
}
|
||||
return ['accepted' => true];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace app\adminapi\service\iam\v2;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
|
||||
final class IamV2Flow
|
||||
{
|
||||
public static function put(string $kind, string $secret, array $payload, int $ttl = 300): void
|
||||
{
|
||||
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 take(string $kind, string $secret, string $sid): array
|
||||
{
|
||||
return Db::transaction(function () use ($kind, $secret, $sid) {
|
||||
$key = hash('sha256', $kind . ':' . $secret);
|
||||
$row = Db::name('iam_v2_flow')->where('flow_key', $key)->lock(true)->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'); }
|
||||
if (Db::name('iam_v2_flow')->where('flow_key', $key)->where('consumed', 0)->update(['consumed' => 1]) !== 1) { throw new RuntimeException('TRANSACTION_EXPIRED'); }
|
||||
return $p;
|
||||
});
|
||||
}
|
||||
public static function form(string $url, array $fields): string
|
||||
{
|
||||
$nonce = bin2hex(random_bytes(18));
|
||||
$escape = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$inputs = '';
|
||||
foreach ($fields as $name => $value) { $inputs .= '<input type="hidden" name="' . $escape($name) . '" value="' . $escape($value) . '">'; }
|
||||
$origin = parse_url($url, PHP_URL_SCHEME) . '://' . parse_url($url, PHP_URL_HOST) . (parse_url($url, PHP_URL_PORT) ? ':' . parse_url($url, PHP_URL_PORT) : '');
|
||||
return '<!doctype html><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src \'none\'; base-uri \'none\'; form-action ' . $escape($origin) . '; script-src \'nonce-' . $nonce . '\'"><title>正在验证身份</title><form method="post" action="' . $escape($url) . '">' . $inputs . '<button>继续</button></form><script nonce="' . $nonce . '">document.forms[0].submit()</script>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace app\adminapi\service\iam\v2;
|
||||
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
use think\facade\Config;
|
||||
use app\adminapi\logic\auth\AdminLogic;
|
||||
|
||||
final class IamV2Manager
|
||||
{
|
||||
public function __construct(private ?IamV2Client $client = null) { $this->client ??= new IamV2Client(); }
|
||||
public function binding(int $id, bool $fresh = false): array
|
||||
{
|
||||
$row = Db::name('iam_v2_binding')->where('admin_id', $id)->find();
|
||||
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'); }
|
||||
$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'); }
|
||||
if ($row && (int) $row['version'] > $version) { throw new RuntimeException('BINDING_CONFLICT'); }
|
||||
$values = ['admin_id' => $id, 'employee_id' => $result['employee']['id'] ?? '', 'subject' => $result['employee']['oidcSubject'] ?? '',
|
||||
'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)))) {
|
||||
self::revoke($id);
|
||||
}
|
||||
return $result + ['checked_at' => time()];
|
||||
}
|
||||
public static function revoke(int $id): void
|
||||
{
|
||||
IamV2Source::revokeAdmin($id);
|
||||
foreach (Db::name('admin_session')->where('admin_id', $id)->column('token') as $token) { AdminLogic::expireToken($token); }
|
||||
}
|
||||
public function assertLinked(int $id): array
|
||||
{
|
||||
$b = $this->binding($id, true);
|
||||
if (($b['binding']['state'] ?? '') !== 'verified') { throw new RuntimeException('NOT_LINKED'); }
|
||||
if (($b['employee']['status'] ?? '') !== 'active' || !($b['access']['allowed'] ?? false)) { throw new RuntimeException('NOT_AUTHORIZED'); }
|
||||
return $b;
|
||||
}
|
||||
public function accounts(array $query, int $actor): array
|
||||
{
|
||||
$page = max(1, (int) ($query['page'] ?? 1)); $size = min(20, max(1, (int) ($query['pageSize'] ?? 20)));
|
||||
$db = Db::name('admin')->whereNull('delete_time');
|
||||
$visible = IamV2Source::visibleIds($actor);
|
||||
if ($visible !== null) { $db->whereIn('id', $visible ?: [0]); }
|
||||
if (($query['q'] ?? '') !== '') { $db->whereLike('account|name', '%' . mb_substr((string) $query['q'], 0, 64) . '%'); }
|
||||
if (($query['state'] ?? '') !== '') {
|
||||
$ids = Db::name('iam_v2_binding')->where('state', (string) $query['state'])->column('admin_id');
|
||||
if ($query['state'] === 'unknown') { $db->whereNotIn('id', Db::name('iam_v2_binding')->column('admin_id')); }
|
||||
else { $db->whereIn('id', $ids ?: [0]); }
|
||||
}
|
||||
$total = (clone $db)->count();
|
||||
$items = $db->field('id,account,name,disable,root')->page($page, $size)->select()->toArray();
|
||||
foreach ($items as &$r) {
|
||||
try { $r['iam'] = $this->binding((int) $r['id']); $r['iam']['state'] = $r['iam']['binding']['state'] ?? 'unlinked'; }
|
||||
catch (\Throwable $e) { $r['iam'] = ['state' => 'unknown', 'error' => 'SERVICE_UNAVAILABLE']; }
|
||||
}
|
||||
return ['items' => $items, 'total' => $total];
|
||||
}
|
||||
public static function input(array $input): array
|
||||
{
|
||||
$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 (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'); }
|
||||
if ($kind === 'create_and_bind') {
|
||||
$n = $input['new_employee'] ?? [];
|
||||
$out['newEmployee'] = ['username' => (string) ($n['username'] ?? ''), 'displayName' => (string) ($n['display_name'] ?? '')];
|
||||
if (!preg_match('/^[A-Za-z0-9_.@-]{3,64}$/D', $out['newEmployee']['username']) || trim($out['newEmployee']['displayName']) === '') { throw new RuntimeException('INVALID_INPUT'); }
|
||||
if (!empty($n['email'])) {
|
||||
if (!filter_var($n['email'], FILTER_VALIDATE_EMAIL)) { throw new RuntimeException('INVALID_INPUT'); }
|
||||
$out['newEmployee']['email'] = $n['email'];
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
public static function cap(string $kind): string { return ['create_and_bind' => 'create', 'reconcile' => 'read'][$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]);
|
||||
$op = $this->client->call('POST', '/operations', $body, $requestId);
|
||||
$this->remember('operation', (string) $op['id'], $session, [$target], ['iam:' . $cap]);
|
||||
if (($op['status'] ?? '') === 'succeeded') { $this->binding($target, true); }
|
||||
return $op;
|
||||
}
|
||||
public function remember(string $kind, string $id, array $session, array $targets, array $caps = []): void
|
||||
{
|
||||
$key = hash('sha256', $kind . ':' . $id);
|
||||
$existing = Db::name('iam_v2_resource')->where('resource_key', $key)->find();
|
||||
if ($existing) { if ((int) $existing['actor_id'] !== (int) $session['admin_id']) { throw new RuntimeException('FORBIDDEN'); } return; }
|
||||
Db::name('iam_v2_resource')->insert(['resource_key' => $key, 'kind' => $kind, 'external_id' => $id, 'actor_id' => $session['admin_id'],
|
||||
'payload' => json_encode(['targets' => $targets, 'capabilities' => $caps]), 'created_at' => time()]);
|
||||
}
|
||||
public function owned(string $kind, string $id, array $session): array
|
||||
{
|
||||
$r = Db::name('iam_v2_resource')->where('resource_key', hash('sha256', $kind . ':' . $id))->where('actor_id', $session['admin_id'])->find();
|
||||
if (!$r) { throw new RuntimeException('FORBIDDEN'); }
|
||||
$payload = json_decode($r['payload'], true, 32, JSON_THROW_ON_ERROR);
|
||||
IamV2Source::scope((int) $session['admin_id'], $payload['targets']);
|
||||
return $payload;
|
||||
}
|
||||
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 = [];
|
||||
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;
|
||||
} catch (RuntimeException $e) {
|
||||
$review[] = ['index' => (int) $index, 'externalAccountId' => (string) ($input['user_id'] ?? ''),
|
||||
'status' => $e->getMessage() === 'PROTECTED_ACCOUNT_REVIEW_REQUIRED' ? 'review' : 'invalid', 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
if (!$mapped) { return ['previewId' => null, 'items' => $review, 'expiresAt' => time()]; }
|
||||
$r = $this->client->call('POST', '/batch/preflight', ['items' => $mapped,
|
||||
'proof' => IamV2Source::proof($session, 'management', $targets, array_values(array_unique($caps)))]);
|
||||
$this->remember('preview', (string) $r['previewId'], $session, $targets, $caps);
|
||||
$resource = hash('sha256', 'preview:' . $r['previewId']);
|
||||
$ready = [];
|
||||
foreach ($r['items'] as &$item) {
|
||||
$centralIndex = (int) $item['index'];
|
||||
if (!array_key_exists($centralIndex, $indexMap)) { throw new RuntimeException('INVALID_RESPONSE'); }
|
||||
$item['index'] = $indexMap[$centralIndex];
|
||||
if ($item['status'] === 'ready') { $ready[] = $item['index']; }
|
||||
}
|
||||
unset($item);
|
||||
Db::name('iam_v2_resource')->where('resource_key', $resource)->update(['payload' => json_encode([
|
||||
'targets' => $targets, 'capabilities' => $caps, 'indexMap' => $indexMap, 'ready' => $ready])]);
|
||||
$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']); }
|
||||
$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);
|
||||
$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;
|
||||
}
|
||||
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'])]);
|
||||
} else { $r = $this->client->call('GET', $path); }
|
||||
foreach ($scope['targets'] as $target) { $this->binding((int) $target, true); } return $r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace app\adminapi\service\iam\v2;
|
||||
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
use think\facade\Config;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\common\cache\AdminAccountSafeCache;
|
||||
|
||||
/** Browser source sessions are separate from legacy account/terminal shared business tokens. */
|
||||
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'); }
|
||||
$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); }
|
||||
$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)];
|
||||
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']);
|
||||
$_COOKIE[self::COOKIE] = $sid;
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
private static function accountStamp(array $admin): string
|
||||
{
|
||||
return hash('sha256', json_encode([$admin['account'], $admin['password'], $admin['work_wechat_userid'] ?? '', (int) $admin['root']], JSON_THROW_ON_ERROR));
|
||||
}
|
||||
public static function admin(int $id): array
|
||||
{
|
||||
$a = Db::name('admin')->where('id', $id)->find();
|
||||
if (!$a || (int) $a['disable'] !== 0 || !empty($a['delete_time'])) { throw new RuntimeException('ACCOUNT_DISABLED'); }
|
||||
return $a;
|
||||
}
|
||||
public static function current(string $token, bool $recent = false): array
|
||||
{
|
||||
$s = self::check((string) ($_COOKIE[self::COOKIE] ?? ''));
|
||||
if (!hash_equals($s['token_hash'], hash('sha256', $token))) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
|
||||
if ($recent && $s['auth_time'] < time() - 300) { throw new RuntimeException('REAUTH_REQUIRED'); }
|
||||
return $s;
|
||||
}
|
||||
public static function check(string $id, ?int $version = null): array
|
||||
{
|
||||
$s = Db::name('iam_v2_source_session')->where('session_id', $id)->find();
|
||||
if (!$s || (int) $s['revoked'] !== 0 || $s['expires_at'] <= time() || ($version !== null && (int) $s['version'] !== $version)) { throw new RuntimeException('REAUTH_REQUIRED'); }
|
||||
$admin = self::admin((int) $s['admin_id']);
|
||||
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'); }
|
||||
return $s;
|
||||
}
|
||||
public static function reauth(string $token, string $password): array
|
||||
{
|
||||
$legacy = Db::name('admin_session')->where('token', $token)->where('expire_time', '>', time())->find();
|
||||
if (!$legacy || $password === '' || strlen($password) > 256) { throw new RuntimeException('REAUTH_REQUIRED'); }
|
||||
$a = self::admin((int) $legacy['admin_id']);
|
||||
$limiter = new AdminAccountSafeCache();
|
||||
if (!$limiter->isSafe()) { throw new RuntimeException('RATE_LIMITED'); }
|
||||
// Deliberately does NOT invoke LoginValidate's local-debug password bypass.
|
||||
if (!hash_equals((string) $a['password'], create_password($password, Config::get('project.unique_identification')))) {
|
||||
$limiter->record(); throw new RuntimeException('REAUTH_REQUIRED');
|
||||
}
|
||||
$limiter->relieve(); return self::enroll($token, 'password');
|
||||
}
|
||||
public static function capabilities(int $id): array
|
||||
{
|
||||
$admin = self::admin($id);
|
||||
$all = ['read', 'bind', 'create', 'unbind', 'rebind', 'batch'];
|
||||
if ((int) $admin['root'] === 1) { return $all; }
|
||||
$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
|
||||
{
|
||||
$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;
|
||||
}
|
||||
public static function visibleIds(int $id): ?array
|
||||
{
|
||||
$a = self::admin($id);
|
||||
$a['role_id'] = Db::name('admin_role')->where('admin_id', $id)->column('role_id');
|
||||
return \app\common\service\DataScope\DataScopeService::getVisibleAdminIds($id, $a);
|
||||
}
|
||||
public static function scope(int $actor, array $targets): void
|
||||
{
|
||||
$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
|
||||
{
|
||||
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)) {
|
||||
throw new RuntimeException('PROTECTED_ACCOUNT_REVIEW_REQUIRED');
|
||||
}
|
||||
}
|
||||
}
|
||||
public static function proof(array $session, string $purpose, array $targets = [], array $capabilities = [], ?string $transaction = null, array $extra = []): string
|
||||
{
|
||||
self::check($session['session_id'], (int) $session['version']);
|
||||
$secret = bin2hex(random_bytes(32));
|
||||
$payload = ['applicationId' => (string) Config::get('iam.application_id', 'zyt'), 'actorLocalId' => (string) $session['admin_id'],
|
||||
'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']));
|
||||
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
|
||||
{
|
||||
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();
|
||||
if (!$r || $r['purpose'] !== $purpose || $r['expires_at'] <= time() || (int) $r['consumed'] !== 0) { throw new RuntimeException('TRANSACTION_EXPIRED'); }
|
||||
$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']);
|
||||
foreach ($p['capabilities'] as $cap) {
|
||||
if (!str_starts_with($cap, 'iam:')) { throw new RuntimeException('FORBIDDEN'); }
|
||||
self::authorize($session, substr($cap, 4), $p['targetLocalIds']);
|
||||
}
|
||||
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;
|
||||
});
|
||||
}
|
||||
public static function revokeSession(string $id): void
|
||||
{
|
||||
Db::name('iam_v2_source_session')->where('session_id', $id)->inc('version')->update(['revoked' => 1]);
|
||||
}
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
// Optional integration: disabled installations never touch IAM or its transaction store.
|
||||
return [
|
||||
'v2_enabled' => env('iam.v2_enabled', false),
|
||||
'v2_token' => env('iam.v2_token', ''),
|
||||
'v2_introspection_token' => env('iam.v2_introspection_token', ''),
|
||||
'v2_event_secret' => env('iam.v2_event_secret', ''),
|
||||
'erp_url' => env('iam.erp_url', ''),
|
||||
'portal_url' => env('iam.portal_url', ''),
|
||||
'enabled' => env('iam.enabled', false),
|
||||
'issuer' => env('iam.issuer', ''),
|
||||
'client_id' => env('iam.client_id', 'zyt'),
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Additive only. Preserve all six 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,
|
||||
admin_id INT UNSIGNED NOT NULL, business_session_id INT UNSIGNED NOT NULL,
|
||||
token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
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,
|
||||
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 (
|
||||
proof_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
|
||||
purpose VARCHAR(32) NOT NULL, session_id CHAR(64) NOT NULL, payload TEXT NOT NULL,
|
||||
expires_at INT UNSIGNED NOT NULL, consumed TINYINT NOT NULL DEFAULT 0, INDEX idx_expiry(expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS zyt_iam_v2_binding (
|
||||
admin_id INT UNSIGNED PRIMARY KEY, employee_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
subject VARCHAR(255) NOT NULL DEFAULT '', state VARCHAR(32) NOT NULL,
|
||||
version BIGINT UNSIGNED NOT NULL DEFAULT 0, payload MEDIUMTEXT NOT NULL,
|
||||
checked_at INT UNSIGNED NOT NULL DEFAULT 0, INDEX idx_employee(employee_id), INDEX idx_subject(subject)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS zyt_iam_v2_resource (
|
||||
resource_key CHAR(64) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
|
||||
kind VARCHAR(32) NOT NULL, external_id VARCHAR(128) NOT NULL, actor_id INT UNSIGNED NOT NULL,
|
||||
payload MEDIUMTEXT NOT NULL, created_at INT UNSIGNED NOT NULL, INDEX idx_actor(actor_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS zyt_iam_v2_flow (
|
||||
flow_key CHAR(64) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
|
||||
kind VARCHAR(32) NOT NULL, payload TEXT NOT NULL, expires_at INT UNSIGNED NOT NULL,
|
||||
consumed TINYINT NOT NULL DEFAULT 0, INDEX idx_expiry(expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
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;
|
||||
|
||||
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)
|
||||
SELECT COALESCE(@iam_parent,0),'C','IAM关联管理','el-icon-Connection',85,'iamV2/read','identity-management','permission/identity/index','','',0,1,0,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms='iamV2/read');
|
||||
SET @iam_menu := (SELECT id FROM zyt_system_menu WHERE perms='iamV2/read' 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)
|
||||
SELECT @iam_menu,'A','绑定IAM','',0,'iamV2/bind','','','','',0,1,0,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms='iamV2/bind');
|
||||
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)
|
||||
SELECT @iam_menu,'A','创建并关联IAM','',0,'iamV2/create','','','','',0,1,0,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms='iamV2/create');
|
||||
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)
|
||||
SELECT @iam_menu,'A','解除IAM关联','',0,'iamV2/unbind','','','','',0,1,0,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms='iamV2/unbind');
|
||||
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)
|
||||
SELECT @iam_menu,'A','更换IAM关联','',0,'iamV2/rebind','','','','',0,1,0,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms='iamV2/rebind');
|
||||
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)
|
||||
SELECT @iam_menu,'A','批量IAM任务','',0,'iamV2/batch','','','','',0,1,0,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms='iamV2/batch');
|
||||
COMMIT;
|
||||
-- Deliberately no system_role_menu grants: assign capabilities explicitly after review.
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
function requireContract(bool $condition, string $message): void { if (!$condition) { throw new RuntimeException($message); } }
|
||||
$root = dirname(__DIR__);
|
||||
$routes = file_get_contents($root . '/app/adminapi/route/app.php');
|
||||
$controller = file_get_contents($root . '/app/adminapi/controller/IamV2Controller.php');
|
||||
$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) {
|
||||
requireContract(str_contains($routes,"Route::post('iam/v2/".$route."'"), 'POST registration ' . $route);
|
||||
}
|
||||
requireContract(str_contains($controller, "METHOD_NOT_ALLOWED"), 'default auto-route verbs checked');
|
||||
requireContract(str_contains($controller,"hash_equals('Bearer ' . \$expected"), 'source service secret constant-time');
|
||||
requireContract(str_contains($controller,'INVALID_ORIGIN'), 'browser mutation origin');
|
||||
requireContract(str_contains($source,"self::check(\$p['sessionId']"), 'proof checks live session');
|
||||
requireContract(str_contains($source,"self::authorize(\$session, substr(\$cap, 4)"), 'proof rechecks live permission');
|
||||
requireContract(str_contains($source,"self::scope((int) \$session['admin_id'], \$targets)"), 'target data scope');
|
||||
requireContract(str_contains($client,'CURLOPT_FOLLOWLOCATION => false') && str_contains($client,'CURLOPT_SSL_VERIFYPEER => true'), 'TLS fixed host no redirect');
|
||||
requireContract(str_contains($config,"'v2_enabled' => env('iam.v2_enabled', false)"), 'default OFF');
|
||||
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');
|
||||
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";
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
/** Disposable SQLite only: no .env, provider calls or real identities. */
|
||||
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\{IamV2Source, IamV2Flow, IamV2Client, IamV2Manager, IamV2Events};
|
||||
function ok(bool $x, string $m): void { if (!$x) { throw new RuntimeException('FAIL ' . $m); } }
|
||||
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-');
|
||||
$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');
|
||||
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,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_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)',
|
||||
'CREATE TABLE iam_v2_event (event_id TEXT PRIMARY KEY, digest TEXT, received_at INTEGER)',
|
||||
'CREATE TABLE iam_v2_resource (resource_key TEXT PRIMARY KEY,kind TEXT,external_id TEXT,actor_id INTEGER,payload TEXT,created_at INTEGER)',
|
||||
] as $sql) { Db::execute($sql); }
|
||||
Db::name('admin')->insertAll([['id'=>1,'account'=>'operator','name'=>'Synthetic Operator','password'=>'not-real','disable'=>0,'root'=>1],['id'=>2,'account'=>'employee','name'=>'Synthetic Employee','password'=>'not-real','disable'=>0,'root'=>0]]);
|
||||
Db::name('admin_session')->insertAll([['id'=>1,'admin_id'=>1,'terminal'=>1,'token'=>'fixture-token-1','expire_time'=>time()+3600],['id'=>2,'admin_id'=>2,'terminal'=>1,'token'=>'fixture-token-2','expire_time'=>time()+3600]]);
|
||||
denies(fn()=>IamV2Source::current('fixture-token-1'),'REAUTH_REQUIRED');
|
||||
$s1=IamV2Source::enroll('fixture-token-1','password',false); $s2=IamV2Source::enroll('fixture-token-1','password',false);
|
||||
ok($s1['session_id']!==$s2['session_id'],'independent browser sessions despite same business token');
|
||||
$_COOKIE[IamV2Source::COOKIE]=$s1['session_id'];
|
||||
ok(IamV2Source::current('fixture-token-1')['auth_method']==='password','immutable method');
|
||||
denies(fn()=>IamV2Source::current('fixture-token-2'),'ACCOUNT_MISMATCH');
|
||||
denies(fn()=>IamV2Source::enroll('fixture-token-1','legacy',false),'REAUTH_REQUIRED');
|
||||
IamV2Source::authorize($s1,'bind',[2]);
|
||||
denies(fn()=>IamV2Source::authorize($s1,'bind',[1]),'PROTECTED_ACCOUNT_REVIEW_REQUIRED');
|
||||
$proof=IamV2Source::proof($s1,'launch',[1],[],'tx-fixture');
|
||||
ok(!str_contains(json_encode(Db::name('iam_v2_proof')->select()->toArray()),$proof),'proof plaintext never stored');
|
||||
denies(fn()=>IamV2Source::introspect($proof,'management','tx-fixture'),'TRANSACTION_EXPIRED');
|
||||
denies(fn()=>IamV2Source::introspect($proof,'launch','other'),'ACCOUNT_MISMATCH');
|
||||
$claims=IamV2Source::introspect($proof,'launch','tx-fixture');
|
||||
ok($claims['active'] && $claims['actorLocalId']==='1' && !isset($claims['token']),'service claims no business token');
|
||||
denies(fn()=>IamV2Source::introspect($proof,'launch','tx-fixture'),'TRANSACTION_EXPIRED');
|
||||
IamV2Flow::put('launch','intent',['sessionId'=>$s1['session_id']]);
|
||||
denies(fn()=>IamV2Flow::take('launch','intent',$s2['session_id']),'ACCOUNT_MISMATCH');
|
||||
IamV2Flow::take('launch','intent',$s1['session_id']); denies(fn()=>IamV2Flow::take('launch','intent',$s1['session_id']),'TRANSACTION_EXPIRED');
|
||||
$expired=IamV2Source::proof($s1,'launch'); Db::name('iam_v2_proof')->where('proof_hash',hash('sha256',$expired))->update(['expires_at'=>time()-1]);
|
||||
denies(fn()=>IamV2Source::introspect($expired,'launch'),'TRANSACTION_EXPIRED');
|
||||
$oldAuth=$s1['auth_time']; Db::name('admin_session')->where('id',1)->update(['update_time'=>time()+30,'expire_time'=>time()+7200]);
|
||||
ok(IamV2Source::check($s1['session_id'])['auth_time']===$oldAuth,'business renewal never freshens authentication');
|
||||
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'];
|
||||
$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]];
|
||||
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];
|
||||
$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');
|
||||
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');
|
||||
$event['reason']='changed';$other=json_encode($event);denies(fn()=>IamV2Events::accept($other,'sha256='.hash_hmac('sha256',$other,$secret),'evt-new',$secret,$revoke),'INVALID_EVENT');
|
||||
$event['id']='future';$event['occurredAt']=gmdate('Y-m-d\TH:i:s\Z',time()+120);$future=json_encode($event);denies(fn()=>IamV2Events::accept($future,'sha256='.hash_hmac('sha256',$future,$secret),'future',$secret,$revoke),'TRANSACTION_EXPIRED');
|
||||
$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');
|
||||
$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;
|
||||
$claims=IamV2Source::introspect($data['proof'],'management');
|
||||
ok($claims['targetLocalIds']===['2'],'only authorized safe target in central proof');
|
||||
ok(in_array('iam:bind',$claims['capabilities'],true),'explicit binding capability');
|
||||
if(str_ends_with($url,'/batch/preflight'))return ['status'=>200,'body'=>json_encode(['previewId'=>'preview-fixture','items'=>[['index'=>0,'externalAccountId'=>'2','status'=>'ready']],'expiresAt'=>time()+300])];
|
||||
ok($data['indexes']===[0],'original selected index mapped to central filtered index');
|
||||
return ['status'=>200,'body'=>json_encode(['id'=>'job-fixture','status'=>'pending','items'=>[]])];
|
||||
});
|
||||
$batchService=new IamV2Manager($batchClient);
|
||||
$preview=$batchService->preflight($s2,[['kind'=>'bind','user_id'=>1,'employee_id'=>'protected','reason'=>'fixture'],['kind'=>'bind','user_id'=>2,'employee_id'=>'normal','reason'=>'fixture']]);
|
||||
ok($preview['items'][0]['status']==='review' && $preview['items'][1]['index']===1,'bulk protected skipped, original indexes retained');
|
||||
denies(fn()=>$batchService->batch($s2,'preview-fixture',[0],'fixture-key'),'INVALID_INPUT');
|
||||
$job=$batchService->batch($s2,'preview-fixture',[1],'fixture-key');ok($job['id']==='job-fixture','explicit safe selection starts job');
|
||||
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');
|
||||
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');
|
||||
$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";
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
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']
|
||||
for(const file of files){
|
||||
const source=fs.readFileSync(new URL(file,root),'utf8'), {descriptor,errors}=sfc.parse(source,{filename:file})
|
||||
assert.deepEqual(errors,[])
|
||||
const script=sfc.compileScript(descriptor,{id:file})
|
||||
const template=sfc.compileTemplate({source:descriptor.template.content,filename:file,id:file,compilerOptions:{bindingMetadata:script.bindings}})
|
||||
assert.deepEqual(template.errors,[],file)
|
||||
}
|
||||
const source=fs.readFileSync(new URL(files[0],root),'utf8'), script=source.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
|
||||
const js=ts.transpileModule(script,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText.replace(/^.*require\(.+\);?$/gm,'').replace(/^Object.defineProperty\(exports.*$/gm,'')
|
||||
async function scenario({enabled=true,fail=false,blocked=false}={}){
|
||||
const events=[],tab={opener:'origin',location:{href:'about:blank'},close(){events.push('close')}},ref=v=>({value:v});let mounted
|
||||
const context={exports:{},vue_1:{ref,onMounted:f=>{mounted=f}},element_plus_1:{ElMessage:{warning:m=>events.push(m)}},iam_v2_1:{iamGet:async()=>({enabled}),iamPost:async(path)=>{events.push(path);assert.equal(tab.opener,null);if(fail)throw{error:{code:'REAUTH_REQUIRED'}};return{url:'https://erp.example.test/iam/launch?source=zyt&intent=opaque'}},identityError:()=> 'reauth'},window:{open:(url,target)=>{events.push(['open',url,target]);return blocked?null:tab}}}
|
||||
context.iam_v2_1.identityError=()=> 'reauth'
|
||||
vm.createContext(context);vm.runInContext(js,context);await mounted();return{context,events,tab}
|
||||
}
|
||||
let s=await scenario({enabled:false});assert.equal(vm.runInContext('enabled.value',s.context),false);assert.deepEqual(s.events,[])
|
||||
s=await scenario();await vm.runInContext('launch()',s.context);assert.equal(s.events[0][0],'open');assert.equal(s.events[1],'/launch/start');assert.match(s.tab.location.href,/intent=opaque/);assert.equal(s.tab.opener,null)
|
||||
s=await scenario({fail:true});await vm.runInContext('launch()',s.context);assert.ok(s.events.includes('close'));assert.equal(vm.runInContext('busy.value',s.context),false)
|
||||
const management=fs.readFileSync(new URL(files[2],root),'utf8')
|
||||
assert.match(management,/核对本地账号/);assert.match(management,/核对 IAM 身份/);assert.match(management,/确认变更/)
|
||||
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')
|
||||
Reference in New Issue
Block a user