mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-09-04 23:13:26 +08:00
feat(mcp): 密钥授权页,仅创建者拥有的节点可授权(防越权与漂移)
This commit is contained in:
@@ -3,9 +3,12 @@
|
|||||||
namespace base\admin\controller\system;
|
namespace base\admin\controller\system;
|
||||||
|
|
||||||
use app\admin\model\SystemAdmin;
|
use app\admin\model\SystemAdmin;
|
||||||
|
use app\admin\model\SystemMcpKeyNode;
|
||||||
use app\admin\service\annotation\ControllerAnnotation;
|
use app\admin\service\annotation\ControllerAnnotation;
|
||||||
use app\admin\service\annotation\NodeAnotation;
|
use app\admin\service\annotation\NodeAnotation;
|
||||||
|
use app\admin\service\NodeService;
|
||||||
use app\common\controller\AdminController;
|
use app\common\controller\AdminController;
|
||||||
|
use app\common\service\AuthService;
|
||||||
use think\App;
|
use think\App;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,4 +142,120 @@ class McpKeyBase extends AdminController
|
|||||||
|
|
||||||
return $this->fetch();
|
return $this->fetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 授权页:勾选创建者当前权限的子集给密钥.
|
||||||
|
* @NodeAnotation(title="授权")
|
||||||
|
*/
|
||||||
|
public function authorize($id)
|
||||||
|
{
|
||||||
|
$row = $this->model->find($id);
|
||||||
|
empty($row) && $this->error('数据不存在');
|
||||||
|
|
||||||
|
// 全量动作节点来自 getNodeTree 展开全部 type=2 节点
|
||||||
|
$nodeService = new NodeService();
|
||||||
|
$module_list = $nodeService->getNodeTree();
|
||||||
|
|
||||||
|
// 创建者当前节点集合:与 checkNode 完全一致的判定链(含超管直通/auth_on/动态黑名单)
|
||||||
|
$authService = new AuthService($row->bind_admin_id);
|
||||||
|
$allActionNodes = [];
|
||||||
|
foreach ($module_list as $module) {
|
||||||
|
foreach ($module['children'] as $controller) {
|
||||||
|
foreach ($controller['children'] as $action) {
|
||||||
|
$allActionNodes[] = $action['node'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$creatorAllowedNodes = $authService->getAdminAllowedNodes($allActionNodes, $row->bind_admin_id);
|
||||||
|
|
||||||
|
// 已选节点 = 库中记录 ∩ 创建者当前集合(防漂移:创建者权限被回收后旧勾选自动失效)
|
||||||
|
$checkedNodeList = (new SystemMcpKeyNode())
|
||||||
|
->where('key_id', $id)
|
||||||
|
->column('node');
|
||||||
|
$checkedNodeList = array_values(array_intersect($checkedNodeList, $creatorAllowedNodes));
|
||||||
|
|
||||||
|
// 树过滤:动作节点不在创建者集合内整级剔除;无动作节点的控制器、无控制器的模块不显示
|
||||||
|
foreach ($module_list as $module_key => $module) {
|
||||||
|
foreach ($module['children'] as $controllerKey => $controller) {
|
||||||
|
foreach ($controller['children'] as $actionKey => $action) {
|
||||||
|
if (!in_array($action['node'], $creatorAllowedNodes)) {
|
||||||
|
unset($module_list[$module_key]['children'][$controllerKey]['children'][$actionKey]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$checked = in_array($action['node'], $checkedNodeList);
|
||||||
|
$checked_string = $checked ? 'checked' : '';
|
||||||
|
$disabled_string = '';
|
||||||
|
if (!$action['auth']) {
|
||||||
|
// 免鉴权节点创建者恒有权限,照 auth/authorize 惯例固定勾选并禁用
|
||||||
|
$checked_string = 'checked';
|
||||||
|
$disabled_string = 'disabled';
|
||||||
|
}
|
||||||
|
$module_list[$module_key]['children'][$controllerKey]['children'][$actionKey]['checked'] = $checked_string;
|
||||||
|
$module_list[$module_key]['children'][$controllerKey]['children'][$actionKey]['disabled'] = $disabled_string;
|
||||||
|
}
|
||||||
|
if (empty($module_list[$module_key]['children'][$controllerKey]['children'])) {
|
||||||
|
unset($module_list[$module_key]['children'][$controllerKey]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (empty($module_list[$module_key]['children'])) {
|
||||||
|
unset($module_list[$module_key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assign('row', $row);
|
||||||
|
$this->assign('checked_node_list', $checkedNodeList);
|
||||||
|
$this->assign('module_list', array_values($module_list));
|
||||||
|
|
||||||
|
return $this->fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 授权保存:先删后插,服务端强校验防越权.
|
||||||
|
* @NodeAnotation(title="授权保存")
|
||||||
|
*/
|
||||||
|
public function saveAuthorize()
|
||||||
|
{
|
||||||
|
$this->checkPostRequest();
|
||||||
|
$id = $this->request->post('id');
|
||||||
|
$node = (array) $this->request->post('node', []);
|
||||||
|
|
||||||
|
$row = $this->model->find($id);
|
||||||
|
empty($row) && $this->error('数据不存在');
|
||||||
|
|
||||||
|
// 服务端强校验:每个提交节点必须 ∈ 创建者当前节点集合(重新计算,防权限已变)
|
||||||
|
$nodeService = new NodeService();
|
||||||
|
$allActionNodes = [];
|
||||||
|
foreach ($nodeService->getNodeTree() as $module) {
|
||||||
|
foreach ($module['children'] as $controller) {
|
||||||
|
foreach ($controller['children'] as $action) {
|
||||||
|
$allActionNodes[] = $action['node'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$authService = new AuthService($row->bind_admin_id);
|
||||||
|
$creatorAllowedNodes = $authService->getAdminAllowedNodes($allActionNodes, $row->bind_admin_id);
|
||||||
|
foreach ($node as $vo) {
|
||||||
|
if (!in_array($authService->parseNodeStr($vo), $creatorAllowedNodes)) {
|
||||||
|
$this->error('包含无权限节点');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$keyNode = new SystemMcpKeyNode();
|
||||||
|
$keyNode->where('key_id', $id)->delete();
|
||||||
|
if (!empty($node)) {
|
||||||
|
$saveAll = [];
|
||||||
|
foreach ($node as $vo) {
|
||||||
|
$saveAll[] = [
|
||||||
|
'key_id' => $id,
|
||||||
|
'node' => $authService->parseNodeStr($vo),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$keyNode->saveAll($saveAll);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->error('保存失败');
|
||||||
|
}
|
||||||
|
$this->success('保存成功');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ var init = {
|
|||||||
deleteUrl: 'system.mcp_key/delete',
|
deleteUrl: 'system.mcp_key/delete',
|
||||||
exportUrl: 'system.mcp_key/export',
|
exportUrl: 'system.mcp_key/export',
|
||||||
modifyUrl: 'system.mcp_key/modify',
|
modifyUrl: 'system.mcp_key/modify',
|
||||||
|
authorize_url: 'system.mcp_key/authorize',
|
||||||
};
|
};
|
||||||
|
|||||||
59
extend/base/admin/view/system/mcp_key/authorize.html
Normal file
59
extend/base/admin/view/system/mcp_key/authorize.html
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<div class="layuimini-container">
|
||||||
|
<form id="app-form" class="layui-form layuimini-form">
|
||||||
|
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label required">密钥名称</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="title" readonly class="layui-input" value="{$row.title|default=''}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label required">分配节点</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<table class="layui-table" lay-size="sm">
|
||||||
|
<colgroup>
|
||||||
|
<col>
|
||||||
|
<col>
|
||||||
|
<col>
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="white-space: nowrap;">模块</th>
|
||||||
|
<th>功能</th>
|
||||||
|
<th>权限</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{volist name='$module_list' id='module'}
|
||||||
|
{volist name='$module.children' id='controller_item'}
|
||||||
|
<tr class="auth-node-tr">
|
||||||
|
<td style="white-space: nowrap;">{$controller_item.module}</td>
|
||||||
|
<td title="{$controller_item.node}">
|
||||||
|
<input lay-filter="controller-checkbox" type="checkbox" title="{$controller_item.title}">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{volist name='controller_item.children' id='action'}
|
||||||
|
<div title="{$action.node}" style="display: inline-block;">
|
||||||
|
<input type="checkbox" lay-filter="action-checkbox" name="node[]" title="{$action.title}" {$action.checked} {$action.disabled} value="{$action.node}">
|
||||||
|
</div>
|
||||||
|
{/volist}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/volist}
|
||||||
|
{/volist}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name="id" readonly class="layui-input" value="{$row.id}">
|
||||||
|
|
||||||
|
<div class="hr-line"></div>
|
||||||
|
<div class="layui-form-item text-center">
|
||||||
|
<button type="submit" class="layui-btn layui-btn-normal layui-btn-sm" lay-submit="system.mcp_key/saveAuthorize">确认</button>
|
||||||
|
<button type="reset" class="layui-btn layui-btn-primary layui-btn-sm">重置</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
50
extend/base/admin/view/system/mcp_key/authorize.js
Normal file
50
extend/base/admin/view/system/mcp_key/authorize.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
$(function () {
|
||||||
|
|
||||||
|
layui.form.on('checkbox(controller-checkbox)', function (data) {
|
||||||
|
var elem = data.elem;
|
||||||
|
var checked = elem.checked;
|
||||||
|
$(elem).closest('tr').find('input').each(function(i,input){
|
||||||
|
if(!$(input).prop('disabled')){
|
||||||
|
$(input).prop('checked', checked);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
layui.form.on('checkbox(action-checkbox)', function (data) {
|
||||||
|
initCheckedStatus();
|
||||||
|
});
|
||||||
|
function initCheckedStatus() {
|
||||||
|
$('.auth-node-tr').each(function (i, elem) {
|
||||||
|
var checkedCount = 0;
|
||||||
|
var totalCount = 0;
|
||||||
|
$(elem).find('[lay-filter="action-checkbox"]').each(function (j, checkbox) {
|
||||||
|
totalCount++;
|
||||||
|
if ($(checkbox).prop('checked')) {
|
||||||
|
checkedCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var checkedStatus;
|
||||||
|
if (checkedCount === totalCount) {
|
||||||
|
checkedStatus = 1; // 全部选中
|
||||||
|
} else if (checkedCount === 0) {
|
||||||
|
checkedStatus = -1; // 全部未选中
|
||||||
|
} else {
|
||||||
|
checkedStatus = 0; // 部分选中
|
||||||
|
}
|
||||||
|
if (checkedStatus === 0) {
|
||||||
|
$(elem).find('[lay-filter="controller-checkbox"]').prop('checked', false);
|
||||||
|
$(elem).find('[lay-filter="controller-checkbox"]').prop('indeterminate', true);
|
||||||
|
} else if (checkedStatus > 0) {
|
||||||
|
$(elem).find('[lay-filter="controller-checkbox"]').prop('checked', true);
|
||||||
|
$(elem).find('[lay-filter="controller-checkbox"]').prop('indeterminate', false);
|
||||||
|
} else {
|
||||||
|
$(elem).find('[lay-filter="controller-checkbox"]').prop('checked', false);
|
||||||
|
$(elem).find('[lay-filter="controller-checkbox"]').prop('indeterminate', false);
|
||||||
|
}
|
||||||
|
layui.form.render('checkbox');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
initCheckedStatus();
|
||||||
|
|
||||||
|
ua.listen();
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
data-auth-edit="{:auth('system.mcp_key/edit')}"
|
data-auth-edit="{:auth('system.mcp_key/edit')}"
|
||||||
data-auth-delete="{:auth('system.mcp_key/delete')}"
|
data-auth-delete="{:auth('system.mcp_key/delete')}"
|
||||||
data-auth-modify="{:auth('system.mcp_key/modify')}"
|
data-auth-modify="{:auth('system.mcp_key/modify')}"
|
||||||
|
data-auth-authorize="{:auth('system.mcp_key/authorize')}"
|
||||||
lay-filter="currentTable">
|
lay-filter="currentTable">
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,7 +40,15 @@ $(function(){
|
|||||||
{field: 'status', search: 'select', selectList: ua.getDataBrage('select_list_status'), title: '状态', width: 100, templet: ua.table.switch},
|
{field: 'status', search: 'select', selectList: ua.getDataBrage('select_list_status'), title: '状态', width: 100, templet: ua.table.switch},
|
||||||
{field: 'remark', title: '备注', templet: ua.table.text},
|
{field: 'remark', title: '备注', templet: ua.table.text},
|
||||||
{
|
{
|
||||||
width: 150, title: '操作', templet: ua.table.tool, fixed: 'right', operat: [
|
width: 210, title: '操作', templet: ua.table.tool, fixed: 'right', operat: [
|
||||||
|
[{
|
||||||
|
text: '授权',
|
||||||
|
url: init.authorize_url,
|
||||||
|
method: 'open',
|
||||||
|
auth: 'authorize',
|
||||||
|
class: 'layui-btn layui-btn-normal layui-btn-xs',
|
||||||
|
extend: 'data-full="true"'
|
||||||
|
}],
|
||||||
'edit',
|
'edit',
|
||||||
'delete'
|
'delete'
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -96,6 +96,33 @@ class AuthServiceBase
|
|||||||
return static::$dynamicNodeList[$node];
|
return static::$dynamicNodeList[$node];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 判断是否需要获取当前节点
|
||||||
|
if (empty($node)) {
|
||||||
|
$node = $this->getCurrentNode();
|
||||||
|
} else {
|
||||||
|
$node = $this->parseNodeStr($node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->checkNodeResolved($node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对已规范化(parseNodeStr 后)的单个节点执行完整判定链.
|
||||||
|
*
|
||||||
|
* checkNode 与 getAdminAllowedNodes 共用此实现,严禁平行复制导致语义漂移。
|
||||||
|
* 判定链(顺序与原 checkNode 保持一致):
|
||||||
|
* 动态节点黑名单 → 超管直通 → auth_on 开关 → 未注册节点 default_auth_check
|
||||||
|
* → 注解 auth=false → status/auth_ids 校验 → 白名单成员判定
|
||||||
|
* @param string $node
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected function checkNodeResolved($node)
|
||||||
|
{
|
||||||
|
// 动态节点开关(按规范化节点命中)
|
||||||
|
if (isset(static::$dynamicNodeList[$node])) {
|
||||||
|
return static::$dynamicNodeList[$node];
|
||||||
|
}
|
||||||
|
|
||||||
// 判断是否为超级管理员
|
// 判断是否为超级管理员
|
||||||
if ($this->isSuperAdmin()) {
|
if ($this->isSuperAdmin()) {
|
||||||
return true;
|
return true;
|
||||||
@@ -104,12 +131,6 @@ class AuthServiceBase
|
|||||||
if ($this->config['auth_on'] == false) {
|
if ($this->config['auth_on'] == false) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// 判断是否需要获取当前节点
|
|
||||||
if (empty($node)) {
|
|
||||||
$node = $this->getCurrentNode();
|
|
||||||
} else {
|
|
||||||
$node = $this->parseNodeStr($node);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 判断是否加入节点控制,优先获取缓存信息
|
// 判断是否加入节点控制,优先获取缓存信息
|
||||||
if (!isset($this->nodeList[$node])) {
|
if (!isset($this->nodeList[$node])) {
|
||||||
@@ -131,6 +152,35 @@ class AuthServiceBase
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量获取管理员对候选节点集合的允许子集.
|
||||||
|
*
|
||||||
|
* 对每个候选节点走与 checkNode() 完全一致的判定链(共用 checkNodeResolved),
|
||||||
|
* 返回其中允许访问的节点(规范化后的形式,保持候选顺序并去重)。
|
||||||
|
* 用于 MCP 密钥授权(创建者权限子集)等批量场景。
|
||||||
|
* @param array $candidateNodes 候选节点列表(controller/action 形式,内部会做 parseNodeStr 规范化)
|
||||||
|
* @param int|null $adminId 管理员ID,为 null 时沿用当前实例的管理员
|
||||||
|
* @return array 允许的节点列表
|
||||||
|
*/
|
||||||
|
public function getAdminAllowedNodes(array $candidateNodes, ?int $adminId = null): array
|
||||||
|
{
|
||||||
|
if (empty($adminId)) {
|
||||||
|
$adminId = $this->adminId;
|
||||||
|
}
|
||||||
|
// 目标管理员与当前实例不一致时,按目标管理员重建判定上下文
|
||||||
|
$service = ($adminId == $this->adminId) ? $this : new static($adminId);
|
||||||
|
|
||||||
|
$allowedNodes = [];
|
||||||
|
foreach ($candidateNodes as $node) {
|
||||||
|
$node = $service->parseNodeStr($node);
|
||||||
|
if ($service->checkNodeResolved($node) && !in_array($node, $allowedNodes)) {
|
||||||
|
$allowedNodes[] = $node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $allowedNodes;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前节点.
|
* 获取当前节点.
|
||||||
* @return string
|
* @return string
|
||||||
|
|||||||
Reference in New Issue
Block a user