From ef7c1f1bc95e1f8ea22c6e8205fe45a87e6c81eb Mon Sep 17 00:00:00 2001 From: WangDL Date: Sat, 9 May 2026 18:57:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=89=E5=85=A8=E5=9F=BA=E7=BA=BF=20?= =?UTF-8?q?+=204=E4=B8=AA=E5=AE=89=E5=85=A8=E6=BC=8F=E6=B4=9E=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20-=20JWT=20AuthGuard/OptionalAuthGuard,=20StrictVali?= =?UTF-8?q?dationPipe,=20=E5=85=A8=E5=B1=80=E5=BC=82=E5=B8=B8=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E5=99=A8,=20Redis=E9=99=90=E6=B5=81429,=20Apple?= =?UTF-8?q?=E7=99=BB=E5=BD=95mock=E6=A8=A1=E5=BC=8F,=20BigInt=E7=B2=BE?= =?UTF-8?q?=E5=BA=A6=E4=BF=AE=E5=A4=8D,=20SECURITY.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 12 +- SECURITY.md | 169 ++++++++++++++++ package-lock.json | 137 +++++++++++++ package.json | 8 + src/app.module.ts | 22 ++- src/common/filters/global-exception.filter.ts | 52 +++++ src/common/filters/http-exception.filter.ts | 32 --- src/common/guards/jwt-auth.guard.ts | 41 +++- src/common/guards/optional-auth.guard.ts | 21 +- ...tion.pipe.ts => strict-validation.pipe.ts} | 26 ++- src/common/types/index.ts | 2 +- src/common/utils/rate-limit.service.ts | 46 +++++ src/common/utils/security.util.ts | 80 ++++++++ src/config/jwt.config.ts | 24 ++- src/main.ts | 105 ++++++---- .../ai-analysis/ai-analysis.service.ts | 9 +- src/modules/auth/auth.controller.ts | 50 ++++- src/modules/auth/auth.module.ts | 3 +- src/modules/auth/auth.repository.ts | 29 --- src/modules/auth/auth.service.ts | 151 ++++++++++++-- .../feedback/dto/create-feedback.dto.ts | 8 + .../knowledge-base/knowledge-base.service.ts | 6 +- src/modules/system/system.controller.ts | 15 +- src/modules/system/system.module.ts | 3 + src/modules/users/users.controller.ts | 27 +-- test-crud.ts | 184 ------------------ 26 files changed, 914 insertions(+), 348 deletions(-) create mode 100644 SECURITY.md create mode 100644 src/common/filters/global-exception.filter.ts delete mode 100644 src/common/filters/http-exception.filter.ts rename src/common/pipes/{validation.pipe.ts => strict-validation.pipe.ts} (56%) create mode 100644 src/common/utils/rate-limit.service.ts create mode 100644 src/common/utils/security.util.ts delete mode 100644 src/modules/auth/auth.repository.ts delete mode 100644 test-crud.ts diff --git a/.env.example b/.env.example index a4080a7..5328fad 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,9 @@ PORT=3000 +NODE_ENV=development -DATABASE_URL="mysql://zhixi_user:Zhixi@2026!App@81.70.187.179:3306/zhixi" +DATABASE_URL="mysql://zhixi_user:Zhixi@2026!App@localhost:3306/zhixi" -REDIS_HOST=81.70.187.179 +REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= REDIS_DB=0 @@ -12,9 +13,12 @@ AI_API_KEY= AI_BASE_URL= JWT_SECRET=change_me_in_production - -NODE_ENV=development +JWT_EXPIRES_IN=1h +JWT_REFRESH_EXPIRES_IN=7d ENABLE_SWAGGER=true SWAGGER_USER=admin SWAGGER_PASSWORD=change_me + +STORAGE_DRIVER=local +STORAGE_LOCAL_PATH=./uploads diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..bc2e1ca --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,169 @@ +# 知习 api-server 安全基线 + +> v0.1 安全设计文档。本后端存储用户资料、知识库、上传文件、主动回忆回答、AI 分析结果和学习记录,第一版必须建立基础安全边界。 + +--- + +## 1. 全局安全中间件 + +| 措施 | 实现 | 文件 | +|------|------|------| +| helmet | `app.use(helmet())` 设置安全 HTTP 头 | `src/main.ts` | +| CORS | 仅允许配置域名。生产环境仅允许 `longde.cloud` | `src/main.ts` | +| body size limit | JSON 请求体最大 10MB | `src/main.ts` | +| 异常过滤 | 生产环境不返回 stack trace | `src/common/filters/global-exception.filter.ts` | + +--- + +## 2. 认证与 Token + +### JWT + +- `accessToken`: JWT,1 小时过期 +- `refreshToken`: 128 位随机 hex,入库只存 SHA-256 hash +- logout 时 `revokedAt = now()` 撤销所有 refresh token +- `/users/me` 及其所有子路由强制 `@UseGuards(JwtAuthGuard)` + +``` +POST /auth/apple → 返回 accessToken + refreshToken +POST /auth/refresh → 消耗旧 refreshToken,发放新 token pair(rotation) +POST /auth/logout → 撤销该用户所有 refresh token +``` + +### 存储安全 + +``` +refresh_tokens.tokenHash = SHA-256(实际 token) +数据库中永远不存明文 refreshToken +``` + +--- + +## 3. 权限与越权防护 + +### 资源归属校验 + +所有用户资源操作必须校验 `userId` 归属: + +```ts +// src/common/utils/security.util.ts +export async function findByIdAndUserId(delegate, id, userId, resourceName) +export function ensureOwnership(record, userId, resourceName) +``` + +### 需校验的资源 + +| 资源 | 校验字段 | +|------|---------| +| KnowledgeBase | `userId` | +| KnowledgeItem | `userId` | +| LearningSession | `userId` | +| ActiveRecallAnswer | `userId` | +| AiAnalysisJob | `userId` | +| AiAnalysisResult | `userId` | +| FocusItem | `userId` | +| ReviewCard | `userId` | +| ReviewLog | `userId` | +| DocumentImport | `userId` | + +--- + +## 4. 参数校验 + +- 全局 `StrictValidationPipe`: + - `whitelist: true` — 自动剥离未声明字段 + - `forbidNonWhitelisted: true` — 未知字段返回 400 + - 字符串字段最大长度 5000 字符 +- 分页 DTO: page≥1, limit 1-100 + +--- + +## 5. 限流(Redis) + +| 场景 | Key | 限制 | +|------|-----|------| +| 登录 | `rate:ip:{ip}:login:{date}` | 20次/IP/天 | +| 反馈 | `rate:ip:{ip}:feedback:hourly` | 5次/IP/时 | +| AI 分析 | `rate:user:{userId}:ai:daily:{date}` | 50次/用户/天 | +| 文件上传 | `rate:user:{userId}:upload:hourly` | 10次/用户/时 | + +实现: `src/common/utils/rate-limit.service.ts` + +--- + +## 6. 文件上传安全 + +| 措施 | 说明 | +|------|------| +| 类型白名单 | PDF, Word, Excel, 纯文本, Markdown, CSV, PNG, JPEG, WebP | +| 大小限制 | 最大 20MB | +| 随机文件名 | `sanitizeFilename()` 生成随机 key,不信任用户原始文件名 | +| 默认私有 | 所有文件默认私有访问 | +| 路径隔离 | `users/{userId}/...` | + +--- + +## 7. Redis 安全使用 + +- 不存核心业务结果(用户资料/知识点/AI分析结果等必须在 MySQL) +- 队列任务只存 `jobId`/`userId` 等引用 ID +- 所有临时 key 必须设置 TTL +- 防重复提交锁必须有 TTL,解锁校验 token +- 不在 Redis 中存 token 明文 + +--- + +## 8. COS 安全使用 + +- Bucket 默认私有读写 +- 后端不向前端暴露 SecretId/SecretKey +- 下载私有文件通过签名 URL +- 上传路径按 `users/{userId}/{randomKey}` 组织 +- 预留临时上传 URL(STS)机制 + +--- + +## 9. Swagger 安全 + +- 开发环境默认开启 +- 生产环境默认关闭 +- 生产环境如需开启,必须配置 Basic Auth(`SWAGGER_USER`/`SWAGGER_PASSWORD`) +- 生产环境手动设置 `ENABLE_SWAGGER=true` + +--- + +## 10. 数据库安全 + +- 不使用 root 连接业务 +- 业务账号 `zhixi_user` 仅需 SELECT/INSERT/UPDATE/DELETE +- 迁移账号和业务账号分离(`prisma db push` 与运行时连接帐号可不同) +- 数据库自动备份建议: `mysqldump zhixi | gzip > backup-$(date +%Y%m%d).sql.gz` + +### 日志中禁止打印 + +``` +DATABASE_URL(含密码) +JWT_SECRET +AI_API_KEY +COS SecretKey +用户完整 refreshToken +用户上传文件的完整内容 +Authorization header +``` + +--- + +## 11. 安全检查清单 + +- [x] helmet 已启用 +- [x] CORS 仅允许白名单域名 +- [x] JWT + refresh token rotation + hash 存储 +- [x] logout 撤销 refresh token +- [x] 所有用户数据接口需要认证 +- [x] 资源所有权校验工具已就绪 +- [x] StrictValidationPipe 全局启用(whitelist + forbidNonWhitelisted) +- [x] Redis 限流已实现 +- [x] 文件类型/大小白名单 +- [x] 全局异常过滤器生产环境不暴露 stack trace +- [x] Swagger 生产环境默认关闭 +- [x] 敏感信息不在日志中打印原则已确立 diff --git a/package-lock.json b/package-lock.json index f79bd97..fedc3b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,11 +15,17 @@ "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.4.2", + "@nestjs/throttler": "^6.5.0", + "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "helmet": "^8.1.0", "ioredis": "^5.10.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.1" @@ -31,9 +37,11 @@ "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", "@prisma/client": "^5.22.0", + "@types/bcryptjs": "^2.4.6", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.12.3", + "@types/passport-jwt": "^4.0.1", "@types/supertest": "^7.0.0", "bullmq": "^5.76.6", "eslint": "^9.18.0", @@ -2577,6 +2585,16 @@ } } }, + "node_modules/@nestjs/passport": { + "version": "11.0.5", + "resolved": "https://registry.npmmirror.com/@nestjs/passport/-/passport-11.0.5.tgz", + "integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "passport": "^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, "node_modules/@nestjs/platform-express": { "version": "11.1.19", "resolved": "https://registry.npmmirror.com/@nestjs/platform-express/-/platform-express-11.1.19.tgz", @@ -2683,6 +2701,17 @@ } } }, + "node_modules/@nestjs/throttler": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/@nestjs/throttler/-/throttler-6.5.0.tgz", + "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-1.8.0.tgz", @@ -2956,6 +2985,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmmirror.com/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -3124,6 +3160,38 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmmirror.com/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmmirror.com/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.15.1.tgz", @@ -4349,6 +4417,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz", @@ -6380,6 +6457,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/html-escaper/-/html-escaper-2.0.2.tgz", @@ -8471,6 +8557,43 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmmirror.com/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", @@ -8548,6 +8671,11 @@ "node": ">=8" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -10287,6 +10415,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmmirror.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index afc713e..30ef1b5 100644 --- a/package.json +++ b/package.json @@ -26,11 +26,17 @@ "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.4.2", + "@nestjs/throttler": "^6.5.0", + "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "helmet": "^8.1.0", "ioredis": "^5.10.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.1" @@ -42,9 +48,11 @@ "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", "@prisma/client": "^5.22.0", + "@types/bcryptjs": "^2.4.6", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.12.3", + "@types/passport-jwt": "^4.0.1", "@types/supertest": "^7.0.0", "bullmq": "^5.76.6", "eslint": "^9.18.0", diff --git a/src/app.module.ts b/src/app.module.ts index 2a3fd05..a936498 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,11 +1,15 @@ import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { APP_FILTER, APP_PIPE } from '@nestjs/core'; +import { JwtModule } from '@nestjs/jwt'; + import { PrismaModule } from './infrastructure/database/prisma.module'; import { RedisModule } from './infrastructure/redis/redis.module'; import { QueueModule } from './infrastructure/queue/queue.module'; import { AiModule } from './infrastructure/ai/ai.module'; import { StorageModule } from './infrastructure/storage/storage.module'; import { LoggerModule } from './infrastructure/logger/logger.module'; + import { SystemModule } from './modules/system/system.module'; import { AuthModule } from './modules/auth/auth.module'; import { UsersModule } from './modules/users/users.module'; @@ -22,6 +26,9 @@ import { NotificationsModule } from './modules/notifications/notifications.modul import { FeedbackModule } from './modules/feedback/feedback.module'; import { WaitlistModule } from './modules/waitlist/waitlist.module'; +import { GlobalExceptionFilter } from './common/filters/global-exception.filter'; +import { StrictValidationPipe } from './common/pipes/strict-validation.pipe'; + import appConfig from './config/app.config'; import databaseConfig from './config/database.config'; import redisConfig from './config/redis.config'; @@ -42,6 +49,15 @@ import storageConfig from './config/storage.config'; storageConfig, ], }), + JwtModule.registerAsync({ + global: true, + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get('jwt.secret'), + signOptions: { expiresIn: config.get('jwt.expiresIn', '1h') as any }, + }), + }), PrismaModule, RedisModule, QueueModule, @@ -64,5 +80,9 @@ import storageConfig from './config/storage.config'; FeedbackModule, WaitlistModule, ], + providers: [ + { provide: APP_FILTER, useClass: GlobalExceptionFilter }, + { provide: APP_PIPE, useClass: StrictValidationPipe }, + ], }) export class AppModule {} diff --git a/src/common/filters/global-exception.filter.ts b/src/common/filters/global-exception.filter.ts new file mode 100644 index 0000000..5356daf --- /dev/null +++ b/src/common/filters/global-exception.filter.ts @@ -0,0 +1,52 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Request, Response } from 'express'; + +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(GlobalExceptionFilter.name); + + constructor(private readonly configService: ConfigService) {} + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + const isProduction = + this.configService.get('app.nodeEnv') === 'production'; + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let message = '服务器内部错误'; + + if (exception instanceof HttpException) { + status = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + message = + typeof exceptionResponse === 'string' + ? exceptionResponse + : (exceptionResponse as any).message || exception.message; + if (Array.isArray(message)) message = message.join('; '); + } + + if (status >= 500) { + this.logger.error( + `[${request.method}] ${request.url} -> ${status}: ${message}`, + isProduction ? undefined : (exception as any)?.stack, + ); + } + + response.status(status).json({ + success: false, + statusCode: status, + message, + ...(isProduction ? {} : { path: request.url }), + }); + } +} diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts deleted file mode 100644 index 8b9f815..0000000 --- a/src/common/filters/http-exception.filter.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - ExceptionFilter, - Catch, - ArgumentsHost, - HttpException, - HttpStatus, -} from '@nestjs/common'; - -@Catch() -export class HttpExceptionFilter implements ExceptionFilter { - catch(exception: unknown, host: ArgumentsHost) { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - - const status = - exception instanceof HttpException - ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; - - const message = - exception instanceof HttpException - ? exception.message - : 'Internal server error'; - - response.status(status).json({ - success: false, - statusCode: status, - message, - timestamp: new Date().toISOString(), - }); - } -} diff --git a/src/common/guards/jwt-auth.guard.ts b/src/common/guards/jwt-auth.guard.ts index 10836c6..a83bba1 100644 --- a/src/common/guards/jwt-auth.guard.ts +++ b/src/common/guards/jwt-auth.guard.ts @@ -1,9 +1,42 @@ -import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; @Injectable() export class JwtAuthGuard implements CanActivate { - canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest(); - return !!request.user; + constructor( + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = this.extractToken(request); + + if (!token) { + throw new UnauthorizedException('请先登录'); + } + + try { + const payload = await this.jwtService.verifyAsync(token, { + secret: this.configService.get('jwt.secret'), + }); + request.user = { id: String(payload.sub), email: payload.email }; + return true; + } catch { + throw new UnauthorizedException('登录已过期,请重新登录'); + } + } + + private extractToken(request: Request): string | undefined { + const authHeader = request.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) return undefined; + return authHeader.split(' ')[1]; } } diff --git a/src/common/guards/optional-auth.guard.ts b/src/common/guards/optional-auth.guard.ts index 83a6495..12732aa 100644 --- a/src/common/guards/optional-auth.guard.ts +++ b/src/common/guards/optional-auth.guard.ts @@ -1,8 +1,27 @@ import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; @Injectable() export class OptionalAuthGuard implements CanActivate { - canActivate(_context: ExecutionContext): boolean { + constructor( + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) return true; + + try { + const token = authHeader.split(' ')[1]; + const payload = await this.jwtService.verifyAsync(token, { + secret: this.configService.get('jwt.secret'), + }); + request.user = { id: String(payload.sub), email: payload.email }; + } catch {} return true; } } diff --git a/src/common/pipes/validation.pipe.ts b/src/common/pipes/strict-validation.pipe.ts similarity index 56% rename from src/common/pipes/validation.pipe.ts rename to src/common/pipes/strict-validation.pipe.ts index b1e350b..b35eb2e 100644 --- a/src/common/pipes/validation.pipe.ts +++ b/src/common/pipes/strict-validation.pipe.ts @@ -1,31 +1,47 @@ import { - PipeTransform, Injectable, + PipeTransform, ArgumentMetadata, BadRequestException, } from '@nestjs/common'; -import { validate } from 'class-validator'; import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; @Injectable() -export class ValidationPipe implements PipeTransform { +export class StrictValidationPipe implements PipeTransform { + private readonly maxStringLength = 5000; + async transform(value: any, { metatype }: ArgumentMetadata) { if (!metatype || !this.toValidate(metatype)) { return value; } const object = plainToInstance(metatype, value); - const errors = await validate(object); + const errors = await validate(object, { + whitelist: true, + forbidNonWhitelisted: true, + }); if (errors.length > 0) { const messages = errors.map((err) => Object.values(err.constraints || {}).join(', '), ); throw new BadRequestException(messages); } - return value; + this.validateStringLengths(object); + return object; } private toValidate(metatype: Function): boolean { const types: Function[] = [String, Boolean, Number, Array, Object]; return !types.includes(metatype); } + + private validateStringLengths(obj: any) { + for (const key of Object.keys(obj)) { + if (typeof obj[key] === 'string' && obj[key].length > this.maxStringLength) { + throw new BadRequestException( + `字段 ${key} 长度不能超过 ${this.maxStringLength} 字符`, + ); + } + } + } } diff --git a/src/common/types/index.ts b/src/common/types/index.ts index 604da24..6b0e053 100644 --- a/src/common/types/index.ts +++ b/src/common/types/index.ts @@ -1,5 +1,5 @@ export interface UserPayload { - id: number; + id: string; email?: string; nickname?: string; } diff --git a/src/common/utils/rate-limit.service.ts b/src/common/utils/rate-limit.service.ts new file mode 100644 index 0000000..053a7f6 --- /dev/null +++ b/src/common/utils/rate-limit.service.ts @@ -0,0 +1,46 @@ +import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; +import { RedisService } from '../../infrastructure/redis/redis.service'; + +@Injectable() +export class RateLimitService { + constructor(private readonly redis: RedisService) {} + + async checkLimit( + key: string, + maxRequests: number, + windowSeconds: number, + ): Promise { + const count = await this.redis.incr(key); + if (count === 1) { + await this.redis.expire(key, windowSeconds); + } + if (count > maxRequests) { + throw new HttpException( + { + statusCode: HttpStatus.TOO_MANY_REQUESTS, + message: `请求过于频繁,请${windowSeconds}秒后再试`, + retryAfter: windowSeconds, + }, + HttpStatus.TOO_MANY_REQUESTS, + ); + } + } + + async loginLimit(ip: string): Promise { + const today = new Date().toISOString().split('T')[0]; + await this.checkLimit(`rate:ip:${ip}:login:${today}`, 20, 1800); + } + + async feedbackLimit(ip: string): Promise { + await this.checkLimit(`rate:ip:${ip}:feedback:hourly`, 5, 3600); + } + + async aiAnalysisLimit(userId: string): Promise { + const today = new Date().toISOString().split('T')[0]; + await this.checkLimit(`rate:user:${userId}:ai:daily:${today}`, 50, 86400); + } + + async fileUploadLimit(userId: string): Promise { + await this.checkLimit(`rate:user:${userId}:upload:hourly`, 10, 3600); + } +} diff --git a/src/common/utils/security.util.ts b/src/common/utils/security.util.ts new file mode 100644 index 0000000..9e04f7d --- /dev/null +++ b/src/common/utils/security.util.ts @@ -0,0 +1,80 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +type Delegate = { + findUnique(args: { where: any }): Promise; + findFirst(args: { where: any }): Promise; +}; + +export async function findByIdAndUserId( + delegate: T, + id: number | bigint, + userId: number | bigint, + resourceName: string, +) { + const record = await delegate.findUnique({ where: { id } } as any); + if (!record) { + throw new BadRequestException(`${resourceName}不存在`); + } + if (record.userId !== userId) { + throw new ForbiddenException(`无权访问该${resourceName}`); + } + return record; +} + +export function ensureOwnership( + record: any, + userId: number | bigint, + resourceName: string, +) { + if (!record) { + throw new BadRequestException(`${resourceName}不存在`); + } + if (record.userId !== userId) { + throw new ForbiddenException(`无权访问该${resourceName}`); + } + return record; +} + +export function sanitizeFilename(originalName: string): string { + const ext = originalName.split('.').pop()?.toLowerCase() || ''; + const safeExt = ext.replace(/[^a-z0-9]/g, ''); + const randomName = + Date.now().toString(36) + Math.random().toString(36).substring(2, 15); + return `${randomName}.${safeExt}`; +} + +export const ALLOWED_FILE_TYPES = [ + 'application/pdf', + 'text/plain', + 'text/markdown', + 'text/csv', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'image/png', + 'image/jpeg', + 'image/webp', +]; + +export const MAX_FILE_SIZE = 20 * 1024 * 1024; + +export function validateFileUpload( + mimeType: string, + sizeBytes: number, +): void { + if (!ALLOWED_FILE_TYPES.includes(mimeType)) { + throw new BadRequestException( + `不支持的文件类型: ${mimeType},仅支持 PDF/Word/Excel/文本/图片`, + ); + } + if (sizeBytes > MAX_FILE_SIZE) { + throw new BadRequestException( + `文件大小不能超过 ${MAX_FILE_SIZE / 1024 / 1024}MB`, + ); + } +} + +export function maskSecret(secret: string): string { + if (!secret || secret.length < 8) return '***'; + return secret.slice(0, 4) + '***' + secret.slice(-4); +} diff --git a/src/config/jwt.config.ts b/src/config/jwt.config.ts index 28cff85..bd0a2c3 100644 --- a/src/config/jwt.config.ts +++ b/src/config/jwt.config.ts @@ -1,7 +1,21 @@ import { registerAs } from '@nestjs/config'; -export default registerAs('jwt', () => ({ - secret: process.env.JWT_SECRET || 'change_me_in_production', - expiresIn: process.env.JWT_EXPIRES_IN || '1h', - refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d', -})); +export default registerAs('jwt', () => { + const secret = process.env.JWT_SECRET; + if (!secret || secret === 'change_me_in_production') { + if (process.env.NODE_ENV === 'production') { + throw new Error( + '生产环境必须设置环境变量 JWT_SECRET,不能使用默认值', + ); + } + console.warn( + '\n⚠️ 警告: JWT_SECRET 使用的是默认值 "change_me_in_production"\n' + + ' 部署到生产环境前请务必设置环境变量 JWT_SECRET\n', + ); + } + return { + secret: secret || 'change_me_in_production', + expiresIn: process.env.JWT_EXPIRES_IN || '1h', + refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d', + }; +}); diff --git a/src/main.ts b/src/main.ts index e5339c0..ca55cb9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,51 +1,90 @@ import { NestFactory } from '@nestjs/core'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { AppModule } from './app.module'; +import helmet from 'helmet'; +import { ConfigService } from '@nestjs/config'; +import { NestExpressApplication } from '@nestjs/platform-express'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule); + const configService = app.get(ConfigService); + const isProduction = configService.get('app.nodeEnv') === 'production'; + + app.use(helmet()); app.enableCors({ - origin: ['https://longde.cloud', 'http://localhost:4321'], + origin: isProduction + ? [configService.get('app.allowedOrigin', 'https://longde.cloud')] + : ['https://longde.cloud', 'http://localhost:4321', 'http://localhost:5173'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], credentials: true, + maxAge: 86400, }); - const config = new DocumentBuilder() - .setTitle('知习 API') - .setDescription('知习 AI-first 系统化学习产品后端 API(v0.1)') - .setVersion('0.1.0') - .addTag('health', '服务健康检查') - .addTag('auth', '用户认证') - .addTag('users', '用户管理') - .addTag('knowledge-base', '知识库') - .addTag('knowledge-items', '知识点') - .addTag('document-import', '资料导入') - .addTag('learning-session', '学习会话') - .addTag('active-recall', '主动回忆') - .addTag('ai-analysis', 'AI 分析') - .addTag('review', '复习管理') - .addTag('focus-items', '待巩固项') - .addTag('learning-activity', '学习活跃') - .addTag('notifications', '消息通知') - .addTag('feedback', '用户反馈') - .addTag('waitlist', '等待名单') - .build(); + app.useBodyParser('json', { limit: '10mb' }); - const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api-docs', app, document, { - swaggerOptions: { persistAuthorization: true }, - customCss: '.swagger-ui .topbar { display: none }', - customSiteTitle: '知习 API 文档', - }); + const swaggerEnabled = !isProduction || configService.get('app.enableSwagger') === true; + if (swaggerEnabled) { + const config = new DocumentBuilder() + .setTitle('知习 API') + .setDescription('知习 AI-first 系统化学习产品后端 API') + .setVersion('0.1.0') + .addBearerAuth() + .addTag('health', '服务健康检查') + .addTag('auth', '用户认证') + .addTag('users', '用户管理') + .addTag('knowledge-base', '知识库') + .addTag('knowledge-items', '知识点') + .addTag('document-import', '资料导入') + .addTag('learning-session', '学习会话') + .addTag('active-recall', '主动回忆') + .addTag('ai-analysis', 'AI 分析') + .addTag('review', '复习管理') + .addTag('focus-items', '待巩固项') + .addTag('learning-activity', '学习活跃') + .addTag('notifications', '消息通知') + .addTag('feedback', '用户反馈') + .addTag('waitlist', '等待名单') + .build(); - app.getHttpAdapter().get('/api-docs-json', (_req: any, res: any) => { - res.json(document); - }); + const document = SwaggerModule.createDocument(app, config); - const port = process.env.PORT ?? 3000; + if (isProduction) { + const swaggerUser = configService.get('app.swaggerUser', 'admin'); + const swaggerPassword = configService.get('app.swaggerPassword'); + if (swaggerPassword) { + app.use('/api-docs', (req: any, res: any, next: any) => { + const auth = req.headers.authorization; + if (!auth?.startsWith('Basic ')) { + res.setHeader('WWW-Authenticate', 'Basic'); + return res.status(401).send('Authentication required'); + } + const [user, pass] = Buffer.from(auth.split(' ')[1], 'base64') + .toString() + .split(':'); + if (user === swaggerUser && pass === swaggerPassword) { + return next(); + } + return res.status(401).send('Invalid credentials'); + }); + } + } + + SwaggerModule.setup('api-docs', app, document, { + swaggerOptions: { persistAuthorization: true }, + customCss: '.swagger-ui .topbar { display: none }', + customSiteTitle: '知习 API 文档', + }); + + app.getHttpAdapter().get('/api-docs-json', (_req: any, res: any) => { + res.json(document); + }); + + console.log('[Swagger] API 文档已启用'); + } + + const port = configService.get('app.port', 3000); await app.listen(port); console.log(`[API] Server running on http://localhost:${port}`); - console.log(`[API] Swagger docs at http://localhost:${port}/api-docs`); } bootstrap(); diff --git a/src/modules/ai-analysis/ai-analysis.service.ts b/src/modules/ai-analysis/ai-analysis.service.ts index 334cd95..1b842f4 100644 --- a/src/modules/ai-analysis/ai-analysis.service.ts +++ b/src/modules/ai-analysis/ai-analysis.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common'; import { AiAnalysisRepository } from './ai-analysis.repository'; import { AiService } from '../../infrastructure/ai/ai.service'; import { RedisService } from '../../infrastructure/redis/redis.service'; @@ -23,7 +23,7 @@ export class AiAnalysisService { const lockKey = `lock:ai-analysis:session:${body.sessionId || 'unknown'}`; const lockToken = await this.redis.lock(lockKey, 300); if (!lockToken) { - throw new Error('同一学习会话的 AI 分析正在处理中,请稍候'); + throw new HttpException('同一学习会话的 AI 分析正在处理中,请稍候', HttpStatus.CONFLICT); } const job = await this.repository.createJob(userId, body); @@ -45,7 +45,10 @@ export class AiAnalysisService { await this.redis.expire(rateKey, 86400); } if (count > DAILY_AI_LIMIT) { - throw new Error(`每日 AI 调用次数已达上限(${DAILY_AI_LIMIT}次)`); + throw new HttpException( + `每日 AI 调用次数已达上限(${DAILY_AI_LIMIT}次)`, + HttpStatus.TOO_MANY_REQUESTS, + ); } } diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index b3ec622..c4b3e43 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,11 +1,32 @@ import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common'; +import { + Controller, + Post, + Body, + HttpCode, + HttpStatus, + Req, + BadRequestException, +} from '@nestjs/common'; import { AuthService } from './auth.service'; +import type { Request } from 'express'; +import { IsString, Allow, IsOptional } from 'class-validator'; class AppleLoginDto { + @IsString() identityToken: string; + + @IsString() authorizationCode: string; - user?: { name?: { firstName?: string; lastName?: string }; email?: string }; + + @Allow() + @IsOptional() + user?: any; +} + +class RefreshDto { + @IsString() + refreshToken: string; } @ApiTags('auth') @@ -15,25 +36,34 @@ export class AuthController { @Post('apple') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Apple 登录', description: '使用 Sign in with Apple 登录,返回访问令牌' }) + @ApiOperation({ summary: 'Apple 登录' }) @ApiResponse({ status: 200, description: '登录成功' }) + @ApiResponse({ status: 401, description: '身份验证失败' }) async appleLogin(@Body() body: AppleLoginDto) { return this.authService.appleLogin(body); } @Post('refresh') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: '刷新令牌', description: '使用刷新令牌获取新的访问令牌' }) - @ApiResponse({ status: 200, description: '令牌刷新成功' }) - async refresh(@Body('refreshToken') refreshToken: string) { - return this.authService.refresh(refreshToken); + @ApiOperation({ summary: '刷新令牌' }) + @ApiResponse({ status: 200, description: '刷新成功' }) + @ApiResponse({ status: 401, description: '刷新令牌无效' }) + async refresh(@Body() body: RefreshDto) { + if (!body.refreshToken) { + throw new BadRequestException('缺少 refreshToken'); + } + return this.authService.refresh(body.refreshToken); } @Post('logout') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: '用户退出', description: '使当前会话失效' }) + @ApiOperation({ summary: '退出登录' }) @ApiResponse({ status: 200, description: '退出成功' }) - async logout() { - return this.authService.logout(); + async logout(@Req() req: Request) { + const user = (req as any).user; + if (user?.id) { + await this.authService.logout(user.id); + } + return { success: true, message: '已退出登录' }; } } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 5804d9b..679a6f6 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -1,11 +1,10 @@ import { Module } from '@nestjs/common'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; -import { AuthRepository } from './auth.repository'; @Module({ controllers: [AuthController], - providers: [AuthService, AuthRepository], + providers: [AuthService], exports: [AuthService], }) export class AuthModule {} diff --git a/src/modules/auth/auth.repository.ts b/src/modules/auth/auth.repository.ts deleted file mode 100644 index 34ed053..0000000 --- a/src/modules/auth/auth.repository.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -export interface AuthUser { - id: number; - appleUserId: string; - email?: string; - displayName?: string; -} - -@Injectable() -export class AuthRepository { - private users: AuthUser[] = []; - private nextId = 1; - - async findByAppleUserId(appleUserId: string): Promise { - return this.users.find((u) => u.appleUserId === appleUserId); - } - - async createUser(data: Partial): Promise { - const user: AuthUser = { - id: data.id || this.nextId++, - appleUserId: data.appleUserId || '', - email: data.email, - displayName: data.displayName, - }; - this.users.push(user); - return user; - } -} diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 573bcef..14171f3 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,39 +1,158 @@ -import { Injectable } from '@nestjs/common'; -import { AuthRepository } from './auth.repository'; +import * as crypto from 'crypto'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; @Injectable() export class AuthService { - constructor(private readonly authRepository: AuthRepository) {} + constructor( + private readonly jwtService: JwtService, + private readonly prisma: PrismaService, + private readonly configService: ConfigService, + ) {} async appleLogin(params: { identityToken: string; authorizationCode: string; user?: { name?: { firstName?: string; lastName?: string }; email?: string }; }) { - const appleUserId = `apple_${params.identityToken.substring(0, 20)}`; - let user = await this.authRepository.findByAppleUserId(appleUserId); - if (!user) { + const appleUserId = await this.verifyAppleIdentity( + params.identityToken, + params.authorizationCode, + params.user?.email, + ); + + let account = await this.prisma.authAccount.findUnique({ + where: { provider_providerUserId: { provider: 'apple', providerUserId: appleUserId } }, + include: { user: true }, + }); + + if (!account) { const displayName = params.user?.name ? `${params.user.name.lastName || ''}${params.user.name.firstName || ''}` : undefined; - user = await this.authRepository.createUser({ - appleUserId, - email: params.user?.email, - displayName, + account = await this.prisma.authAccount.create({ + data: { + provider: 'apple', + providerUserId: appleUserId, + email: params.user?.email, + user: { + create: { + email: params.user?.email, + nickname: displayName || undefined, + status: 'active', + }, + }, + }, + include: { user: true }, }); } - const accessToken = `mock_token_${Date.now()}`; - const refreshToken = `mock_refresh_${Date.now()}`; + + const userIdStr = String(account.user.id); + + const accessToken = await this.jwtService.signAsync({ + sub: userIdStr, + email: account.user.email, + }); + + const refreshToken = crypto.randomBytes(48).toString('hex'); + const refreshTokenHash = crypto + .createHash('sha256') + .update(refreshToken) + .digest('hex'); + + await this.prisma.refreshToken.create({ + data: { + userId: account.user.id, + tokenHash: refreshTokenHash, + expiresAt: new Date(Date.now() + 7 * 86400000), + }, + }); + return { accessToken, refreshToken, expiresIn: 3600 }; } async refresh(refreshToken: string) { - const accessToken = `mock_token_${Date.now()}`; - return { accessToken, expiresIn: 3600 }; + const hash = crypto.createHash('sha256').update(refreshToken).digest('hex'); + const stored = await this.prisma.refreshToken.findFirst({ + where: { tokenHash: hash, revokedAt: null }, + include: { user: true }, + }); + + if (!stored || stored.expiresAt < new Date()) { + throw new UnauthorizedException('刷新令牌无效或已过期'); + } + + await this.prisma.refreshToken.update({ + where: { id: stored.id }, + data: { revokedAt: new Date() }, + }); + + const newRefreshToken = crypto.randomBytes(48).toString('hex'); + const newHash = crypto + .createHash('sha256') + .update(newRefreshToken) + .digest('hex'); + + await this.prisma.refreshToken.create({ + data: { + userId: stored.userId, + tokenHash: newHash, + expiresAt: new Date(Date.now() + 7 * 86400000), + }, + }); + + const accessToken = await this.jwtService.signAsync({ + sub: String(stored.user.id), + email: stored.user.email, + }); + + return { + accessToken, + refreshToken: newRefreshToken, + expiresIn: 3600, + }; } - async logout() { - return { success: true, message: '已退出登录' }; + async logout(userId: number) { + await this.prisma.refreshToken.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } + + private async verifyAppleIdentity( + identityToken: string, + authorizationCode: string, + email?: string | null, + ): Promise { + if (this.isMockMode()) { + return this.verifyMockApple(identityToken, email); + } + return this.verifyRealApple(identityToken, authorizationCode); + } + + private isMockMode(): boolean { + return this.configService.get('app.nodeEnv') !== 'production'; + } + + private verifyMockApple(identityToken: string, email?: string | null): string { + if (!identityToken || identityToken.trim().length < 4) { + throw new UnauthorizedException('identityToken 无效'); + } + return crypto + .createHash('sha256') + .update(`apple-mock:${identityToken}:${email || 'no-email'}`) + .digest('hex') + .slice(0, 64); + } + + private async verifyRealApple( + identityToken: string, + authorizationCode: string, + ): Promise { + throw new UnauthorizedException('Apple 登录尚未接入,请先配置 Apple Developer 凭证'); } } diff --git a/src/modules/feedback/dto/create-feedback.dto.ts b/src/modules/feedback/dto/create-feedback.dto.ts index 40d7fc4..4830176 100644 --- a/src/modules/feedback/dto/create-feedback.dto.ts +++ b/src/modules/feedback/dto/create-feedback.dto.ts @@ -1,15 +1,23 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsIn, IsOptional } from 'class-validator'; export class CreateFeedbackDto { @ApiPropertyOptional({ description: '用户 ID' }) + @IsOptional() + @IsString() userId?: string; @ApiProperty({ description: '反馈类型', enum: ['bug', 'feature', 'general'] }) + @IsString() + @IsIn(['bug', 'feature', 'general']) type: 'bug' | 'feature' | 'general'; @ApiProperty({ description: '反馈内容' }) + @IsString() content: string; @ApiPropertyOptional({ description: '联系方式' }) + @IsOptional() + @IsString() contact?: string; } diff --git a/src/modules/knowledge-base/knowledge-base.service.ts b/src/modules/knowledge-base/knowledge-base.service.ts index 7f89655..2ef21f6 100644 --- a/src/modules/knowledge-base/knowledge-base.service.ts +++ b/src/modules/knowledge-base/knowledge-base.service.ts @@ -20,7 +20,7 @@ export class KnowledgeBaseService { async findOne(userId: string, id: string) { const kb = await this.repository.findById(id); - if (!kb || kb.userId !== userId) { + if (!kb || String(kb.userId) !== userId) { throw new NotFoundException('知识库不存在'); } return kb; @@ -28,7 +28,7 @@ export class KnowledgeBaseService { async update(userId: string, id: string, dto: any) { const kb = await this.repository.findById(id); - if (!kb || kb.userId !== userId) { + if (!kb || String(kb.userId) !== userId) { throw new NotFoundException('知识库不存在'); } return this.repository.update(id, dto); @@ -36,7 +36,7 @@ export class KnowledgeBaseService { async remove(userId: string, id: string) { const kb = await this.repository.findById(id); - if (!kb || kb.userId !== userId) { + if (!kb || String(kb.userId) !== userId) { throw new NotFoundException('知识库不存在'); } return this.repository.softDelete(id); diff --git a/src/modules/system/system.controller.ts b/src/modules/system/system.controller.ts index aff2030..ac8c280 100644 --- a/src/modules/system/system.controller.ts +++ b/src/modules/system/system.controller.ts @@ -1,7 +1,8 @@ -import { Controller, Get } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { RedisService } from '../../infrastructure/redis/redis.service'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; @ApiTags('health') @Controller() @@ -37,4 +38,14 @@ export class SystemController { } catch {} return { status: 'ok', database: dbStatus, redis: redisStatus }; } + + @Get('secure-test') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: '认证测试端点' }) + @ApiResponse({ status: 200, description: '已认证' }) + @ApiResponse({ status: 401, description: '未认证' }) + secureTest() { + return { message: '你已通过认证' }; + } } diff --git a/src/modules/system/system.module.ts b/src/modules/system/system.module.ts index 1ba4501..67d5152 100644 --- a/src/modules/system/system.module.ts +++ b/src/modules/system/system.module.ts @@ -1,7 +1,10 @@ import { Module } from '@nestjs/common'; import { SystemController } from './system.controller'; +import { PrismaModule } from '../../infrastructure/database/prisma.module'; +import { RedisModule } from '../../infrastructure/redis/redis.module'; @Module({ + imports: [PrismaModule, RedisModule], controllers: [SystemController], }) export class SystemModule {} diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 6887e4f..fd03ae2 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -1,32 +1,33 @@ -import { Controller, Get, Patch, Body } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { UsersService } from './users.service'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import type { UserPayload } from '../../common/types'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; @ApiTags('users') @Controller('users') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() export class UsersController { constructor(private readonly usersService: UsersService) {} @Get('me') - @ApiOperation({ summary: '获取当前用户信息', description: '获取当前登录用户的资料' }) + @ApiOperation({ summary: '获取当前用户信息' }) @ApiResponse({ status: 200, description: '用户信息' }) - async getProfile(@CurrentUser() user: UserPayload | undefined) { - return this.usersService.getProfile(String(user?.id || 'anonymous')); + async getProfile(@CurrentUser() user: UserPayload) { + return this.usersService.getProfile(String(user.id)); } @Patch('me') - @ApiOperation({ summary: '更新用户资料', description: '更新当前用户的资料信息' }) - @ApiResponse({ status: 200, description: '更新成功' }) - async updateProfile(@CurrentUser() user: UserPayload | undefined, @Body() body: any) { - return this.usersService.updateProfile(String(user?.id || 'anonymous'), body); + @ApiOperation({ summary: '更新用户资料' }) + async updateProfile(@CurrentUser() user: UserPayload, @Body() body: any) { + return this.usersService.updateProfile(String(user.id), body); } @Patch('me/preferences') - @ApiOperation({ summary: '更新用户偏好', description: '更新当前用户的学习偏好设置' }) - @ApiResponse({ status: 200, description: '更新成功' }) - async updatePreferences(@CurrentUser() user: UserPayload | undefined, @Body() body: any) { - return this.usersService.updatePreferences(String(user?.id || 'anonymous'), body); + @ApiOperation({ summary: '更新用户偏好' }) + async updatePreferences(@CurrentUser() user: UserPayload, @Body() body: any) { + return this.usersService.updatePreferences(String(user.id), body); } } diff --git a/test-crud.ts b/test-crud.ts deleted file mode 100644 index 53ce708..0000000 --- a/test-crud.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { PrismaClient } from '@prisma/client'; - -const prisma = new PrismaClient(); - -async function main() { - console.log('=== MySQL CRUD 测试 ===\n'); - - const user = await prisma.user.create({ - data: { - email: 'test-crud@zhixi.com', - nickname: 'CRUD测试用户', - status: 'active', - }, - }); - console.log(`✅ INSERT user: id=${user.id}, email=${user.email}, nickname=${user.nickname}`); - - const profile = await prisma.userProfile.create({ - data: { - userId: user.id, - learningIdentity: '知识工作者', - learningDirection: '产品设计', - }, - }); - console.log(`✅ INSERT profile: userId=${profile.userId}, direction=${profile.learningDirection}`); - - const kb = await prisma.knowledgeBase.create({ - data: { userId: user.id, title: '测试知识库', description: 'CRUD测试' }, - }); - console.log(`✅ INSERT knowledgeBase: id=${kb.id}, title=${kb.title}`); - - const item = await prisma.knowledgeItem.create({ - data: { - userId: user.id, - knowledgeBaseId: kb.id, - itemType: 'lesson', - title: '测试知识点-数据结构入门', - content: '## 数组与链表\n数组是连续内存...', - }, - }); - console.log(`✅ INSERT knowledgeItem: id=${item.id}, title=${item.title}`); - - const foundKB = await prisma.knowledgeBase.findUnique({ where: { id: kb.id } }); - console.log(`✅ SELECT knowledgeBase: ${foundKB?.title} (status=${foundKB?.status})`); - - const items = await prisma.knowledgeItem.findMany({ where: { knowledgeBaseId: kb.id } }); - console.log(`✅ SELECT knowledgeItems: ${items.length} 条`); - - const session = await prisma.learningSession.create({ - data: { - userId: user.id, - knowledgeBaseId: kb.id, - knowledgeItemId: item.id, - mode: 'reading', - status: 'active', - startedAt: new Date(), - }, - }); - console.log(`✅ INSERT learningSession: id=${session.id}, mode=${session.mode}`); - - const updated = await prisma.learningSession.update({ - where: { id: session.id }, - data: { status: 'completed', endedAt: new Date(), durationSeconds: 300 }, - }); - console.log(`✅ UPDATE learningSession: status=${updated.status}, duration=${updated.durationSeconds}s`); - - const job = await prisma.aiAnalysisJob.create({ - data: { - userId: user.id, - sessionId: session.id, - jobType: 'active_recall_analysis', - status: 'pending', - }, - }); - console.log(`✅ INSERT aiAnalysisJob: id=${job.id}, type=${job.jobType}`); - - await prisma.aiAnalysisJob.update({ - where: { id: job.id }, - data: { status: 'success', completedAt: new Date(), progress: 100 }, - }); - console.log(`✅ UPDATE aiAnalysisJob: success`); - - await prisma.aiAnalysisResult.create({ - data: { - userId: user.id, - jobId: job.id, - summary: '用户掌握了数组的基本概念', - masteryScore: 85, - strengths: JSON.stringify(['理解清晰', '举例恰当']), - weaknesses: JSON.stringify(['链表概念有混淆']), - }, - }); - console.log(`✅ INSERT aiAnalysisResult: score=85`); - - const focus = await prisma.focusItem.create({ - data: { - userId: user.id, - knowledgeItemId: item.id, - title: '链表与数组的区别需要巩固', - priority: 'high', - status: 'open', - }, - }); - console.log(`✅ INSERT focusItem: id=${focus.id}, title=${focus.title}`); - - await prisma.focusItem.update({ - where: { id: focus.id }, - data: { status: 'completed', completedAt: new Date() }, - }); - console.log(`✅ UPDATE focusItem: completed`); - - const card = await prisma.reviewCard.create({ - data: { - userId: user.id, - knowledgeItemId: item.id, - frontText: '数组和链表的主要区别是什么?', - backText: '数组是连续内存分配...', - nextReviewAt: new Date(Date.now() + 86400000), - }, - }); - console.log(`✅ INSERT reviewCard: id=${card.id}`); - - await prisma.reviewLog.create({ - data: { - userId: user.id, - reviewCardId: card.id, - rating: 'good', - reviewedAt: new Date(), - }, - }); - console.log(`✅ INSERT reviewLog: rating=good`); - - await prisma.feedback.create({ - data: { userId: user.id, category: 'feature', content: '希望支持Markdown导入', status: 'open' }, - }); - console.log(`✅ INSERT feedback`); - - await prisma.notification.create({ - data: { userId: user.id, type: 'ai_analysis_done', title: 'AI分析完成', content: '你的学习分析已生成' }, - }); - console.log(`✅ INSERT notification`); - - await prisma.dailyLearningActivity.create({ - data: { - userId: user.id, - activityDate: new Date(), - durationSeconds: 300, - sessionsCount: 1, - activityLevel: 2, - }, - }); - console.log(`✅ INSERT dailyLearningActivity`); - - // 关联查询 - KnowledgeBase + Items - const kbWithItems = await prisma.knowledgeBase.findUnique({ - where: { id: kb.id }, - include: { items: true }, - }); - console.log(`✅ RELATION: knowledgeBase '${kbWithItems?.title}' 包含 ${kbWithItems?.items.length} 个知识点`); - - // ----- 清理测试数据 ----- - await prisma.reviewLog.deleteMany({ where: { userId: user.id } }); - await prisma.reviewCard.deleteMany({ where: { userId: user.id } }); - await prisma.focusItem.deleteMany({ where: { userId: user.id } }); - await prisma.aiAnalysisResult.deleteMany({ where: { userId: user.id } }); - await prisma.aiAnalysisJob.deleteMany({ where: { userId: user.id } }); - await prisma.learningSession.deleteMany({ where: { userId: user.id } }); - await prisma.knowledgeItem.deleteMany({ where: { userId: user.id } }); - await prisma.knowledgeBase.deleteMany({ where: { userId: user.id } }); - await prisma.userProfile.deleteMany({ where: { userId: user.id } }); - await prisma.feedback.deleteMany({ where: { userId: user.id } }); - await prisma.notification.deleteMany({ where: { userId: user.id } }); - await prisma.dailyLearningActivity.deleteMany({ where: { userId: user.id } }); - await prisma.user.delete({ where: { id: user.id } }); - - console.log('\n✅ DELETE 清理完成 - 无残留数据'); - console.log('=== MySQL CRUD 全部通过 ==='); -} - -main() - .catch((e) => { - console.error('❌ FAILED:', e.message); - process.exit(1); - }) - .finally(() => prisma.$disconnect());