diff --git a/.gitignore b/.gitignore index af645f5..913f013 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,7 @@ data/projects.json data/system.json data/configs/*.json data/mcp.json +data/users.json +data/api-tokens.json *.log .reasonix/* \ No newline at end of file diff --git a/docs/原型设计.md b/docs/原型设计.md new file mode 100644 index 0000000..63fb8ad --- /dev/null +++ b/docs/原型设计.md @@ -0,0 +1,126 @@ +# 页面实现文档:登录 / 项目列表 / 系统管理 + +## 1. 页面流程 + +```mermaid +flowchart TD + A[登录页 login.html] -->|登录成功| B[项目列表页 projects.html] + B -->|点击项目卡片| D[配置管理页 index.html] + B -->|管理员点击「系统管理」| C[系统管理 admin.html] + C -->|返回项目列表| B + A -.->|未登录访问任意页面| B + B -.->|token 过期 API 返回 401| A +``` + +## 2. 认证机制 + +- 登录成功返回 `{ token, user }`,前端存入 `localStorage`。 +- 所有 API 请求自动携带 `Authorization: Bearer ` 头。 +- 服务端使用内存 session(`src/routes/auth.js`),TTL 24 小时。 +- API 返回 401 时前端自动清理凭证并跳转登录页。 +- **首次启动自动创建默认管理员**:`admin / admin123`(密码经 scrypt 加盐哈希,存于 `data/users.json`)。 + +| 角色 | 项目列表 | 进入配置 | 系统管理 | +|------|---------|---------|---------| +| 管理员 (admin) | ✓ | ✓ | ✓ | +| 普通用户 (user) | ✓ | ✓ | ✗(入口隐藏 + 后端 403) | + +## 3. 页面清单(public/ 目录) + +| 文件 | 说明 | +|------|------| +| `public/login.html` | 登录页,居中卡片,深蓝渐变背景 | +| `public/projects.html` | 项目列表页,卡片网格 + 搜索 + 新建/删除项目 | +| `public/admin.html` | 系统管理页,用户管理 + Token 管理两个 Tab | +| `public/index.html` | 配置管理页(原有),标题栏增加用户信息/系统管理/退出入口 | + +## 4. 新增 API + +### 4.1 认证 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/auth/login` | 登录,Body: `{ username, password }`,返回 `{ token, user }` | +| POST | `/api/auth/logout` | 注销,销毁 session | +| GET | `/api/auth/me` | 获取当前登录用户 | + +### 4.2 用户管理(需管理员) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/users` | 用户列表(不含密码字段) | +| POST | `/api/users` | 新增用户 `{ username, displayName, password, role, enabled }` | +| PUT | `/api/users/:id` | 编辑 `{ displayName, role, enabled }` | +| PUT | `/api/users/:id/password` | 重置密码 `{ password }` | +| DELETE | `/api/users/:id` | 删除用户 | + +### 4.3 Token 管理(需管理员) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/tokens` | Token 列表 | +| POST | `/api/tokens` | 生成 `{ name, projectId, permission, expiresInDays }`,返回含明文 value | +| PUT | `/api/tokens/:id` | 编辑 `{ name, projectId, permission, enabled }` | +| PUT | `/api/tokens/:id/revoke` | 吊销 | +| DELETE | `/api/tokens/:id` | 删除 | + +## 5. 数据模型 + +`data/` 目录扩展: + +``` +data/ +├── users.json # 用户表(password 为 salt:hash) +└── api-tokens.json # API Token 表(reg_ 前缀,MCP token 仍由 mcp.json 管理) +``` + +### 用户 (User) + +```json +{ + "id": "a1b2c3d4", + "username": "admin", + "displayName": "系统管理员", + "password": "salt:scrypt-hash", + "role": "admin", + "enabled": true, + "createdAt": "2026-09-11T08:00:00.000Z", + "updatedAt": "2026-09-11T08:00:00.000Z", + "lastLoginAt": "2026-09-11T09:00:00.000Z" +} +``` + +### API Token + +```json +{ + "id": "x1y2z3a4", + "name": "CI 自动发布", + "projectId": "proj-001", + "permission": "readwrite", + "value": "reg_", + "enabled": true, + "createdAt": "2026-09-11T08:00:00.000Z", + "expiresAt": "2026-12-10T08:00:00.000Z" +} +``` + +## 6. 目录结构变更 + +``` +src/ +├── server.js # 挂载 auth/users/tokens 路由 + 全局认证中间件 +├── routes/ +│ ├── auth.js # 登录/登出/me + session 管理 +│ ├── users.js # 用户管理(管理员) +│ └── tokens.js # Token 管理(管理员) +└── services/ + ├── userService.js # 用户业务逻辑 + scrypt 密码哈希 + └── tokenService.js # Token 业务逻辑 + +public/ +├── login.html # 登录页 +├── projects.html # 项目列表页 +├── admin.html # 系统管理页 +└── js/api.js # 新增 auth/user/token 接口封装 +``` \ No newline at end of file diff --git a/docs/需求.md b/docs/需求.md index aacac8a..a80309a 100644 --- a/docs/需求.md +++ b/docs/需求.md @@ -60,7 +60,7 @@ ## 3. 非功能需求 - **NFR-1**:技术栈:Node.js + Express 后端,原生 HTML/CSS/JS 前端 -- **NFR-2**:后续对接 SSO 单点登录,当前版本暂不实现认证 +- **NFR-2**:提供登录认证(用户名/密码 + Bearer session token);用户分为管理员(含系统管理权限)与普通用户;后续可对接 SSO 单点登录 - **NFR-3**:界面响应式,支持主流浏览器 - **NFR-4**:配置数据实时写入持久化存储,保证数据一致性 diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..21e87ec --- /dev/null +++ b/public/admin.html @@ -0,0 +1,667 @@ + + + + + + 系统管理 - Registry 配置管理中心 + + + + +
+ 📋 Registry 配置管理中心 + 系统管理 +
+ + 👤 + 管理员 + + ← 返回项目列表 + 退出登录 +
+
+ +
+ + +
+ +
+
+
+

用户管理 管理系统账号与角色权限

+
+ + +
+
+
+ + + + + + + + +
用户名显示名称角色状态创建时间最后登录操作
+
+
+
+ + +
+
+
+

Token 管理 MCP / API 访问令牌

+
+ + +
+
+
+ + + + + + + + +
名称关联项目权限Token状态过期时间创建时间操作
+
+
+
+
+
+ +
+ 系统管理 · 用户管理 + +
+ + + + + + + + + + + + + + + + + + + + diff --git a/public/css/style.css b/public/css/style.css index 4149d97..d78b378 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -632,11 +632,56 @@ body.mode-readonly .row-btn:not(.view) { color: #fff; } -.titlebar .current-project { - font-size: 13px; - color: #cfe3f7; - padding-left: 14px; - border-left: 1px solid rgba(255, 255, 255, 0.3); +.titlebar .current-project { + font-size: 13px; + color: #cfe3f7; + padding-left: 14px; + border-left: 1px solid rgba(255, 255, 255, 0.3); +} + +/* ===== 标题栏右侧:用户信息与入口 ===== */ +.titlebar-right { + margin-left: auto; + display: flex; + align-items: center; + gap: 14px; + font-size: 12px; +} + +.titlebar-user { + color: #e8f1fb; + display: flex; + align-items: center; + gap: 6px; +} + +.role-badge { + display: inline-block; + padding: 1px 8px; + border-radius: 10px; + font-size: 11px; + background: rgba(255, 255, 255, 0.18); + border: 1px solid rgba(255, 255, 255, 0.35); +} + +.role-badge.admin { + background: #ffb900; + border-color: #ffd666; + color: #4a3200; + font-weight: 600; +} + +.titlebar-link { + color: #cfe3f7; + text-decoration: none; + cursor: pointer; + padding-left: 14px; + border-left: 1px solid rgba(255, 255, 255, 0.3); +} + +.titlebar-link:hover { + color: #fff; + text-decoration: underline; } /* ===== 工具栏(分组处理) ===== */ diff --git a/public/index.html b/public/index.html index ff7e224..6126922 100644 --- a/public/index.html +++ b/public/index.html @@ -7,10 +7,19 @@ - -
- 📋 Registry 配置管理中心 - 未选择项目 + +
+ 📋 Registry 配置管理中心 + 未选择项目 +
@@ -312,8 +321,33 @@ - - + + + diff --git a/public/js/api.js b/public/js/api.js index 7cc66bb..5a6b1da 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -1,129 +1,145 @@ -/** - * API 调用封装 - * 统一处理与后端 REST 接口的交互,返回解析后的数据或抛出错误 - */ -const API = { - /** - * 通用请求方法 - * @param {string} url - 请求路径 - * @param {Object} options - fetch 选项 - * @returns {Promise} 响应数据 - */ - async request(url, options = {}) { - const opts = { - headers: { "Content-Type": "application/json" }, - ...options, - }; - if (opts.body && typeof opts.body !== "string") { - opts.body = JSON.stringify(opts.body); - } - const res = await fetch(url, opts); - const json = await res.json(); - if (!json.success) { - throw new Error(json.message || "请求失败"); - } - return json.data; - }, - - /** - * 获取所有项目 - * @returns {Promise} 项目列表 - */ - getProjects() { - return this.request("/api/projects"); - }, - - /** - * 获取单个项目 - * @param {string} id - 项目 ID - * @returns {Promise} 项目对象 - */ - getProject(id) { - return this.request(`/api/projects/${id}`); - }, - - /** - * 创建项目 - * @param {Object} data - 项目数据 - * @returns {Promise} 创建的项目 - */ - createProject(data) { - return this.request("/api/projects", { method: "POST", body: data }); - }, - - /** - * 删除项目 - * @param {string} id - 项目 ID - * @returns {Promise} - */ - deleteProject(id) { - return this.request(`/api/projects/${id}`, { method: "DELETE" }); - }, - - /** - * 更新项目维度映射方式 - * @param {string} id - 项目 ID - * @param {Object} mappings - { idcMapping, environmentMapping, groupMapping } - * @returns {Promise} 更新后的项目 - */ - updateMappings(id, mappings) { - return this.request(`/api/projects/${id}/mappings`, { method: "PUT", body: mappings }); - }, - - /** - * 获取项目下所有配置 - * @param {string} projectId - 项目 ID - * @returns {Promise} 配置项列表 - */ - getConfigs(projectId) { - return this.request(`/api/projects/${projectId}/configs`); - }, - - /** - * 新增配置项 - * @param {string} projectId - 项目 ID - * @param {Object} data - 配置项数据 - * @returns {Promise} 创建的配置项 - */ - createConfig(projectId, data) { - return this.request(`/api/projects/${projectId}/configs`, { method: "POST", body: data }); - }, - - /** - * 更新配置项 - * @param {string} projectId - 项目 ID - * @param {string} configId - 配置项 ID - * @param {Object} data - 要更新的字段 - * @returns {Promise} 更新后的配置项 - */ - updateConfig(projectId, configId, data) { - return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "PUT", body: data }); - }, - - /** - * 删除配置项 - * @param {string} projectId - 项目 ID - * @param {string} configId - 配置项 ID - * @returns {Promise} - */ - deleteConfig(projectId, configId) { - return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "DELETE" }); - }, - - /** - * 获取当前模式 - * @returns {Promise} { mode } - */ - getMode() { - return this.request("/api/mode"); - }, - - /** - * 切换模式 - * @param {string} mode - 目标模式(plan/edit/readonly) - * @returns {Promise} { mode } - */ - setMode(mode) { - return this.request("/api/mode", { method: "PUT", body: { mode } }); - }, -}; +/** + * API 调用封装 + * 统一处理与后端 REST 接口的交互,返回解析后的数据或抛出错误 + */ +const API = { + /** + * 通用请求方法 + * @param {string} url - 请求路径 + * @param {Object} options - fetch 选项 + * @returns {Promise} 响应数据 + */ + async request(url, options = {}) { + const headers = { "Content-Type": "application/json" }; + // 自动携带认证 token + const token = localStorage.getItem("registry_token"); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + const opts = { headers, ...options }; + if (opts.body && typeof opts.body !== "string") { + opts.body = JSON.stringify(opts.body); + } + const res = await fetch(url, opts); + // 401 未登录,跳转登录页 + if (res.status === 401 && !url.includes("/api/auth/")) { + localStorage.removeItem("registry_token"); + localStorage.removeItem("registry_user"); + window.location.href = "/login.html"; + throw new Error("登录已过期,请重新登录"); + } + const json = await res.json(); + if (!json.success) { + throw new Error(json.message || "请求失败"); + } + return json.data; + }, + + // ========== 认证 ========== + + login(username, password) { + return this.request("/api/auth/login", { method: "POST", body: { username, password } }); + }, + + logout() { + return this.request("/api/auth/logout", { method: "POST" }); + }, + + getMe() { + return this.request("/api/auth/me"); + }, + + // ========== 项目 ========== + + getProjects() { + return this.request("/api/projects"); + }, + + getProject(id) { + return this.request(`/api/projects/${id}`); + }, + + createProject(data) { + return this.request("/api/projects", { method: "POST", body: data }); + }, + + deleteProject(id) { + return this.request(`/api/projects/${id}`, { method: "DELETE" }); + }, + + updateMappings(id, mappings) { + return this.request(`/api/projects/${id}/mappings`, { method: "PUT", body: mappings }); + }, + + // ========== 配置 ========== + + getConfigs(projectId) { + return this.request(`/api/projects/${projectId}/configs`); + }, + + createConfig(projectId, data) { + return this.request(`/api/projects/${projectId}/configs`, { method: "POST", body: data }); + }, + + updateConfig(projectId, configId, data) { + return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "PUT", body: data }); + }, + + deleteConfig(projectId, configId) { + return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "DELETE" }); + }, + + // ========== 模式 ========== + + getMode() { + return this.request("/api/mode"); + }, + + setMode(mode) { + return this.request("/api/mode", { method: "PUT", body: { mode } }); + }, + + // ========== 用户管理 ========== + + getUsers() { + return this.request("/api/users"); + }, + + createUser(data) { + return this.request("/api/users", { method: "POST", body: data }); + }, + + updateUser(id, data) { + return this.request(`/api/users/${id}`, { method: "PUT", body: data }); + }, + + resetPassword(id, password) { + return this.request(`/api/users/${id}/password`, { method: "PUT", body: { password } }); + }, + + deleteUser(id) { + return this.request(`/api/users/${id}`, { method: "DELETE" }); + }, + + // ========== Token 管理 ========== + + getTokens() { + return this.request("/api/tokens"); + }, + + createToken(data) { + return this.request("/api/tokens", { method: "POST", body: data }); + }, + + updateToken(id, data) { + return this.request(`/api/tokens/${id}`, { method: "PUT", body: data }); + }, + + revokeToken(id) { + return this.request(`/api/tokens/${id}/revoke`, { method: "PUT" }); + }, + + deleteToken(id) { + return this.request(`/api/tokens/${id}`, { method: "DELETE" }); + }, +}; diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..6b86494 --- /dev/null +++ b/public/login.html @@ -0,0 +1,95 @@ + + + + + + 登录 - Registry 配置管理中心 + + + + + + + + + diff --git a/public/projects.html b/public/projects.html new file mode 100644 index 0000000..8a6bb14 --- /dev/null +++ b/public/projects.html @@ -0,0 +1,315 @@ + + + + + + 项目管理 - Registry 配置管理中心 + + + + +
+ 📋 Registry 配置管理中心 + 项目管理 +
+ + 👤 + + + + 退出登录 +
+
+ +
+
+ 项目 + + +
+
+ 共 0 个项目 +
+
+ +
+
+

我的项目

+ 选择一个项目进入配置管理 +
+
+ + + +
+
+
+ +
+ 加载中... + +
+ + + + + + + + + + + diff --git a/src/persistence/jsonAdapter.js b/src/persistence/jsonAdapter.js index 102d873..c440e0d 100644 --- a/src/persistence/jsonAdapter.js +++ b/src/persistence/jsonAdapter.js @@ -11,6 +11,8 @@ const DATA_DIR = path.join(__dirname, "..", "..", "data"); const PROJECTS_FILE = path.join(DATA_DIR, "projects.json"); const SYSTEM_FILE = path.join(DATA_DIR, "system.json"); const MCP_FILE = path.join(DATA_DIR, "mcp.json"); +const USERS_FILE = path.join(DATA_DIR, "users.json"); +const API_TOKENS_FILE = path.join(DATA_DIR, "api-tokens.json"); const CONFIGS_DIR = path.join(DATA_DIR, "configs"); /** @@ -29,6 +31,12 @@ function ensureDataDir() { if (!fs.existsSync(SYSTEM_FILE)) { fs.writeFileSync(SYSTEM_FILE, JSON.stringify({ mode: "edit" }, null, 2), "utf-8"); } + if (!fs.existsSync(USERS_FILE)) { + fs.writeFileSync(USERS_FILE, "[]", "utf-8"); + } + if (!fs.existsSync(API_TOKENS_FILE)) { + fs.writeFileSync(API_TOKENS_FILE, "[]", "utf-8"); + } } /** @@ -140,6 +148,42 @@ function saveMcpConfig(mcp) { writeJsonFile(MCP_FILE, mcp); } +/** + * 加载用户列表 + * @returns {Array} + */ +function loadUsers() { + ensureDataDir(); + return readJsonFile(USERS_FILE) || []; +} + +/** + * 保存用户列表 + * @param {Array} users + */ +function saveUsers(users) { + ensureDataDir(); + writeJsonFile(USERS_FILE, users); +} + +/** + * 加载 API Token 列表 + * @returns {Array} + */ +function loadApiTokens() { + ensureDataDir(); + return readJsonFile(API_TOKENS_FILE) || []; +} + +/** + * 保存 API Token 列表 + * @param {Array} tokens + */ +function saveApiTokens(tokens) { + ensureDataDir(); + writeJsonFile(API_TOKENS_FILE, tokens); +} + module.exports = { loadProjects, saveProjects, @@ -150,4 +194,8 @@ module.exports = { saveSystem, loadMcpConfig, saveMcpConfig, + loadUsers, + saveUsers, + loadApiTokens, + saveApiTokens, }; diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..399dd2d --- /dev/null +++ b/src/routes/auth.js @@ -0,0 +1,114 @@ +/** + * 认证路由 + * POST /api/auth/login - 登录 + * POST /api/auth/logout - 登出 + * GET /api/auth/me - 获取当前登录用户 + */ +const express = require("express"); +const router = express.Router(); +const userService = require("../services/userService"); + +// 内存 session 存储:token -> { user, expiresAt } +const sessions = new Map(); + +const SESSION_TTL = 24 * 60 * 60 * 1000; // 24 小时 + +/** + * 清理过期 session + */ +function cleanExpiredSessions() { + const now = Date.now(); + for (const [token, session] of sessions) { + if (session.expiresAt < now) sessions.delete(token); + } +} + +/** + * 创建 session + */ +function createSession(user) { + cleanExpiredSessions(); + const token = userService.generateToken(32); + sessions.set(token, { + user, + expiresAt: Date.now() + SESSION_TTL, + }); + return token; +} + +/** + * 获取 session 中的用户 + */ +function getSession(token) { + if (!token) return null; + const session = sessions.get(token); + if (!session) return null; + if (session.expiresAt < Date.now()) { + sessions.delete(token); + return null; + } + // 续期 + session.expiresAt = Date.now() + SESSION_TTL; + return session.user; +} + +/** + * 销毁 session + */ +function destroySession(token) { + if (token) sessions.delete(token); +} + +/** + * POST /api/auth/login + * Body: { username, password } + */ +router.post("/login", (req, res) => { + try { + const { username, password } = req.body; + if (!username || !password) { + return res.status(400).json({ success: false, message: "用户名和密码不能为空" }); + } + const user = userService.authenticate(username, password); + if (!user) { + return res.status(401).json({ success: false, message: "用户名或密码错误" }); + } + const token = createSession(user); + res.json({ success: true, data: { token, user } }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +/** + * POST /api/auth/logout + * Header: Authorization: Bearer + */ +router.post("/logout", (req, res) => { + const token = extractToken(req); + destroySession(token); + res.json({ success: true, message: "已退出登录" }); +}); + +/** + * GET /api/auth/me + * Header: Authorization: Bearer + */ +router.get("/me", (req, res) => { + const token = extractToken(req); + const user = getSession(token); + if (!user) { + return res.status(401).json({ success: false, message: "未登录或登录已过期" }); + } + res.json({ success: true, data: user }); +}); + +/** + * 从请求头提取 Bearer token + */ +function extractToken(req) { + const auth = req.headers.authorization || ""; + return auth.startsWith("Bearer ") ? auth.slice(7).trim() : ""; +} + +module.exports = { router, getSession, extractToken }; diff --git a/src/routes/tokens.js b/src/routes/tokens.js new file mode 100644 index 0000000..f0126df --- /dev/null +++ b/src/routes/tokens.js @@ -0,0 +1,88 @@ +/** + * API Token 管理路由(需管理员权限) + * GET /api/tokens - Token 列表 + * POST /api/tokens - 生成 Token + * PUT /api/tokens/:id - 编辑 Token + * PUT /api/tokens/:id/revoke - 吊销 Token + * DELETE /api/tokens/:id - 删除 Token + */ +const express = require("express"); +const router = express.Router(); +const tokenService = require("../services/tokenService"); + +/** + * 管理员权限校验中间件 + */ +function requireAdmin(req, res, next) { + if (!req.user || req.user.role !== "admin") { + return res.status(403).json({ success: false, message: "需要管理员权限" }); + } + next(); +} + +router.use(requireAdmin); + +/** + * GET /api/tokens + */ +router.get("/", (req, res) => { + try { + res.json({ success: true, data: tokenService.getAllTokens() }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +/** + * POST /api/tokens + * Body: { name, projectId, permission, expiresInDays } + */ +router.post("/", (req, res) => { + try { + const token = tokenService.createToken(req.body); + res.status(201).json({ success: true, data: token }); + } catch (err) { + res.status(400).json({ success: false, message: err.message }); + } +}); + +/** + * PUT /api/tokens/:id + * Body: { name, projectId, permission, enabled } + */ +router.put("/:id", (req, res) => { + try { + const token = tokenService.updateToken(req.params.id, req.body); + res.json({ success: true, data: token }); + } catch (err) { + res.status(400).json({ success: false, message: err.message }); + } +}); + +/** + * PUT /api/tokens/:id/revoke + */ +router.put("/:id/revoke", (req, res) => { + try { + const ok = tokenService.revokeToken(req.params.id); + if (!ok) return res.status(404).json({ success: false, message: "Token 不存在" }); + res.json({ success: true, message: "Token 已吊销" }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +/** + * DELETE /api/tokens/:id + */ +router.delete("/:id", (req, res) => { + try { + const ok = tokenService.deleteToken(req.params.id); + if (!ok) return res.status(404).json({ success: false, message: "Token 不存在" }); + res.json({ success: true, message: "Token 已删除" }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +module.exports = router; diff --git a/src/routes/users.js b/src/routes/users.js new file mode 100644 index 0000000..8149917 --- /dev/null +++ b/src/routes/users.js @@ -0,0 +1,89 @@ +/** + * 用户管理路由(需管理员权限) + * GET /api/users - 用户列表 + * POST /api/users - 新增用户 + * PUT /api/users/:id - 编辑用户 + * PUT /api/users/:id/password - 重置密码 + * DELETE /api/users/:id - 删除用户 + */ +const express = require("express"); +const router = express.Router(); +const userService = require("../services/userService"); + +/** + * 管理员权限校验中间件 + */ +function requireAdmin(req, res, next) { + // 此中间件依赖 auth 中间件已挂载到 req.user + if (!req.user || req.user.role !== "admin") { + return res.status(403).json({ success: false, message: "需要管理员权限" }); + } + next(); +} + +router.use(requireAdmin); + +/** + * GET /api/users + */ +router.get("/", (req, res) => { + try { + res.json({ success: true, data: userService.getAllUsers() }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +/** + * POST /api/users + * Body: { username, displayName, password, role, enabled } + */ +router.post("/", (req, res) => { + try { + const user = userService.createUser(req.body); + res.status(201).json({ success: true, data: user }); + } catch (err) { + res.status(400).json({ success: false, message: err.message }); + } +}); + +/** + * PUT /api/users/:id + * Body: { displayName, role, enabled } + */ +router.put("/:id", (req, res) => { + try { + const user = userService.updateUser(req.params.id, req.body); + res.json({ success: true, data: user }); + } catch (err) { + res.status(400).json({ success: false, message: err.message }); + } +}); + +/** + * PUT /api/users/:id/password + * Body: { password } + */ +router.put("/:id/password", (req, res) => { + try { + userService.resetPassword(req.params.id, req.body.password); + res.json({ success: true, message: "密码已重置" }); + } catch (err) { + res.status(400).json({ success: false, message: err.message }); + } +}); + +/** + * DELETE /api/users/:id + */ +router.delete("/:id", (req, res) => { + try { + const ok = userService.deleteUser(req.params.id); + if (!ok) return res.status(404).json({ success: false, message: "用户不存在" }); + res.json({ success: true, message: "用户已删除" }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +module.exports = router; diff --git a/src/server.js b/src/server.js index efde8ef..98739e3 100644 --- a/src/server.js +++ b/src/server.js @@ -6,7 +6,11 @@ const express = require("express"); const path = require("path"); const projectRoutes = require("./routes/projects"); const configRoutes = require("./routes/configs"); +const { router: authRoutes, getSession, extractToken } = require("./routes/auth"); +const userRoutes = require("./routes/users"); +const tokenRoutes = require("./routes/tokens"); const { loadSystem, saveSystem } = require("./persistence/jsonAdapter"); +const userService = require("./services/userService"); // MCP 服务(Model Context Protocol,供 AI 自动操作) const mcpRoutes = require("./mcp"); @@ -17,16 +21,34 @@ const PORT = process.env.PORT || 3000; // 解析 JSON 请求体 app.use(express.json()); +// 初始化默认管理员账号(首次启动时自动创建) +userService.ensureDefaultAdmin(); + +// 全局认证中间件:解析 Bearer token,将用户信息挂载到 req.user(非强制) +app.use((req, res, next) => { + const token = extractToken(req); + req.user = getSession(token) || null; + next(); +}); + // 静态资源服务(禁用缓存,避免浏览器加载到旧版本页面/脚本) app.use(express.static(path.join(__dirname, "..", "public"), { etag: false, lastModified: false, cacheControl: false, setHeaders: (res) => res.setHeader("Cache-Control", "no-store") })); // API 路由 +app.use("/api/auth", authRoutes); app.use("/api/projects", projectRoutes); app.use("/api/projects/:projectId/configs", configRoutes); +app.use("/api/users", userRoutes); +app.use("/api/tokens", tokenRoutes); // MCP 服务端点(Streamable HTTP 传输) app.use("/mcp", mcpRoutes); +// 根路径重定向到登录页 +app.get("/", (req, res) => { + res.redirect("/login.html"); +}); + /** * GET /api/mode * 获取当前系统模式 diff --git a/src/services/tokenService.js b/src/services/tokenService.js new file mode 100644 index 0000000..6c69421 --- /dev/null +++ b/src/services/tokenService.js @@ -0,0 +1,124 @@ +/** + * API Token 服务 + * 负责 API 访问令牌的增删改查与吊销,持久化到 data/api-tokens.json + * (与 MCP token 独立,MCP token 由 data/mcp.json 管理) + */ +const { loadApiTokens, saveApiTokens } = require("../persistence/jsonAdapter"); +const { generateToken } = require("./userService"); + +/** + * 获取所有 Token(完整值,供列表展示) + * @returns {Array} + */ +function getAllTokens() { + return loadApiTokens(); +} + +/** + * 按 ID 查找 Token + * @param {string} id + * @returns {Object|null} + */ +function findById(id) { + return loadApiTokens().find((t) => t.id === id) || null; +} + +/** + * 创建 Token + * @param {Object} data - { name, projectId, permission, expiresInDays } + * @returns {Object} 创建的 Token(含明文 value) + */ +function createToken(data) { + const tokens = loadApiTokens(); + if (!data.name || !data.name.trim()) { + throw new Error("Token 名称不能为空"); + } + const tokenValue = `reg_${generateToken(24)}`; + const now = new Date(); + let expiresAt = null; + if (data.expiresInDays && data.expiresInDays > 0) { + expiresAt = new Date(now.getTime() + data.expiresInDays * 86400000).toISOString(); + } + const token = { + id: generateToken(8), + name: data.name.trim(), + projectId: data.projectId || "all", + permission: data.permission === "readwrite" ? "readwrite" : "readonly", + value: tokenValue, + enabled: true, + createdAt: now.toISOString(), + expiresAt, + }; + tokens.push(token); + saveApiTokens(tokens); + return token; +} + +/** + * 更新 Token 元信息 + * @param {string} id + * @param {Object} data - { name, projectId, permission, enabled } + * @returns {Object} + */ +function updateToken(id, data) { + const tokens = loadApiTokens(); + const idx = tokens.findIndex((t) => t.id === id); + if (idx === -1) throw new Error("Token 不存在"); + if (data.name !== undefined) tokens[idx].name = data.name.trim(); + if (data.projectId !== undefined) tokens[idx].projectId = data.projectId; + if (data.permission !== undefined) tokens[idx].permission = data.permission; + if (data.enabled !== undefined) tokens[idx].enabled = !!data.enabled; + saveApiTokens(tokens); + return tokens[idx]; +} + +/** + * 吊销 Token(设置 enabled = false) + * @param {string} id + * @returns {boolean} + */ +function revokeToken(id) { + const tokens = loadApiTokens(); + const idx = tokens.findIndex((t) => t.id === id); + if (idx === -1) return false; + tokens[idx].enabled = false; + saveApiTokens(tokens); + return true; +} + +/** + * 删除 Token + * @param {string} id + * @returns {boolean} + */ +function deleteToken(id) { + const tokens = loadApiTokens(); + const idx = tokens.findIndex((t) => t.id === id); + if (idx === -1) return false; + tokens.splice(idx, 1); + saveApiTokens(tokens); + return true; +} + +/** + * 根据 token value 校验是否有效(用于 API 鉴权) + * @param {string} value + * @returns {Object|null} token 对象或 null + */ +function validateToken(value) { + if (!value) return null; + const token = loadApiTokens().find((t) => t.value === value); + if (!token || !token.enabled) return null; + if (token.expiresAt && new Date(token.expiresAt) < new Date()) return null; + return token; +} + +module.exports = { + getAllTokens, + findById, + createToken, + updateToken, + revokeToken, + deleteToken, + validateToken, +}; diff --git a/src/services/userService.js b/src/services/userService.js new file mode 100644 index 0000000..4fc7801 --- /dev/null +++ b/src/services/userService.js @@ -0,0 +1,206 @@ +/** + * 用户服务 + * 负责用户的增删改查、密码校验与哈希,数据持久化到 data/users.json + */ +const crypto = require("crypto"); +const { loadUsers, saveUsers } = require("../persistence/jsonAdapter"); + +/** + * 密码哈希:使用 scrypt 算法 + 随机盐值 + * @param {string} password - 明文密码 + * @returns {string} salt:hash 格式的哈希值 + */ +function hashPassword(password) { + const salt = crypto.randomBytes(16).toString("hex"); + const hash = crypto.scryptSync(password, salt, 64).toString("hex"); + return `${salt}:${hash}`; +} + +/** + * 校验密码是否匹配 + * @param {string} password - 明文密码 + * @param {string} stored - 存储的 salt:hash + * @returns {boolean} + */ +function verifyPassword(password, stored) { + const [salt, hash] = stored.split(":"); + const hashToCheck = crypto.scryptSync(password, salt, 64).toString("hex"); + return crypto.timingSafeEqual(Buffer.from(hash, "hex"), Buffer.from(hashToCheck, "hex")); +} + +/** + * 生成随机 token + * @param {number} [length=32] - 字节长度 + * @returns {string} hex token + */ +function generateToken(length = 32) { + return crypto.randomBytes(length).toString("hex"); +} + +/** + * 确保默认管理员账号存在(首次启动时初始化) + */ +function ensureDefaultAdmin() { + const users = loadUsers(); + if (users.length === 0) { + users.push({ + id: generateToken(8), + username: "admin", + displayName: "系统管理员", + password: hashPassword("admin123"), + role: "admin", + enabled: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + lastLoginAt: null, + }); + saveUsers(users); + console.log("已创建默认管理员账号: admin"); + } +} + +/** + * 按用户名查找用户 + * @param {string} username + * @returns {Object|null} + */ +function findByUsername(username) { + return loadUsers().find((u) => u.username === username) || null; +} + +/** + * 按 ID 查找用户 + * @param {string} id + * @returns {Object|null} + */ +function findById(id) { + return loadUsers().find((u) => u.id === id) || null; +} + +/** + * 获取所有用户(不含密码字段) + * @returns {Array} + */ +function getAllUsers() { + return loadUsers().map(({ password, ...rest }) => rest); +} + +/** + * 创建用户 + * @param {Object} data - { username, displayName, password, role, enabled } + * @returns {Object} 创建的用户(不含密码) + * @throws {Error} + */ +function createUser(data) { + const users = loadUsers(); + if (!data.username || !data.username.trim()) { + throw new Error("用户名不能为空"); + } + if (!data.password) { + throw new Error("密码不能为空"); + } + if (users.find((u) => u.username === data.username.trim())) { + throw new Error("用户名已存在"); + } + const user = { + id: generateToken(8), + username: data.username.trim(), + displayName: (data.displayName || data.username).trim(), + password: hashPassword(data.password), + role: data.role === "admin" ? "admin" : "user", + enabled: data.enabled !== false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + lastLoginAt: null, + }; + users.push(user); + saveUsers(users); + const { password, ...rest } = user; + return rest; +} + +/** + * 更新用户 + * @param {string} id + * @param {Object} data - { displayName, role, enabled } + * @returns {Object} + * @throws {Error} + */ +function updateUser(id, data) { + const users = loadUsers(); + const idx = users.findIndex((u) => u.id === id); + if (idx === -1) throw new Error("用户不存在"); + if (data.displayName !== undefined) users[idx].displayName = data.displayName.trim(); + if (data.role !== undefined) users[idx].role = data.role === "admin" ? "admin" : "user"; + if (data.enabled !== undefined) users[idx].enabled = !!data.enabled; + users[idx].updatedAt = new Date().toISOString(); + saveUsers(users); + const { password, ...rest } = users[idx]; + return rest; +} + +/** + * 重置密码 + * @param {string} id + * @param {string} newPassword + * @throws {Error} + */ +function resetPassword(id, newPassword) { + const users = loadUsers(); + const idx = users.findIndex((u) => u.id === id); + if (idx === -1) throw new Error("用户不存在"); + if (!newPassword) throw new Error("新密码不能为空"); + users[idx].password = hashPassword(newPassword); + users[idx].updatedAt = new Date().toISOString(); + saveUsers(users); +} + +/** + * 删除用户 + * @param {string} id + * @returns {boolean} + */ +function deleteUser(id) { + const users = loadUsers(); + const idx = users.findIndex((u) => u.id === id); + if (idx === -1) return false; + users.splice(idx, 1); + saveUsers(users); + return true; +} + +/** + * 登录校验,成功后更新 lastLoginAt + * @param {string} username + * @param {string} password + * @returns {Object|null} 用户对象(不含密码)或 null + */ +function authenticate(username, password) { + const user = findByUsername(username); + if (!user || !user.enabled) return null; + if (!verifyPassword(password, user.password)) return null; + // 更新最后登录时间 + const users = loadUsers(); + const idx = users.findIndex((u) => u.id === user.id); + if (idx !== -1) { + users[idx].lastLoginAt = new Date().toISOString(); + saveUsers(users); + } + const { password: _, ...rest } = user; + return { ...rest, lastLoginAt: users[idx].lastLoginAt }; +} + +module.exports = { + ensureDefaultAdmin, + hashPassword, + verifyPassword, + generateToken, + findByUsername, + findById, + getAllUsers, + createUser, + updateUser, + resetPassword, + deleteUser, + authenticate, +};