From bb61e6e6f5d40df765f47f0c87e4422ca47e33a7 Mon Sep 17 00:00:00 2001 From: SunWuyuan Date: Sat, 1 Nov 2025 19:31:46 +0800 Subject: [PATCH] feat: Add AutoAuth functionality and enhance Apps API - Introduced AutoAuth model to manage automatic authorization configurations for devices. - Added new endpoint to obtain token via namespace and password for automatic authorization. - Implemented functionality to set student names for student-type tokens. - Enhanced AppInstall model to include deviceType and isReadOnly fields. - Updated device creation to allow custom namespaces and ensure uniqueness. - Added routes for managing AutoAuth configurations, including CRUD operations. - Implemented checks for read-only tokens in KV operations. - Created detailed API documentation for AutoAuth and new Apps API endpoints. - Added migration scripts to accommodate new database schema changes. --- API_AUTOAUTH.md | 276 ++++++++++++++ API_QUICK_REFERENCE.md | 0 NEW_APIS_SUMMARY.md | 0 app.js | 4 + middleware/device.js | 6 +- middleware/kvTokenAuth.js | 9 +- package.json | 3 +- pnpm-lock.yaml | 88 +++-- .../migration.sql | 47 +++ prisma/schema.prisma | 23 +- routes/apps.js | 216 +++++++++++ routes/auto-auth.js | 343 ++++++++++++++++++ routes/device.js | 17 +- routes/kv-token.js | 63 ++++ 14 files changed, 1039 insertions(+), 56 deletions(-) create mode 100644 API_AUTOAUTH.md create mode 100644 API_QUICK_REFERENCE.md create mode 100644 NEW_APIS_SUMMARY.md create mode 100644 prisma/migrations/20251025113931_add_auto_auth_and_device_updates/migration.sql create mode 100644 routes/auto-auth.js diff --git a/API_AUTOAUTH.md b/API_AUTOAUTH.md new file mode 100644 index 0000000..7b73c17 --- /dev/null +++ b/API_AUTOAUTH.md @@ -0,0 +1,276 @@ +# AutoAuth 和新增 Apps API 文档 + +## 概述 + +本文档描述了自动授权 (AutoAuth) 相关的 API 接口以及新增的应用管理接口。 + +--- + +## Apps API 新增接口 + +### 1. 通过 namespace 和密码获取 token + +**端点**: `POST /apps/auth/token` + +**描述**: 通过设备的 namespace 和密码进行自动授权,创建新的 AppInstall 并返回 token。 + +**请求体**: +```json +{ + "namespace": "string (必填)", + "password": "string (可选,根据自动授权配置)", + "appId": "string (必填)" +} +``` + +**成功响应** (201 Created): +```json +{ + "success": true, + "token": "string", + "deviceType": "string | null", + "isReadOnly": boolean, + "installedAt": "datetime" +} +``` + +**错误响应**: +- `400 Bad Request`: 缺少必填字段 +- `404 Not Found`: 设备不存在或 namespace 不正确 +- `401 Unauthorized`: 密码不正确或需要提供密码 + +**说明**: +- 该接口会查找匹配的 AutoAuth 配置 +- 如果提供了密码,会验证密码是否匹配任何自动授权配置 +- 如果没有提供密码,会查找无密码的自动授权配置 +- 根据匹配的 AutoAuth 配置设置 `deviceType` 和 `isReadOnly` 属性 + +--- + +### 2. 设置学生名称 + +**端点**: `POST /apps/tokens/:token/set-student-name` + +**描述**: 为学生类型的 token 设置名称(更新 note 字段)。 + +**URL 参数**: +- `token`: AppInstall 的 token + +**请求体**: +```json +{ + "name": "string (必填)" +} +``` + +**成功响应** (200 OK): +```json +{ + "success": true, + "token": "string", + "name": "string", + "updatedAt": "datetime" +} +``` + +**错误响应**: +- `400 Bad Request`: 缺少名称或名称不在学生列表中 +- `403 Forbidden`: token 类型不是 student +- `404 Not Found`: token 不存在或设备未设置学生列表 + +**说明**: +- 只有 `deviceType` 为 `student` 的 token 才能使用此接口 +- 会验证提供的名称是否存在于设备的 `classworks-list-main` 键值中 +- 学生列表格式: `[{"id": 1, "name": "学生1"}, {"id": 2, "name": "学生2"}]` + +--- + +## AutoAuth 管理 API + +> 🔐 **所有 AutoAuth 管理接口都需要 JWT Account Token 认证** +> +> **重要**: 只有已绑定账户的设备才能使用这些接口。未绑定账户的设备无法管理 AutoAuth 配置。 +> +> 通过 HTTP Headers 提供: +> - `Authorization`: `Bearer {jwt_token}` - 账户的 JWT Token + +### 1. 获取设备的自动授权配置列表 + +**端点**: `GET /auto-auth/devices/:uuid/auth-configs` + +**认证**: 需要 JWT Token (账户必须是设备的拥有者) + +**URL 参数**: +- `uuid`: 设备的 UUID + +**成功响应** (200 OK): +```json +{ + "success": true, + "configs": [ + { + "id": "string", + "hasPassword": boolean, + "deviceType": "string | null", + "isReadOnly": boolean, + "createdAt": "datetime", + "updatedAt": "datetime" + } + ] +} +``` + +**说明**: +- 返回的配置不包含实际的密码哈希值,只显示是否有密码 + +--- + +### 2. 创建自动授权配置 + +**端点**: `POST /auto-auth/devices/:uuid/auth-configs` + +**认证**: 需要 JWT Token (账户必须是设备的拥有者) + +**URL 参数**: +- `uuid`: 设备的 UUID + +**请求体**: +```json +{ + "password": "string (可选)", + "deviceType": "string (可选: teacher|student|classroom|parent)", + "isReadOnly": boolean (可选,默认 false) +} +``` + +**成功响应** (201 Created): +```json +{ + "success": true, + "config": { + "id": "string", + "hasPassword": boolean, + "deviceType": "string | null", + "isReadOnly": boolean, + "createdAt": "datetime" + } +} +``` + +**错误响应**: +- `400 Bad Request`: 设备类型无效或密码配置已存在 + +**说明**: +- 同一设备的密码必须唯一(包括空密码) +- `deviceType` 必须是 `teacher`、`student`、`classroom`、`parent` 之一,或为空 + +--- + +### 3. 更新自动授权配置 + +**端点**: `PUT /auto-auth/devices/:uuid/auth-configs/:configId` + +**认证**: 需要 JWT Token (账户必须是设备的拥有者) + +**URL 参数**: +- `uuid`: 设备的 UUID +- `configId`: 自动授权配置的 ID + +**请求体**: +```json +{ + "password": "string (可选)", + "deviceType": "string (可选: teacher|student|classroom|parent)", + "isReadOnly": boolean (可选) +} +``` + +**成功响应** (200 OK): +```json +{ + "success": true, + "config": { + "id": "string", + "hasPassword": boolean, + "deviceType": "string | null", + "isReadOnly": boolean, + "updatedAt": "datetime" + } +} +``` + +**错误响应**: +- `400 Bad Request`: 设备类型无效或新密码与其他配置冲突 +- `403 Forbidden`: 无权操作此配置 +- `404 Not Found`: 配置不存在 + +**说明**: +- 只能更新属于当前设备的配置 +- 更新密码时会检查是否与该设备的其他配置冲突 + +--- + +### 4. 删除自动授权配置 + +**端点**: `DELETE /auto-auth/devices/:uuid/auth-configs/:configId` + +**认证**: 需要 JWT Token (账户必须是设备的拥有者) + +**URL 参数**: +- `uuid`: 设备的 UUID +- `configId`: 自动授权配置的 ID + +**成功响应** (204 No Content): +- 无响应体 + +**错误响应**: +- `403 Forbidden`: 无权操作此配置 +- `404 Not Found`: 配置不存在 + +**说明**: +- 只能删除属于当前设备的配置 + +--- + +## 设备类型 (deviceType) + +可选的设备类型值: +- `teacher`: 教师 +- `student`: 学生 +- `classroom`: 班级一体机 +- `parent`: 家长 +- `null`: 未指定类型 + +--- + +## 使用流程示例 + +### 场景 1: 学生使用 namespace 登录 + +1. 学生输入班级的 namespace 和密码 +2. 调用 `POST /apps/auth/token` 获取 token +3. 使用返回的 token 访问 KV 存储 +4. 如果是学生类型,调用 `POST /apps/tokens/:token/set-student-name` 设置自己的名称 + +### 场景 2: 管理员配置自动授权 + +1. 管理员通过账户登录获取 JWT Token +2. 调用 `POST /auto-auth/devices/:uuid/auth-configs` 创建多个授权配置: + - 教师密码(deviceType: teacher, isReadOnly: false) + - 学生密码(deviceType: student, isReadOnly: false) + - 家长密码(deviceType: parent, isReadOnly: true) +3. 学生/教师/家长使用对应密码通过 namespace 登录 + +**注意**: 设备必须已绑定到管理员的账户才能配置 AutoAuth + +--- + +## 注意事项 + +1. **密码安全**: 所有密码都使用 bcrypt 进行哈希存储 +2. **唯一性约束**: + - 同一设备的 namespace 必须唯一 + - 同一设备的 AutoAuth 密码必须唯一(包括 null) +3. **级联删除**: 删除设备会级联删除所有相关的 AutoAuth 配置和 AppInstall 记录 +4. **只读限制**: isReadOnly 为 true 的 token 在 KV 操作中会受到写入限制 +5. **账户绑定要求**: 只有已绑定账户的设备才能管理 AutoAuth 配置,未绑定账户的设备无法使用 AutoAuth 管理接口 diff --git a/API_QUICK_REFERENCE.md b/API_QUICK_REFERENCE.md new file mode 100644 index 0000000..e69de29 diff --git a/NEW_APIS_SUMMARY.md b/NEW_APIS_SUMMARY.md new file mode 100644 index 0000000..e69de29 diff --git a/app.js b/app.js index 4c56089..aca369d 100644 --- a/app.js +++ b/app.js @@ -20,6 +20,7 @@ import appsRouter from "./routes/apps.js"; import deviceRouter from "./routes/device.js"; import deviceAuthRouter from "./routes/device-auth.js"; import accountsRouter from "./routes/accounts.js"; +import autoAuthRouter from "./routes/auto-auth.js"; var app = express(); @@ -87,6 +88,9 @@ app.get("/check", apiLimiter, (req, res) => { // Mount the Apps router with API rate limiting app.use("/apps", apiLimiter, appsRouter); +// Mount the Auto Auth router with API rate limiting +app.use("/auto-auth", apiLimiter, autoAuthRouter); + // Mount the Device router with API rate limiting app.use("/devices", apiLimiter, deviceRouter); diff --git a/middleware/device.js b/middleware/device.js index 1623d4e..bf33a0f 100644 --- a/middleware/device.js +++ b/middleware/device.js @@ -16,7 +16,7 @@ const prisma = new PrismaClient(); /** * 设备中间件 - 统一处理设备UUID * - * 从req.params.deviceUuid、req.params.namespace或req.body.deviceUuid获取UUID + * 从req.params.deviceUuid或req.body.deviceUuid获取UUID * 如果设备不存在则自动创建 * 将设备信息存储到res.locals.device * @@ -25,7 +25,7 @@ const prisma = new PrismaClient(); * router.get('/path/:deviceUuid', deviceMiddleware, handler) */ export const deviceMiddleware = errors.catchAsync(async (req, res, next) => { - const deviceUuid = req.params.deviceUuid || req.params.namespace || req.body.deviceUuid; + const deviceUuid = req.params.deviceUuid || req.body.deviceUuid; if (!deviceUuid) { return next(errors.createError(400, "缺少设备UUID")); @@ -65,7 +65,7 @@ export const deviceMiddleware = errors.catchAsync(async (req, res, next) => { * router.get('/path/:deviceUuid', deviceInfoMiddleware, handler) */ export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) => { - const deviceUuid = req.params.deviceUuid || req.params.namespace; + const deviceUuid = req.params.deviceUuid ; if (!deviceUuid) { return next(errors.createError(400, "缺少设备UUID")); diff --git a/middleware/kvTokenAuth.js b/middleware/kvTokenAuth.js index ff1b847..e8e06be 100644 --- a/middleware/kvTokenAuth.js +++ b/middleware/kvTokenAuth.js @@ -39,7 +39,7 @@ export const kvTokenAuth = async (req, res, next) => { res.locals.device = appInstall.device; res.locals.appInstall = appInstall; res.locals.deviceId = appInstall.device.id; - + res.locals.token = token; next(); } catch (error) { next(error); @@ -54,6 +54,13 @@ export const kvTokenAuth = async (req, res, next) => { * 3. Body: token 或 apptoken */ function extractToken(req) { + // 优先从 Authorization header 提取 Bearer token(支持大小写) + const authHeader = req.headers && (req.headers.authorization || req.headers.Authorization); + if (authHeader) { + const m = authHeader.match(/^Bearer\s+(.+)$/i); + if (m) return m[1]; + } + return ( req.headers["x-app-token"] || req.query.token || diff --git a/package.json b/package.json index feab494..5713725 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,6 @@ "private": true, "scripts": { "start": "node ./bin/www", - "prisma": "prisma generate", "dev": "NODE_ENV=development nodemon node .bin/www", "get-token": "node ./cli/get-token.js" }, @@ -35,6 +34,6 @@ "uuid": "^11.1.0" }, "devDependencies": { - "prisma": "6.16.2" + "prisma": "^6.18.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af36691..998164d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 1.34.0 '@prisma/client': specifier: 6.16.2 - version: 6.16.2(prisma@6.16.2(typescript@5.8.3))(typescript@5.8.3) + version: 6.16.2(prisma@6.18.0(typescript@5.8.3))(typescript@5.8.3) axios: specifier: ^1.9.0 version: 1.9.0(debug@4.4.1) @@ -79,8 +79,8 @@ importers: version: 11.1.0 devDependencies: prisma: - specifier: 6.16.2 - version: 6.16.2(typescript@5.8.3) + specifier: ^6.18.0 + version: 6.18.0(typescript@5.8.3) kv-admin: dependencies: @@ -902,23 +902,23 @@ packages: typescript: optional: true - '@prisma/config@6.16.2': - resolution: {integrity: sha512-mKXSUrcqXj0LXWPmJsK2s3p9PN+aoAbyMx7m5E1v1FufofR1ZpPoIArjjzOIm+bJRLLvYftoNYLx1tbHgF9/yg==} + '@prisma/config@6.18.0': + resolution: {integrity: sha512-rgFzspCpwsE+q3OF/xkp0fI2SJ3PfNe9LLMmuSVbAZ4nN66WfBiKqJKo/hLz3ysxiPQZf8h1SMf2ilqPMeWATQ==} - '@prisma/debug@6.16.2': - resolution: {integrity: sha512-bo4/gA/HVV6u8YK2uY6glhNsJ7r+k/i5iQ9ny/3q5bt9ijCj7WMPUwfTKPvtEgLP+/r26Z686ly11hhcLiQ8zA==} + '@prisma/debug@6.18.0': + resolution: {integrity: sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==} - '@prisma/engines-version@6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43': - resolution: {integrity: sha512-ThvlDaKIVrnrv97ujNFDYiQbeMQpLa0O86HFA2mNoip4mtFqM7U5GSz2ie1i2xByZtvPztJlNRgPsXGeM/kqAA==} + '@prisma/engines-version@6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f': + resolution: {integrity: sha512-T7Af4QsJQnSgWN1zBbX+Cha5t4qjHRxoeoWpK4JugJzG/ipmmDMY5S+O0N1ET6sCBNVkf6lz+Y+ZNO9+wFU8pQ==} - '@prisma/engines@6.16.2': - resolution: {integrity: sha512-7yf3AjfPUgsg/l7JSu1iEhsmZZ/YE00yURPjTikqm2z4btM0bCl2coFtTGfeSOWbQMmq45Jab+53yGUIAT1sjA==} + '@prisma/engines@6.18.0': + resolution: {integrity: sha512-i5RzjGF/ex6AFgqEe2o1IW8iIxJGYVQJVRau13kHPYEL1Ck8Zvwuzamqed/1iIljs5C7L+Opiz5TzSsUebkriA==} - '@prisma/fetch-engine@6.16.2': - resolution: {integrity: sha512-wPnZ8DMRqpgzye758ZvfAMiNJRuYpz+rhgEBZi60ZqDIgOU2694oJxiuu3GKFeYeR/hXxso4/2oBC243t/whxQ==} + '@prisma/fetch-engine@6.18.0': + resolution: {integrity: sha512-TdaBvTtBwP3IoqVYoGIYpD4mWlk0pJpjTJjir/xLeNWlwog7Sl3bD2J0jJ8+5+q/6RBg+acb9drsv5W6lqae7A==} - '@prisma/get-platform@6.16.2': - resolution: {integrity: sha512-U/P36Uke5wS7r1+omtAgJpEB94tlT4SdlgaeTc6HVTTT93pXj7zZ+B/cZnmnvjcNPfWddgoDx8RLjmQwqGDYyA==} + '@prisma/get-platform@6.18.0': + resolution: {integrity: sha512-uXNJCJGhxTCXo2B25Ta91Rk1/Nmlqg9p7G9GKh8TPhxvAyXCvMNQoogj4JLEUy+3ku8g59cpyQIKFhqY2xO2bg==} '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -1622,8 +1622,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - effect@3.16.12: - resolution: {integrity: sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg==} + effect@3.18.4: + resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} @@ -1867,10 +1867,6 @@ packages: engines: {node: '>=10'} hasBin: true - jiti@2.4.2: - resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} - hasBin: true - jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -2206,8 +2202,8 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} - prisma@6.16.2: - resolution: {integrity: sha512-aRvldGE5UUJTtVmFiH3WfNFNiqFlAtePUxcI0UEGlnXCX7DqhiMT5TRYwncHFeA/Reca5W6ToXXyCMTeFPdSXA==} + prisma@6.18.0: + resolution: {integrity: sha512-bXWy3vTk8mnRmT+SLyZBQoC2vtV9Z8u7OHvEu+aULYxwiop/CPiFZ+F56KsNRNf35jw+8wcu8pmLsjxpBxAO9g==} engines: {node: '>=18.18'} hasBin: true peerDependencies: @@ -3517,40 +3513,40 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) - '@prisma/client@6.16.2(prisma@6.16.2(typescript@5.8.3))(typescript@5.8.3)': + '@prisma/client@6.16.2(prisma@6.18.0(typescript@5.8.3))(typescript@5.8.3)': optionalDependencies: - prisma: 6.16.2(typescript@5.8.3) + prisma: 6.18.0(typescript@5.8.3) typescript: 5.8.3 - '@prisma/config@6.16.2': + '@prisma/config@6.18.0': dependencies: c12: 3.1.0 deepmerge-ts: 7.1.5 - effect: 3.16.12 + effect: 3.18.4 empathic: 2.0.0 transitivePeerDependencies: - magicast - '@prisma/debug@6.16.2': {} + '@prisma/debug@6.18.0': {} - '@prisma/engines-version@6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43': {} + '@prisma/engines-version@6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f': {} - '@prisma/engines@6.16.2': + '@prisma/engines@6.18.0': dependencies: - '@prisma/debug': 6.16.2 - '@prisma/engines-version': 6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43 - '@prisma/fetch-engine': 6.16.2 - '@prisma/get-platform': 6.16.2 + '@prisma/debug': 6.18.0 + '@prisma/engines-version': 6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f + '@prisma/fetch-engine': 6.18.0 + '@prisma/get-platform': 6.18.0 - '@prisma/fetch-engine@6.16.2': + '@prisma/fetch-engine@6.18.0': dependencies: - '@prisma/debug': 6.16.2 - '@prisma/engines-version': 6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43 - '@prisma/get-platform': 6.16.2 + '@prisma/debug': 6.18.0 + '@prisma/engines-version': 6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f + '@prisma/get-platform': 6.18.0 - '@prisma/get-platform@6.16.2': + '@prisma/get-platform@6.18.0': dependencies: - '@prisma/debug': 6.16.2 + '@prisma/debug': 6.18.0 '@protobufjs/aspromise@1.1.2': {} @@ -4076,7 +4072,7 @@ snapshots: dotenv: 16.6.1 exsolve: 1.0.7 giget: 2.0.0 - jiti: 2.4.2 + jiti: 2.6.1 ohash: 2.0.11 pathe: 2.0.3 perfect-debounce: 1.0.0 @@ -4208,7 +4204,7 @@ snapshots: ee-first@1.1.1: {} - effect@3.16.12: + effect@3.18.4: dependencies: '@standard-schema/spec': 1.0.0 fast-check: 3.23.2 @@ -4507,8 +4503,6 @@ snapshots: filelist: 1.0.4 minimatch: 3.1.2 - jiti@2.4.2: {} - jiti@2.6.1: {} js-base64@3.7.7: {} @@ -4789,10 +4783,10 @@ snapshots: dependencies: xtend: 4.0.2 - prisma@6.16.2(typescript@5.8.3): + prisma@6.18.0(typescript@5.8.3): dependencies: - '@prisma/config': 6.16.2 - '@prisma/engines': 6.16.2 + '@prisma/config': 6.18.0 + '@prisma/engines': 6.18.0 optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: diff --git a/prisma/migrations/20251025113931_add_auto_auth_and_device_updates/migration.sql b/prisma/migrations/20251025113931_add_auto_auth_and_device_updates/migration.sql new file mode 100644 index 0000000..f152f9f --- /dev/null +++ b/prisma/migrations/20251025113931_add_auto_auth_and_device_updates/migration.sql @@ -0,0 +1,47 @@ +/* + Warnings: + + - A unique constraint covering the columns `[namespace]` on the table `Device` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE `AppInstall` ADD COLUMN `deviceType` VARCHAR(191) NULL, + ADD COLUMN `isReadOnly` BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE `Device` ADD COLUMN `namespace` VARCHAR(191) NULL; + +-- 将所有设备的 namespace 设置为对应的 uuid 值,避免唯一键冲突 +UPDATE `Device` SET `namespace` = `uuid` WHERE `namespace` IS NULL; + +-- CreateTable +CREATE TABLE `AutoAuth` ( + `id` VARCHAR(191) NOT NULL, + `deviceId` INTEGER NOT NULL, + `password` VARCHAR(191) NULL, + `deviceType` VARCHAR(191) NULL, + `isReadOnly` BOOLEAN NOT NULL DEFAULT false, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + UNIQUE INDEX `AutoAuth_deviceId_password_key`(`deviceId`, `password`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 为每个设备创建默认的 AutoAuth 记录,将 Device.password 复制为 AutoAuth.password +INSERT INTO `AutoAuth` (`id`, `deviceId`, `password`, `deviceType`, `isReadOnly`, `createdAt`, `updatedAt`) +SELECT + CONCAT('autoauth_', UUID()), + `id`, + `password`, + NULL, + false, + CURRENT_TIMESTAMP(3), + CURRENT_TIMESTAMP(3) +FROM `Device`; + +-- CreateIndex +CREATE UNIQUE INDEX `Device_namespace_key` ON `Device`(`namespace`); + +-- AddForeignKey +ALTER TABLE `AutoAuth` ADD CONSTRAINT `AutoAuth_deviceId_fkey` FOREIGN KEY (`deviceId`) REFERENCES `Device`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9fe0835..f735c19 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,7 +8,7 @@ datasource db { } model KVStore { - deviceId Int // 设备ID,作为namespace的一部分 + deviceId Int key String value Json creatorIp String? @default("") @@ -48,22 +48,41 @@ model Device { updatedAt DateTime @updatedAt password String? passwordHint String? + namespace String? @unique // 用户自定义的唯一命名空间 // 关联关系 account Account? @relation(fields: [accountId], references: [id], onDelete: SetNull) appInstalls AppInstall[] kvStore KVStore[] // 设备相关的KV存储 + autoAuths AutoAuth[] // 自动授权配置 } model AppInstall { id String @id @default(cuid()) deviceId Int // 关联的设备ID appId String // 应用ID (SHA256 hash) - token String @unique // 应用安装的唯一访问令牌,拥有完整KV读写权限 + token String @unique // 应用安装的唯一访问令牌,拥有完整KV读写权限 note String? // 安装备注 + isReadOnly Boolean @default(false) // 是否只读 + deviceType String? // 设备类型: teacher(教师), student(学生), classroom(班级一体机), parent(家长) installedAt DateTime @default(now()) updatedAt DateTime @updatedAt // 关联关系 device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade) } + +model AutoAuth { + id String @id @default(cuid()) + deviceId Int // 关联的设备ID + password String? // 配置密码,可以为空 + deviceType String? // 自动设备类型: teacher(教师), student(学生), classroom(班级一体机), parent(家长) + isReadOnly Boolean @default(false) // 是否只读 + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // 关联关系 + device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade) + + @@unique([deviceId, password]) // 同一设备的密码必须唯一 +} diff --git a/routes/apps.js b/routes/apps.js index 2aebfd9..62f892b 100644 --- a/routes/apps.js +++ b/routes/apps.js @@ -2,9 +2,11 @@ import { Router } from "express"; const router = Router(); import { uuidAuth } from "../middleware/uuidAuth.js"; import { jwtAuth } from "../middleware/jwt-auth.js"; +import { kvTokenAuth } from "../middleware/kvTokenAuth.js"; import { PrismaClient } from "@prisma/client"; import crypto from "crypto"; import errors from "../utils/errors.js"; +import { verifyDevicePassword } from "../utils/crypto.js"; const prisma = new PrismaClient(); @@ -158,4 +160,218 @@ router.get( }) ); +/** + * POST /apps/auth/token + * 通过 namespace 和密码获取 token (自动授权) + * Body: { namespace: string, password: string, appId: string } + */ +router.post( + "/auth/token", + errors.catchAsync(async (req, res, next) => { + const { namespace, password, appId } = req.body; + + if (!namespace) { + return next(errors.createError(400, "需要提供 namespace")); + } + + if (!appId) { + return next(errors.createError(400, "需要提供 appId")); + } + + // 通过 namespace 查找设备 + const device = await prisma.device.findUnique({ + where: { namespace }, + include: { + autoAuths: true, + }, + }); + + if (!device) { + return next(errors.createError(404, "设备不存在或 namespace 不正确")); + } + + // 查找匹配的自动授权配置 + let matchedAutoAuth = null; + + // 如果提供了密码,查找匹配密码的自动授权 + if (password) { + // 首先尝试直接匹配明文密码 + matchedAutoAuth = device.autoAuths.find(auth => auth.password === password); + + // 如果没有匹配到,尝试验证哈希密码(向后兼容) + if (!matchedAutoAuth) { + for (const autoAuth of device.autoAuths) { + if (autoAuth.password && autoAuth.password.startsWith('$2')) { // bcrypt 哈希以 $2 开头 + try { + if (await verifyDevicePassword(password, autoAuth.password)) { + matchedAutoAuth = autoAuth; + + // 自动迁移:将哈希密码更新为明文密码 + await prisma.autoAuth.update({ + where: { id: autoAuth.id }, + data: { password: password }, // 保存明文密码 + }); + + console.log(`AutoAuth ${autoAuth.id} 密码已自动迁移为明文`); + break; + } + } catch (err) { + // 如果验证失败,继续尝试下一个 + continue; + } + } + } + } + + if (!matchedAutoAuth) { + return next(errors.createError(401, "密码不正确")); + } + } else { + // 如果没有提供密码,查找密码为空的自动授权 + matchedAutoAuth = device.autoAuths.find(auth => !auth.password); + + if (!matchedAutoAuth) { + return next(errors.createError(401, "需要提供密码")); + } + } + + // 根据自动授权配置创建 AppInstall + const token = crypto.randomBytes(32).toString("hex"); + + const installation = await prisma.appInstall.create({ + data: { + deviceId: device.id, + appId: appId, + token, + note: null, + isReadOnly: matchedAutoAuth.isReadOnly, + deviceType: matchedAutoAuth.deviceType, + }, + }); + + return res.status(201).json({ + success: true, + token: installation.token, + deviceType: installation.deviceType, + isReadOnly: installation.isReadOnly, + installedAt: installation.installedAt, + }); + }) +); + +/** + * POST /apps/tokens/:token/set-student-name + * 设置学生名称 (仅限学生类型的 token) + * Body: { name: string } + */ +router.post( + "/tokens/:token/set-student-name", + errors.catchAsync(async (req, res, next) => { + const { token } = req.params; + const { name } = req.body; + + if (!name) { + return next(errors.createError(400, "需要提供学生名称")); + } + + // 查找 token 对应的应用安装记录 + const appInstall = await prisma.appInstall.findUnique({ + where: { token }, + include: { + device: true, + }, + }); + + if (!appInstall) { + return next(errors.createError(404, "Token 不存在")); + } + + // 验证 token 类型是否为 student + if (appInstall.deviceType !== 'student') { + return next(errors.createError(403, "只有学生类型的 token 可以设置名称")); + } + + // 读取设备的 classworks-list-main 键值 + const kvRecord = await prisma.kVStore.findUnique({ + where: { + deviceId_key: { + deviceId: appInstall.deviceId, + key: 'classworks-list-main', + }, + }, + }); + + if (!kvRecord) { + return next(errors.createError(404, "设备未设置学生列表")); + } + + // 解析学生列表 + let studentList; + try { + studentList = kvRecord.value; + if (!Array.isArray(studentList)) { + return next(errors.createError(500, "学生列表格式错误")); + } + } catch (error) { + return next(errors.createError(500, "无法解析学生列表")); + } + + // 验证名称是否在学生列表中 + const studentExists = studentList.some(student => student.name === name); + + if (!studentExists) { + return next(errors.createError(400, "该名称不在学生列表中")); + } + + // 更新 AppInstall 的 note 字段 + const updatedInstall = await prisma.appInstall.update({ + where: { id: appInstall.id }, + data: { note: name }, + }); + + return res.json({ + success: true, + token: updatedInstall.token, + name: updatedInstall.note, + deviceType: updatedInstall.deviceType, + updatedAt: updatedInstall.updatedAt, + }); + }) +); + +/** + * PUT /apps/tokens/:token/note + * 更新令牌的备注信息 + * Body: { note: string } + */ +router.put( + "/tokens/:token/note", + errors.catchAsync(async (req, res, next) => { + const { token } = req.params; + const { note } = req.body; + + // 查找 token 对应的应用安装记录 + const appInstall = await prisma.appInstall.findUnique({ + where: { token }, + }); + + if (!appInstall) { + return next(errors.createError(404, "Token 不存在")); + } + + // 更新 AppInstall 的 note 字段 + const updatedInstall = await prisma.appInstall.update({ + where: { id: appInstall.id }, + data: { note: note || null }, + }); + + return res.json({ + success: true, + token: updatedInstall.token, + note: updatedInstall.note, + updatedAt: updatedInstall.updatedAt, + }); + }) +); + export default router; \ No newline at end of file diff --git a/routes/auto-auth.js b/routes/auto-auth.js new file mode 100644 index 0000000..68b74ed --- /dev/null +++ b/routes/auto-auth.js @@ -0,0 +1,343 @@ +import { Router } from "express"; +const router = Router(); +import { jwtAuth } from "../middleware/jwt-auth.js"; +import { PrismaClient } from "@prisma/client"; +import errors from "../utils/errors.js"; + +const prisma = new PrismaClient(); + +/** + * GET /auto-auth/devices/:uuid/auth-configs + * 获取设备的所有自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户) + */ +router.get( + "/devices/:uuid/auth-configs", + jwtAuth, + errors.catchAsync(async (req, res, next) => { + const { uuid } = req.params; + const account = res.locals.account; + + // 查找设备并验证是否属于当前账户 + const device = await prisma.device.findUnique({ + where: { uuid }, + }); + + if (!device) { + return next(errors.createError(404, "设备不存在")); + } + + // 验证设备是否绑定到当前账户 + if (!device.accountId || device.accountId !== account.id) { + return next(errors.createError(403, "该设备未绑定到您的账户")); + } + + const autoAuths = await prisma.autoAuth.findMany({ + where: { deviceId: device.id }, + orderBy: { createdAt: 'desc' }, + }); + + // 返回配置,智能处理密码显示 + const configs = autoAuths.map(auth => { + // 检查是否是 bcrypt 哈希密码 + const isHashedPassword = auth.password && auth.password.startsWith('$2'); + + return { + id: auth.id, + password: isHashedPassword ? null : auth.password, // 哈希密码不返回 + isLegacyHash: isHashedPassword, // 标记是否为旧的哈希密码 + deviceType: auth.deviceType, + isReadOnly: auth.isReadOnly, + createdAt: auth.createdAt, + updatedAt: auth.updatedAt, + }; + }); + + return res.json({ + success: true, + configs, + }); + }) +); + +/** + * POST /auto-auth/devices/:uuid/auth-configs + * 创建新的自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户) + * Body: { password?: string, deviceType?: string, isReadOnly?: boolean } + */ +router.post( + "/devices/:uuid/auth-configs", + jwtAuth, + errors.catchAsync(async (req, res, next) => { + const { uuid } = req.params; + const account = res.locals.account; + const { password, deviceType, isReadOnly } = req.body; + + // 查找设备并验证是否属于当前账户 + const device = await prisma.device.findUnique({ + where: { uuid }, + }); + + if (!device) { + return next(errors.createError(404, "设备不存在")); + } + + // 验证设备是否绑定到当前账户 + if (!device.accountId || device.accountId !== account.id) { + return next(errors.createError(403, "该设备未绑定到您的账户")); + } + + // 验证 deviceType 如果提供的话 + const validDeviceTypes = ['teacher', 'student', 'classroom', 'parent']; + if (deviceType && !validDeviceTypes.includes(deviceType)) { + return next(errors.createError(400, `设备类型必须是以下之一: ${validDeviceTypes.join(', ')}`)); + } + + // 规范化密码:空字符串视为 null + const plainPassword = (password !== undefined && password !== '') ? password : null; + + // 查询该设备的所有自动授权配置,本地检查是否存在相同密码 + const allAuths = await prisma.autoAuth.findMany({ + where: { deviceId: device.id }, + }); + + const existingAuth = allAuths.find(auth => auth.password === plainPassword); + + if (existingAuth) { + return next(errors.createError(400, "该密码的自动授权配置已存在")); + } + + // 创建新的自动授权配置(密码明文存储) + const autoAuth = await prisma.autoAuth.create({ + data: { + deviceId: device.id, + password: plainPassword, + deviceType: deviceType || null, + isReadOnly: isReadOnly || false, + }, + }); + + return res.status(201).json({ + success: true, + config: { + id: autoAuth.id, + password: autoAuth.password, // 返回明文密码 + deviceType: autoAuth.deviceType, + isReadOnly: autoAuth.isReadOnly, + createdAt: autoAuth.createdAt, + }, + }); + }) +);/** + * PUT /auto-auth/devices/:uuid/auth-configs/:configId + * 更新自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户) + * Body: { password?: string, deviceType?: string, isReadOnly?: boolean } + */ +router.put( + "/devices/:uuid/auth-configs/:configId", + jwtAuth, + errors.catchAsync(async (req, res, next) => { + const { uuid, configId } = req.params; + const account = res.locals.account; + const { password, deviceType, isReadOnly } = req.body; + + // 查找设备并验证是否属于当前账户 + const device = await prisma.device.findUnique({ + where: { uuid }, + }); + + if (!device) { + return next(errors.createError(404, "设备不存在")); + } + + // 验证设备是否绑定到当前账户 + if (!device.accountId || device.accountId !== account.id) { + return next(errors.createError(403, "该设备未绑定到您的账户")); + } + + // 查找自动授权配置 + const autoAuth = await prisma.autoAuth.findUnique({ + where: { id: configId }, + }); + + if (!autoAuth) { + return next(errors.createError(404, "自动授权配置不存在")); + } + + // 确保配置属于当前设备 + if (autoAuth.deviceId !== device.id) { + return next(errors.createError(403, "无权操作此配置")); + } + + // 验证 deviceType + const validDeviceTypes = ['teacher', 'student', 'classroom', 'parent']; + if (deviceType && !validDeviceTypes.includes(deviceType)) { + return next(errors.createError(400, `设备类型必须是以下之一: ${validDeviceTypes.join(', ')}`)); + } + + // 准备更新数据 + const updateData = {}; + + if (password !== undefined) { + // 规范化密码:空字符串视为 null + const plainPassword = (password !== '') ? password : null; + + // 查询该设备的所有配置,本地检查新密码是否与其他配置冲突 + const allAuths = await prisma.autoAuth.findMany({ + where: { deviceId: device.id }, + }); + + const conflictAuth = allAuths.find(auth => + auth.id !== configId && auth.password === plainPassword + ); + + if (conflictAuth) { + return next(errors.createError(400, "该密码已被其他配置使用")); + } + + updateData.password = plainPassword; + } + + if (deviceType !== undefined) { + updateData.deviceType = deviceType || null; + } + + if (isReadOnly !== undefined) { + updateData.isReadOnly = isReadOnly; + } + + // 更新配置 + const updatedAuth = await prisma.autoAuth.update({ + where: { id: configId }, + data: updateData, + }); + + return res.json({ + success: true, + config: { + id: updatedAuth.id, + password: updatedAuth.password, // 返回明文密码 + deviceType: updatedAuth.deviceType, + isReadOnly: updatedAuth.isReadOnly, + updatedAt: updatedAuth.updatedAt, + }, + }); + }) +); + +/** + * DELETE /auto-auth/devices/:uuid/auth-configs/:configId + * 删除自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户) + */ +router.delete( + "/devices/:uuid/auth-configs/:configId", + jwtAuth, + errors.catchAsync(async (req, res, next) => { + const { uuid, configId } = req.params; + const account = res.locals.account; + + // 查找设备并验证是否属于当前账户 + const device = await prisma.device.findUnique({ + where: { uuid }, + }); + + if (!device) { + return next(errors.createError(404, "设备不存在")); + } + + // 验证设备是否绑定到当前账户 + if (!device.accountId || device.accountId !== account.id) { + return next(errors.createError(403, "该设备未绑定到您的账户")); + } + + // 查找自动授权配置 + const autoAuth = await prisma.autoAuth.findUnique({ + where: { id: configId }, + }); + + if (!autoAuth) { + return next(errors.createError(404, "自动授权配置不存在")); + } + + // 确保配置属于当前设备 + if (autoAuth.deviceId !== device.id) { + return next(errors.createError(403, "无权操作此配置")); + } + + // 删除配置 + await prisma.autoAuth.delete({ + where: { id: configId }, + }); + + return res.status(204).end(); + }) +); + +/** + * PUT /auto-auth/devices/:uuid/namespace + * 修改设备的 namespace (需要 JWT 认证,且设备必须绑定到该账户) + * Body: { namespace: string } + */ +router.put( + "/devices/:uuid/namespace", + jwtAuth, + errors.catchAsync(async (req, res, next) => { + const { uuid } = req.params; + const account = res.locals.account; + const { namespace } = req.body; + + if (!namespace) { + return next(errors.createError(400, "需要提供 namespace")); + } + + // 规范化 namespace:去除首尾空格 + const trimmedNamespace = namespace.trim(); + + if (!trimmedNamespace) { + return next(errors.createError(400, "namespace 不能为空")); + } + + // 查找设备并验证是否属于当前账户 + const device = await prisma.device.findUnique({ + where: { uuid }, + }); + + if (!device) { + return next(errors.createError(404, "设备不存在")); + } + + // 验证设备是否绑定到当前账户 + if (!device.accountId || device.accountId !== account.id) { + return next(errors.createError(403, "该设备未绑定到您的账户")); + } + + // 检查新的 namespace 是否已被其他设备使用 + if (device.namespace !== trimmedNamespace) { + const existingDevice = await prisma.device.findUnique({ + where: { namespace: trimmedNamespace }, + }); + + if (existingDevice) { + return next(errors.createError(409, "该 namespace 已被其他设备使用")); + } + } + + // 更新设备的 namespace + const updatedDevice = await prisma.device.update({ + where: { id: device.id }, + data: { namespace: trimmedNamespace }, + }); + + return res.json({ + success: true, + device: { + id: updatedDevice.id, + uuid: updatedDevice.uuid, + name: updatedDevice.name, + namespace: updatedDevice.namespace, + updatedAt: updatedDevice.updatedAt, + }, + }); + }) +); + +export default router; diff --git a/routes/device.js b/routes/device.js index 4ff82d0..c11d919 100644 --- a/routes/device.js +++ b/routes/device.js @@ -16,7 +16,7 @@ const prisma = new PrismaClient(); router.post( "/", errors.catchAsync(async (req, res, next) => { - const { uuid, deviceName } = req.body; + const { uuid, deviceName, namespace } = req.body; if (!uuid) { return next(errors.createError(400, "设备UUID是必需的")); @@ -35,11 +35,24 @@ router.post( return next(errors.createError(409, "设备UUID已存在")); } + // 处理 namespace:如果没有提供,则使用 uuid + const deviceNamespace = namespace && namespace.trim() ? namespace.trim() : uuid; + + // 检查 namespace 是否已被使用 + const existingNamespace = await prisma.device.findUnique({ + where: { namespace: deviceNamespace }, + }); + + if (existingNamespace) { + return next(errors.createError(409, "该 namespace 已被使用")); + } + // 创建设备 const device = await prisma.device.create({ data: { uuid, name: deviceName, + namespace: deviceNamespace, }, }); @@ -49,6 +62,7 @@ router.post( id: device.id, uuid: device.uuid, name: device.name, + namespace: device.namespace, createdAt: device.createdAt, }, }); @@ -97,6 +111,7 @@ router.get( avatarUrl: device.account.avatarUrl, } : null, isBoundToAccount: !!device.account, + namespace: device.namespace, }); }) );/** diff --git a/routes/kv-token.js b/routes/kv-token.js index 2df04d5..d0ab9a0 100644 --- a/routes/kv-token.js +++ b/routes/kv-token.js @@ -56,6 +56,54 @@ router.get( }) ); +/** + * GET /_token + * 获取当前 KV Token 的详细信息(类型、备注等) + */ +router.get( + "/_token", + errors.catchAsync(async (req, res, next) => { + const token = res.locals.token; + const deviceId = res.locals.deviceId; + + // 查找当前 token 对应的应用安装记录 + const appInstall = await prisma.appInstall.findUnique({ + where: { token }, + include: { + device: { + select: { + id: true, + uuid: true, + name: true, + namespace: true, + }, + }, + }, + }); + + if (!appInstall) { + return next(errors.createError(404, "Token 信息不存在")); + } + + return res.json({ + success: true, + token: appInstall.token, + appId: appInstall.appId, + deviceType: appInstall.deviceType, + isReadOnly: appInstall.isReadOnly, + note: appInstall.note, + installedAt: appInstall.installedAt, + updatedAt: appInstall.updatedAt, + device: { + id: appInstall.device.id, + uuid: appInstall.device.uuid, + name: appInstall.device.name, + namespace: appInstall.device.namespace, + }, + }); + }) +); + /** * GET /_keys * 获取当前token对应设备的键名列表(分页,不包括内容) @@ -200,6 +248,11 @@ router.get( router.post( "/_batchimport", errors.catchAsync(async (req, res, next) => { + // 检查token是否为只读 + if (res.locals.appInstall?.isReadOnly) { + return next(errors.createError(403, "当前token为只读模式,无法修改数据")); + } + const deviceId = res.locals.deviceId; const data = req.body; @@ -268,6 +321,11 @@ router.post( router.post( "/:key", errors.catchAsync(async (req, res, next) => { + // 检查token是否为只读 + if (res.locals.appInstall?.isReadOnly) { + return next(errors.createError(403, "当前token为只读模式,无法修改数据")); + } + const deviceId = res.locals.deviceId; const { key } = req.params; const value = req.body; @@ -313,6 +371,11 @@ router.post( router.delete( "/:key", errors.catchAsync(async (req, res, next) => { + // 检查token是否为只读 + if (res.locals.appInstall?.isReadOnly) { + return next(errors.createError(403, "当前token为只读模式,无法修改数据")); + } + const deviceId = res.locals.deviceId; const { key } = req.params;