mirror of
https://github.com/certd/certd.git
synced 2026-08-07 06:18:04 +08:00
perf: 新增AI插件开发受限令牌与隔离API
This commit is contained in:
@@ -43,7 +43,7 @@ Certd 插件按来源分为三类:
|
|||||||
|
|
||||||
## API 认证
|
## API 认证
|
||||||
|
|
||||||
提示词会提供 Certd API 地址和当前用户 Token。调用 API 时使用:
|
提示词会提供 Certd API 地址和仅限 AI 插件开发接口的受限 Token。调用 API 时使用:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
Authorization: <token>
|
Authorization: <token>
|
||||||
@@ -57,7 +57,7 @@ Content-Type: application/json
|
|||||||
Node 请求须直接读取 UTF-8 文件或在 Node 内构造 JSON,并使用 `JSON.stringify`:
|
Node 请求须直接读取 UTF-8 文件或在 Node 内构造 JSON,并使用 `JSON.stringify`:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const response = await fetch(`${apiBase}/sys/plugin/find`, {
|
const response = await fetch(`${apiBase}/scoped/sys/ai/plugin/find`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: token, "Content-Type": "application/json; charset=utf-8" },
|
headers: { Authorization: token, "Content-Type": "application/json; charset=utf-8" },
|
||||||
body: JSON.stringify({ keywords: ["nginx"], includeBuiltIn: true, includeStore: true }),
|
body: JSON.stringify({ keywords: ["nginx"], includeBuiltIn: true, includeStore: true }),
|
||||||
@@ -66,28 +66,28 @@ const response = await fetch(`${apiBase}/sys/plugin/find`, {
|
|||||||
|
|
||||||
## UTF-8 保存
|
## UTF-8 保存
|
||||||
|
|
||||||
在 Windows 上,Node 直接以 UTF-8 读取 YAML 并用 `JSON.stringify` 发送;不要让 PowerShell 转发含中文的 YAML/JSON。保存后检查中文字段不含 `?`,再调用 `/sys/plugin/find` 或 `/sys/plugin/info` 验证。
|
在 Windows 上,Node 直接以 UTF-8 读取 YAML 并用 `JSON.stringify` 发送;不要让 PowerShell 转发含中文的 YAML/JSON。保存后检查中文字段不含 `?`,再调用 `/scoped/sys/ai/plugin/find` 或 `/scoped/sys/ai/plugin/info` 验证。
|
||||||
|
|
||||||
## API 工作流
|
## API 工作流
|
||||||
|
|
||||||
1. 使用 `/sys/plugin/find` 查询插件和 Access,可通过 `keywords` 数组传递多个关键词。
|
1. 使用 `/scoped/sys/ai/plugin/find` 查询插件和 Access,可通过 `keywords` 数组传递多个关键词。
|
||||||
2. 查询结果包含 `editable`:
|
2. 查询结果包含 `editable`:
|
||||||
- `editable: true`:允许当前 Agent 修改并保存。
|
- `editable: true`:允许当前 Agent 修改并保存。
|
||||||
- `editable: false`:只能读取和使用,不能修改。
|
- `editable: false`:只能读取和使用,不能修改。
|
||||||
3. 读取完整 YAML 时调用 `/sys/plugin/export`。
|
3. 读取完整 YAML 时调用 `/scoped/sys/ai/plugin/export`。
|
||||||
4. 所有插件保存统一调用 `/sys/plugin/import`,并始终传递完整 YAML:
|
4. 所有插件保存统一调用 `/scoped/sys/ai/plugin/import`,并始终传递完整 YAML:
|
||||||
- 新插件使用 `override: false`。
|
- 新插件使用 `override: false`。
|
||||||
- 已有插件使用 `override: true`;导入接口根据 `author` 和 `name` 定位并覆盖已有记录。
|
- 已有插件使用 `override: true`;导入接口根据 `author` 和 `name` 定位并覆盖已有记录。
|
||||||
5. 不使用 `/sys/plugin/add` 或 `/sys/plugin/update` 保存插件,避免保存路径分叉、字段丢失和 Windows 请求兼容性问题。
|
5. 不使用 `/sys/plugin/add` 或 `/sys/plugin/update` 保存插件,避免保存路径分叉、字段丢失和 Windows 请求兼容性问题。
|
||||||
6. 保存完成后重新调用 `/sys/plugin/find` 或 `/sys/plugin/info` 验证结果。
|
6. 保存完成后重新调用 `/scoped/sys/ai/plugin/find` 或 `/scoped/sys/ai/plugin/info` 验证结果。
|
||||||
|
|
||||||
`/sys/plugin/find` 会在一次请求中分别查询内置插件和 `store` 插件,再合并返回;`store` 插件需按上述字段区分市场插件与本地插件。
|
`/scoped/sys/ai/plugin/find` 会在一次请求中分别查询内置插件和 `store` 插件,再合并返回;`store` 插件需按上述字段区分市场插件与本地插件。
|
||||||
|
|
||||||
详细请求字段见 `references/certd-api.md`。
|
详细请求字段见 `references/certd-api.md`。
|
||||||
|
|
||||||
## Access 协作
|
## Access 协作
|
||||||
|
|
||||||
开发 Task 或 DNS Provider 前,先用 `/sys/plugin/find` 查询对应 Access:
|
开发 Task 或 DNS Provider 前,先用 `/scoped/sys/ai/plugin/find` 查询对应 Access:
|
||||||
|
|
||||||
1. 如果没有对应 Access,先创建 Access 插件,再创建业务插件。
|
1. 如果没有对应 Access,先创建 Access 插件,再创建业务插件。
|
||||||
2. 如果已有 Access,先读取它的完整 YAML 和 `content`。
|
2. 如果已有 Access,先读取它的完整 YAML 和 `content`。
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ Access 插件负责保存授权配置,也负责封装平台 API/SDK,供 Task
|
|||||||
|
|
||||||
## 查询顺序
|
## 查询顺序
|
||||||
|
|
||||||
1. 调用 `/sys/plugin/find`,使用 `pluginType: access`。
|
1. 调用 `/scoped/sys/ai/plugin/find`,使用 `pluginType: access`。
|
||||||
2. 根据 `name`、`author`、`fullName` 识别目标 Access。
|
2. 根据 `name`、`author`、`fullName` 识别目标 Access。
|
||||||
3. 使用 `/sys/plugin/export` 读取完整 YAML。
|
3. 使用 `/scoped/sys/ai/plugin/export` 读取完整 YAML。
|
||||||
4. 检查 `content` 中已经提供的方法。
|
4. 检查 `content` 中已经提供的方法。
|
||||||
|
|
||||||
## 修改规则
|
## 修改规则
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
## 查询插件
|
## 查询插件
|
||||||
|
|
||||||
```http
|
```http
|
||||||
POST /sys/plugin/find
|
POST /scoped/sys/ai/plugin/find
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
Authorization: <token>
|
Authorization: <token>
|
||||||
```
|
```
|
||||||
@@ -28,19 +28,19 @@ Authorization: <token>
|
|||||||
- `type: "store"` 且没有 `appId`、`developerId`:本地插件。
|
- `type: "store"` 且没有 `appId`、`developerId`:本地插件。
|
||||||
|
|
||||||
结果中的 `editable` 是唯一的编辑权限依据;不能只按插件来源判断是否可修改。
|
结果中的 `editable` 是唯一的编辑权限依据;不能只按插件来源判断是否可修改。
|
||||||
列表结果只返回插件基础信息,不返回 `content`、`setting`、`sysSetting`、`metadata` 或 `extra`。需要完整 YAML 时再调用 `/sys/plugin/export`。
|
列表结果只返回插件基础信息,不返回 `content`、`setting`、`sysSetting`、`metadata` 或 `extra`。需要完整 YAML 时再调用 `/scoped/sys/ai/plugin/export`。
|
||||||
|
|
||||||
## 读取插件信息
|
## 读取插件信息
|
||||||
|
|
||||||
```http
|
```http
|
||||||
POST /sys/plugin/info?id=12
|
POST /scoped/sys/ai/plugin/info?id=12
|
||||||
Authorization: <token>
|
Authorization: <token>
|
||||||
```
|
```
|
||||||
|
|
||||||
## 导出完整 YAML
|
## 导出完整 YAML
|
||||||
|
|
||||||
```http
|
```http
|
||||||
POST /sys/plugin/export
|
POST /scoped/sys/ai/plugin/export
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
Authorization: <token>
|
Authorization: <token>
|
||||||
```
|
```
|
||||||
@@ -53,22 +53,10 @@ Authorization: <token>
|
|||||||
|
|
||||||
## 保存插件
|
## 保存插件
|
||||||
|
|
||||||
已有插件使用:
|
使用完整 YAML 导入:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
POST /sys/plugin/update
|
POST /scoped/sys/ai/plugin/import
|
||||||
```
|
|
||||||
|
|
||||||
新插件使用:
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /sys/plugin/add
|
|
||||||
```
|
|
||||||
|
|
||||||
也可以使用完整 YAML 导入:
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /sys/plugin/import
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -79,4 +67,4 @@ POST /sys/plugin/import
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
保存后重新调用 `/sys/plugin/find` 或 `/sys/plugin/info` 验证。
|
保存后重新调用 `/scoped/sys/ai/plugin/find` 或 `/scoped/sys/ai/plugin/info` 验证。
|
||||||
|
|||||||
@@ -22,4 +22,4 @@
|
|||||||
- `change.md` 只记录插件 ID、版本、时间和脱敏修改摘要。
|
- `change.md` 只记录插件 ID、版本、时间和脱敏修改摘要。
|
||||||
- 不保存 Token、证书、私钥、Cookie、环境变量和真实授权值。
|
- 不保存 Token、证书、私钥、Cookie、环境变量和真实授权值。
|
||||||
- 恢复历史版本前先备份当前 YAML。
|
- 恢复历史版本前先备份当前 YAML。
|
||||||
- 恢复后通过 `/sys/plugin/update` 或 `/sys/plugin/import` 写回 Certd。
|
- 恢复后通过 `/scoped/sys/ai/plugin/import` 写回 Certd。
|
||||||
|
|||||||
@@ -51,4 +51,4 @@ return class DemoTask extends AbstractTaskPlugin {
|
|||||||
- 用 `this.ctx.http` 请求远程 API,用 `this.getAccess` 获取授权。
|
- 用 `this.ctx.http` 请求远程 API,用 `this.getAccess` 获取授权。
|
||||||
- 外部 API 返回失败或业务失败时抛出异常。
|
- 外部 API 返回失败或业务失败时抛出异常。
|
||||||
- 对重复执行保持幂等,避免把真实 Token、证书和私钥写入日志。
|
- 对重复执行保持幂等,避免把真实 Token、证书和私钥写入日志。
|
||||||
- 修改完成后把整个 YAML 通过 Certd `/sys/plugin/update` 或 `/sys/plugin/import` 保存。
|
- 修改完成后把整个 YAML 通过 Certd `/scoped/sys/ai/plugin/import` 保存。
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ Certd 是可私有化部署的 SSL/TLS 证书自动化管理平台,提供 Web
|
|||||||
|
|
||||||
- 务必写单元测试,覆盖主要业务逻辑。
|
- 务必写单元测试,覆盖主要业务逻辑。
|
||||||
- 实现新功能或修复行为缺陷前,优先补单元测试并先确认红灯,再实现并跑聚焦验证。
|
- 实现新功能或修复行为缺陷前,优先补单元测试并先确认红灯,再实现并跑聚焦验证。
|
||||||
|
- 单元测试应优先直接测试原始业务方法,不要为了方便测试而抽取没有业务价值的 helper;数据库、RPC 和其他外部依赖可以使用 mock 隔离。
|
||||||
- 确实不适合先写测试时,在回复中说明原因和替代验证方式。
|
- 确实不适合先写测试时,在回复中说明原因和替代验证方式。
|
||||||
- 后补单元测试时,按正确行为写预期;若红灯需要修改既有实现,先向用户确认这是 bug 还是既有需求,避免未经确认改变行为。
|
- 后补单元测试时,按正确行为写预期;若红灯需要修改既有实现,先向用户确认这是 bug 还是既有需求,避免未经确认改变行为。
|
||||||
- 后端纯单测放在 `src/**/*.test.ts`,尽量与被测文件相邻;`test:unit` 只跑这些文件,构建/打包应排除 `*.test.ts`。
|
- 后端纯单测放在 `src/**/*.test.ts`,尽量与被测文件相邻;`test:unit` 只跑这些文件,构建/打包应排除 `*.test.ts`。
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ export async function FindPlugins(query: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function GetScopedAccessToken(scoped: string[]): Promise<{ token: string; expire: number; scoped: string[] }> {
|
||||||
|
return await request({
|
||||||
|
url: "/sys/basic/getScopedAccessToken",
|
||||||
|
method: "post",
|
||||||
|
data: { scoped },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function AddObj(obj: any) {
|
export async function AddObj(obj: any) {
|
||||||
return await request({
|
return await request({
|
||||||
url: apiPrefix + "/add",
|
url: apiPrefix + "/add",
|
||||||
|
|||||||
+9
-9
@@ -21,7 +21,7 @@
|
|||||||
<a-tag color="blue">API 模式</a-tag>
|
<a-tag color="blue">API 模式</a-tag>
|
||||||
</div>
|
</div>
|
||||||
<a-textarea class="plugin-ai-dev__prompt-text" :value="prompt" readonly :rows="24" placeholder="生成后复制到 Codex 或 Trae 中运行。" />
|
<a-textarea class="plugin-ai-dev__prompt-text" :value="prompt" readonly :rows="24" placeholder="生成后复制到 Codex 或 Trae 中运行。" />
|
||||||
<div class="plugin-ai-dev__prompt-warning">该提示词包含 Certd 的访问 Token,请勿泄露给他人</div>
|
<div class="plugin-ai-dev__prompt-warning">提示词使用受限且短时有效的 AI 开发 Token,可正常提供给可信 Agent,请勿公开或随意转发</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,7 +31,6 @@
|
|||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import { message } from "ant-design-vue";
|
import { message } from "ant-design-vue";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
import { useUserStore } from "/@/store/user";
|
|
||||||
import { env } from "/src/utils/util.env";
|
import { env } from "/src/utils/util.env";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@@ -43,7 +42,6 @@ const props = defineProps<{
|
|||||||
pluginName?: string;
|
pluginName?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const userStore = useUserStore();
|
|
||||||
const requirement = ref("");
|
const requirement = ref("");
|
||||||
const selectedPluginId = ref<number | string | undefined>(props.pluginId);
|
const selectedPluginId = ref<number | string | undefined>(props.pluginId);
|
||||||
const pluginOptions = ref<{ label: string; value: number | string }[]>([]);
|
const pluginOptions = ref<{ label: string; value: number | string }[]>([]);
|
||||||
@@ -93,17 +91,17 @@ async function createPrompt() {
|
|||||||
}
|
}
|
||||||
creating.value = true;
|
creating.value = true;
|
||||||
try {
|
try {
|
||||||
prompt.value = buildPrompt();
|
const accessToken = await api.GetScopedAccessToken(["sys/ai"]);
|
||||||
|
prompt.value = buildPrompt(accessToken.token);
|
||||||
message.success("启动提示词已生成");
|
message.success("启动提示词已生成");
|
||||||
} finally {
|
} finally {
|
||||||
creating.value = false;
|
creating.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPrompt() {
|
function buildPrompt(token: string) {
|
||||||
const pluginLabel = props.pluginName || selectedPluginId.value;
|
const pluginLabel = props.pluginName || selectedPluginId.value;
|
||||||
const pluginText = pluginLabel ? `当前插件:${pluginLabel}` : "当前为新插件开发";
|
const pluginText = pluginLabel ? `当前插件:${pluginLabel}` : "当前为新插件开发";
|
||||||
const token = userStore.getToken || "";
|
|
||||||
const certdUrl = window.location.origin;
|
const certdUrl = window.location.origin;
|
||||||
const apiBase = new URL(env.API || "/api", certdUrl).toString().replace(/\/$/, "");
|
const apiBase = new URL(env.API || "/api", certdUrl).toString().replace(/\/$/, "");
|
||||||
return `你是 Certd 在线插件开发 Agent。
|
return `你是 Certd 在线插件开发 Agent。
|
||||||
@@ -119,21 +117,23 @@ ${certdUrl}
|
|||||||
Certd API 地址:
|
Certd API 地址:
|
||||||
${apiBase}
|
${apiBase}
|
||||||
|
|
||||||
当前用户 Token:
|
受限的 Certd AccessToken(仅能访问插件查询、编辑相关的几个接口):
|
||||||
${token}
|
${token}
|
||||||
|
|
||||||
|
如果token过期,请向用户重新申请AccessToken. (让用户重新生成提示词,到里面复制上面这一串新AccessToken给你)
|
||||||
|
|
||||||
开发流程:
|
开发流程:
|
||||||
1. 开始开发前,先检查当前工作目录是否已经是 certd 项目:应能看到 package.json、packages/ui/certd-server/src/plugins/、.trae/skills/ 等特征。
|
1. 开始开发前,先检查当前工作目录是否已经是 certd 项目:应能看到 package.json、packages/ui/certd-server/src/plugins/、.trae/skills/ 等特征。
|
||||||
2. 检查 .trae/skills/ 下是否已经有 certd-online-plugin-dev 技能。
|
2. 检查 .trae/skills/ 下是否已经有 certd-online-plugin-dev 技能。
|
||||||
3. 如果当前目录不是 certd 项目,或缺少该技能,则先拉取 certd 仓库代码:优先 https://atomgit.com/certd/certd/,如果 AtomGit 拉取失败,再使用 https://github.com/certd/certd。
|
3. 如果当前目录不是 certd 项目,或缺少该技能,则先拉取 certd 仓库代码:优先 https://atomgit.com/certd/certd/,如果 AtomGit 拉取失败,再使用 https://github.com/certd/certd。
|
||||||
4. 加载 .trae/skills/certd-online-plugin-dev/SKILL.md,并按插件类型加载对应子 Skill。
|
4. 加载 .trae/skills/certd-online-plugin-dev/SKILL.md,并按插件类型加载对应子 Skill。
|
||||||
5. 参考 certd 项目下已有内置插件 packages/ui/certd-server/src/plugins/ 的实现方式进行开发。
|
5. 参考 certd 项目下已有内置插件 packages/ui/certd-server/src/plugins/ 的实现方式进行开发。
|
||||||
6. 使用当前 Token 调用 Certd API,通过 /sys/plugin/find 查询插件和 Access。
|
6. 使用受限 Token 调用 Certd API,只能访问 /scoped/sys/ai/plugin/ 前缀接口;通过 /scoped/sys/ai/plugin/find 查询插件和 Access。
|
||||||
7. 开发 Task 或 DNS 插件前,先查询对应 Access,优先复用 Access 提供的 API/SDK 能力。
|
7. 开发 Task 或 DNS 插件前,先查询对应 Access,优先复用 Access 提供的 API/SDK 能力。
|
||||||
8. 如果没有 Access,先创建 Access 插件;如果 Access 的 editable 为 true 且缺少能力,可以先修改 Access。
|
8. 如果没有 Access,先创建 Access 插件;如果 Access 的 editable 为 true 且缺少能力,可以先修改 Access。
|
||||||
9. 在当前工作区创建并使用 .tmp/online-plugin-dev 作为本次插件开发临时目录,历史记录、临时 YAML、脚本草稿和调试记录都放在该目录下。
|
9. 在当前工作区创建并使用 .tmp/online-plugin-dev 作为本次插件开发临时目录,历史记录、临时 YAML、脚本草稿和调试记录都放在该目录下。
|
||||||
10. 修改任何插件前,先在 .tmp/online-plugin-dev/history 下保存完整 YAML 历史记录,便于恢复。
|
10. 修改任何插件前,先在 .tmp/online-plugin-dev/history 下保存完整 YAML 历史记录,便于恢复。
|
||||||
11. 通过 Certd API 读取和保存完整插件 YAML,不使用 WebSocket,不依赖浏览器草稿。
|
11. 读取完整 YAML 使用 /scoped/sys/ai/plugin/export,保存完整 YAML 使用 /scoped/sys/ai/plugin/import;不使用 WebSocket,不依赖浏览器草稿。
|
||||||
12. 保存完成后向用户报告 API 操作结果,不自动发布。
|
12. 保存完成后向用户报告 API 操作结果,不自动发布。
|
||||||
|
|
||||||
认证请求要求:
|
认证请求要求:
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ process.on("uncaughtException", error => {
|
|||||||
// log()
|
// log()
|
||||||
// }127.0.0.1
|
// }127.0.0.1
|
||||||
|
|
||||||
|
|
||||||
// startHeapLog();
|
// startHeapLog();
|
||||||
@Configuration({
|
@Configuration({
|
||||||
detectorOptions: {
|
detectorOptions: {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { ALL, Body, Controller, Inject, Post, Provide, Query } from "@midwayjs/core";
|
||||||
|
import { BaseController } from "@certd/lib-server";
|
||||||
|
import { PluginFindReq, PluginImportReq, PluginService } from "../../../modules/plugin/service/plugin-service.js";
|
||||||
|
import { AuditType } from "../../../modules/sys/enterprise/service/audit-constants.js";
|
||||||
|
|
||||||
|
@Provide()
|
||||||
|
@Controller("/api/scoped/sys/ai/plugin")
|
||||||
|
export class AiPluginController extends BaseController {
|
||||||
|
@Inject()
|
||||||
|
service: PluginService;
|
||||||
|
|
||||||
|
getAuditType(): string {
|
||||||
|
return AuditType.plugin.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/find", { description: "sys:settings:view", summary: "AI 查询插件" })
|
||||||
|
async find(@Body(ALL) body: PluginFindReq) {
|
||||||
|
const res = await this.service.findPlugins(body || {});
|
||||||
|
return this.ok(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/info", { description: "sys:settings:view", summary: "AI 查询插件信息" })
|
||||||
|
async info(@Query("id") id: number) {
|
||||||
|
const res = await this.service.info(id);
|
||||||
|
return this.ok(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/export", { description: "sys:settings:view", summary: "AI 导出插件" })
|
||||||
|
async export(@Body("id") id: number) {
|
||||||
|
const res = await this.service.exportPlugin(id);
|
||||||
|
return this.ok(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/import", { description: "sys:settings:edit", summary: "AI 导入插件" })
|
||||||
|
async import(@Body(ALL) body: PluginImportReq) {
|
||||||
|
const res = await this.service.importPlugin(body);
|
||||||
|
this.auditLog({ content: "AI 导入了插件配置" });
|
||||||
|
return this.ok(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { ALL, Body, Controller, Inject, Post, Provide } from "@midwayjs/core";
|
||||||
|
import { BaseController } from "@certd/lib-server";
|
||||||
|
import { LoginService } from "../../../modules/login/service/login-service.js";
|
||||||
|
import { AuditType } from "../../../modules/sys/enterprise/service/audit-constants.js";
|
||||||
|
|
||||||
|
const AI_PLUGIN_TOKEN_SCOPE = "sys/ai";
|
||||||
|
|
||||||
|
@Provide()
|
||||||
|
@Controller("/api/sys/basic")
|
||||||
|
export class SysBasicController extends BaseController {
|
||||||
|
@Inject()
|
||||||
|
loginService: LoginService;
|
||||||
|
|
||||||
|
getAuditType(): string {
|
||||||
|
return AuditType.settings.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/getScopedAccessToken", { description: "sys:settings:edit", summary: "获取 AI 插件开发访问令牌" })
|
||||||
|
async getScopedAccessToken(@Body(ALL) body: { scoped?: string[] }) {
|
||||||
|
const scoped = body?.scoped || [];
|
||||||
|
if (!Array.isArray(scoped) || scoped.length !== 1 || scoped[0] !== AI_PLUGIN_TOKEN_SCOPE) {
|
||||||
|
throw new Error(`仅支持申请 ${AI_PLUGIN_TOKEN_SCOPE} 范围的访问令牌`);
|
||||||
|
}
|
||||||
|
const user = this.ctx.user;
|
||||||
|
if (!user?.id || !user.username || !Array.isArray(user.roles)) {
|
||||||
|
throw new Error("当前登录令牌不支持申请受限访问令牌");
|
||||||
|
}
|
||||||
|
const res = await this.loginService.generateScopedAccessToken(user, scoped);
|
||||||
|
this.auditLog({ content: "获取了 AI 插件开发受限访问令牌" });
|
||||||
|
return this.ok(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,7 +55,7 @@ export class PluginController extends CrudController<PluginService> {
|
|||||||
};
|
};
|
||||||
merge(bean, def);
|
merge(bean, def);
|
||||||
bean.fullName = bean.name;
|
bean.fullName = bean.name;
|
||||||
if (bean.author){
|
if (bean.author) {
|
||||||
bean.fullName = bean.author + "/" + bean.name;
|
bean.fullName = bean.author + "/" + bean.name;
|
||||||
}
|
}
|
||||||
const res = await super.add(bean);
|
const res = await super.add(bean);
|
||||||
@@ -189,5 +189,4 @@ export class PluginController extends CrudController<PluginService> {
|
|||||||
const res = await this.service.exportPlugin(id);
|
const res = await this.service.exportPlugin(id);
|
||||||
return this.ok(res);
|
return this.ok(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,3 +55,36 @@ describe("AuthorityMiddleware guestOptionalAuth", () => {
|
|||||||
assert.deepEqual(ctx.user.roles, [1]);
|
assert.deepEqual(ctx.user.roles, [1]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("AuthorityMiddleware scoped token", () => {
|
||||||
|
it("rejects a scoped token outside its API prefix", async () => {
|
||||||
|
const middleware = createMiddleware(Constants.per.authOnly);
|
||||||
|
const ctx = createCtx();
|
||||||
|
ctx.path = "/api/sys/plugin/find";
|
||||||
|
const token = jwt.sign({ id: 1, roles: [1], scoped: ["sys/ai"] }, middleware.secret);
|
||||||
|
ctx.get = (name: string) => (name === "Authorization" ? `Bearer ${token}` : "");
|
||||||
|
let called = false;
|
||||||
|
|
||||||
|
await middleware.resolve()(ctx, async () => {
|
||||||
|
called = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(called, false);
|
||||||
|
assert.equal(ctx.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a scoped token within its API prefix", async () => {
|
||||||
|
const middleware = createMiddleware(Constants.per.authOnly);
|
||||||
|
const ctx = createCtx();
|
||||||
|
ctx.path = "/api/scoped/sys/ai/plugin/find";
|
||||||
|
const token = jwt.sign({ id: 1, roles: [1], scoped: ["sys/ai"] }, middleware.secret);
|
||||||
|
ctx.get = (name: string) => (name === "Authorization" ? `Bearer ${token}` : "");
|
||||||
|
let called = false;
|
||||||
|
|
||||||
|
await middleware.resolve()(ctx, async () => {
|
||||||
|
called = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(called, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ export class AuthorityMiddleware implements IWebMiddleware {
|
|||||||
logger.error("token verify error: ", err);
|
logger.error("token verify error: ", err);
|
||||||
return this.notAuth(ctx);
|
return this.notAuth(ctx);
|
||||||
}
|
}
|
||||||
|
if (!this.isScopedTokenPathAllowed(ctx)) {
|
||||||
|
return this.notScoped(ctx);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (permission === Constants.per.guestOptionalAuth) {
|
if (permission === Constants.per.guestOptionalAuth) {
|
||||||
await next();
|
await next();
|
||||||
@@ -111,6 +114,35 @@ export class AuthorityMiddleware implements IWebMiddleware {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private notScoped(ctx: IMidwayKoaContext) {
|
||||||
|
ctx.status = 403;
|
||||||
|
ctx.body = Constants.res.permission;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isScopedTokenPathAllowed(ctx: IMidwayKoaContext) {
|
||||||
|
const user = ctx.user as { scoped?: unknown } | undefined;
|
||||||
|
if (!user || !("scoped" in user)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(user.scoped) || user.scoped.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestPath = ctx.path.replace(/^\/+|\/+$/g, "");
|
||||||
|
return user.scoped.some(scope => {
|
||||||
|
if (typeof scope !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const normalizedScope = scope.trim().replace(/^\/+|\/+$/g, "");
|
||||||
|
if (!/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(normalizedScope)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const scopedPrefix = `api/scoped/${normalizedScope}`;
|
||||||
|
return requestPath === scopedPrefix || requestPath.startsWith(`${scopedPrefix}/`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private extractProjectId(ctx: IMidwayKoaContext) {
|
private extractProjectId(ctx: IMidwayKoaContext) {
|
||||||
const headerVal = ctx.headers["project-id"] as string;
|
const headerVal = ctx.headers["project-id"] as string;
|
||||||
const queryVal = (ctx.request as any)?.query?.projectId;
|
const queryVal = (ctx.request as any)?.query?.projectId;
|
||||||
|
|||||||
@@ -3,3 +3,5 @@ export { CronConfiguration as Configuration } from "./configuration.js";
|
|||||||
// export * from './controller/user';
|
// export * from './controller/user';
|
||||||
// export * from './controller/api';
|
// export * from './controller/api';
|
||||||
// export * from './service/user';
|
// export * from './service/user';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
import { LoginService } from "./login-service.js";
|
import { LoginService } from "./login-service.js";
|
||||||
|
|
||||||
function createLoginService() {
|
function createLoginService() {
|
||||||
@@ -76,3 +77,28 @@ describe("LoginService.register", () => {
|
|||||||
assert.equal(calls.bindInvitee.length, 0);
|
assert.equal(calls.bindInvitee.length, 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("LoginService.generateScopedAccessToken", () => {
|
||||||
|
it("adds the requested scopes to a short-lived JWT", async () => {
|
||||||
|
const service = new LoginService();
|
||||||
|
(service as any).jwt = { expire: 7200 };
|
||||||
|
service.sysSettingsService = {
|
||||||
|
async getSetting() {
|
||||||
|
return { jwtKey: "test-secret" };
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const result = await service.generateScopedAccessToken(
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
username: "admin",
|
||||||
|
roles: [1],
|
||||||
|
},
|
||||||
|
["sys/ai"]
|
||||||
|
);
|
||||||
|
const payload = jwt.verify(result.token, "test-secret") as jwt.JwtPayload;
|
||||||
|
|
||||||
|
assert.deepEqual(payload.scoped, ["sys/ai"]);
|
||||||
|
assert.equal(result.expire, 3600);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -256,6 +256,33 @@ export class LoginService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async generateScopedAccessToken(user: { id: number; username: string; roles: number[] }, scoped: string[]) {
|
||||||
|
const normalizedScopes = [...new Set((scoped || []).map(item => `${item || ""}`.trim()).filter(Boolean))];
|
||||||
|
if (normalizedScopes.length === 0) {
|
||||||
|
throw new CommonException("scoped不能为空");
|
||||||
|
}
|
||||||
|
const setting = await this.sysSettingsService.getSetting<SysPrivateSettings>(SysPrivateSettings);
|
||||||
|
const expire = Math.min(this.jwt.expire, 6 * 60 * 60);
|
||||||
|
const token = jwt.sign(
|
||||||
|
{
|
||||||
|
username: user.username,
|
||||||
|
id: user.id,
|
||||||
|
roles: user.roles,
|
||||||
|
scoped: normalizedScopes,
|
||||||
|
},
|
||||||
|
setting.jwtKey,
|
||||||
|
{ expiresIn: expire }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
expire,
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
scoped: normalizedScopes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async loginByOpenId(req: { openId: string; type: string }) {
|
async loginByOpenId(req: { openId: string; type: string }) {
|
||||||
const { openId, type } = req;
|
const { openId, type } = req;
|
||||||
const oauthBound = await this.oauthBoundService.findOne({
|
const oauthBound = await this.oauthBoundService.findOne({
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type CloudflareRecord = {
|
|||||||
icon: "simple-icons:cloudflare",
|
icon: "simple-icons:cloudflare",
|
||||||
// 这里是对应的 cloudflare的access类型名称
|
// 这里是对应的 cloudflare的access类型名称
|
||||||
accessType: "cloudflare",
|
accessType: "cloudflare",
|
||||||
|
order: 1,
|
||||||
})
|
})
|
||||||
export class CloudflareDnsProvider extends AbstractDnsProvider<CloudflareRecord> {
|
export class CloudflareDnsProvider extends AbstractDnsProvider<CloudflareRecord> {
|
||||||
access!: CloudflareAccess;
|
access!: CloudflareAccess;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export type GoogleCloudDnsRecord = {
|
|||||||
desc: "Google Cloud DNS提供商",
|
desc: "Google Cloud DNS提供商",
|
||||||
icon: "flat-color-icons:google",
|
icon: "flat-color-icons:google",
|
||||||
accessType: "google",
|
accessType: "google",
|
||||||
order: 50,
|
order: 2,
|
||||||
})
|
})
|
||||||
export class GoogleCloudDnsProvider extends AbstractDnsProvider<GoogleCloudDnsRecord> {
|
export class GoogleCloudDnsProvider extends AbstractDnsProvider<GoogleCloudDnsRecord> {
|
||||||
access!: GoogleAccess;
|
access!: GoogleAccess;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type SearchRecordOptions = {
|
|||||||
desc: "华为云DNS解析提供商",
|
desc: "华为云DNS解析提供商",
|
||||||
accessType: "huawei",
|
accessType: "huawei",
|
||||||
icon: "svg:icon-huawei",
|
icon: "svg:icon-huawei",
|
||||||
|
order: 1,
|
||||||
})
|
})
|
||||||
export class HuaweiDnsProvider extends AbstractDnsProvider {
|
export class HuaweiDnsProvider extends AbstractDnsProvider {
|
||||||
client!: HuaweiYunClient;
|
client!: HuaweiYunClient;
|
||||||
|
|||||||
+1
@@ -8,6 +8,7 @@ const tencentDnsProviderDefine: any = {
|
|||||||
desc: "腾讯云域名DNS解析提供者",
|
desc: "腾讯云域名DNS解析提供者",
|
||||||
accessType: "tencent",
|
accessType: "tencent",
|
||||||
icon: "svg:icon-tencentcloud",
|
icon: "svg:icon-tencentcloud",
|
||||||
|
order: 0,
|
||||||
dependPlugins: {
|
dependPlugins: {
|
||||||
"access:tencent": "*",
|
"access:tencent": "*",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { TencentAccess } from "../../plugin-lib/tencent/access.js";
|
|||||||
dependPlugins: {
|
dependPlugins: {
|
||||||
"access:tencent": "*",
|
"access:tencent": "*",
|
||||||
},
|
},
|
||||||
|
order: 1,
|
||||||
})
|
})
|
||||||
export class TencentEoDnsProvider extends AbstractDnsProvider {
|
export class TencentEoDnsProvider extends AbstractDnsProvider {
|
||||||
access!: TencentAccess;
|
access!: TencentAccess;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { PageSearch } from "@certd/pipeline";
|
|||||||
desc: "火山引擎DNS解析提供商",
|
desc: "火山引擎DNS解析提供商",
|
||||||
accessType: "volcengine",
|
accessType: "volcengine",
|
||||||
icon: "svg:icon-volcengine",
|
icon: "svg:icon-volcengine",
|
||||||
order: 2,
|
order: 1,
|
||||||
})
|
})
|
||||||
export class VolcengineDnsProvider extends AbstractDnsProvider {
|
export class VolcengineDnsProvider extends AbstractDnsProvider {
|
||||||
client: VolcengineDnsClient;
|
client: VolcengineDnsClient;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ type westRecord = {
|
|||||||
record_id: number;
|
record_id: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
// 这个别删,在WestDnsProvider有使用
|
||||||
export class WestDnsProviderDomain extends AbstractDnsProvider<westRecord> {
|
export class WestDnsProviderDomain extends AbstractDnsProvider<westRecord> {
|
||||||
access!: WestAccess;
|
access!: WestAccess;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user