代码评审 & 安全修复

后端:
- 创建 AdminGuard 替代 12 处手动 role 查库检查,统一用 JWT payload 中的 role
- 密码字段 select: false,所有需密码的查询显式 select('+password')
- 文件上传接口移除 @Public(),需 JWT 认证
- 管理员搜索关键词限长 50 字符防 ReDoS
- CORS 收窄,不再对非生产环境放行所有源
- postbuild 复制 certs 路径同步到 dist/src/certs
- package.json main/start:prod 路径更新为 dist/src/main

前端:
- resume.vue 文件上传补充 Authorization header
- login.vue 移除含用户邮箱的 console.log 日志
This commit is contained in:
yuzhiran
2026-06-11 19:55:10 +08:00
parent f7da843d56
commit 6dfb6bef48
14 changed files with 61 additions and 104 deletions
+20 -66
View File
@@ -2,6 +2,7 @@ import { Controller, Get, Post, Body, Query, HttpException, HttpStatus, UseGuard
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
import { AdminGuard } from '../../common/guards/admin.guard'
import { CurrentUser } from '../../common/decorators/current-user.decorator'
import { User, UserDocument } from '../user/user.schema'
import { Interview, InterviewDocument } from '../interview/interview.schema'
@@ -11,6 +12,7 @@ import { WechatPayService } from '../payment/wechat-pay.service'
const VIP_DURATION_DAYS = 30
@UseGuards(JwtAuthGuard, AdminGuard)
@Controller('admin')
export class AdminController {
constructor(
@@ -22,27 +24,18 @@ export class AdminController {
) {}
@Get('check')
@UseGuards(JwtAuthGuard)
async checkAdmin(@CurrentUser('userId') userId: string) {
const user = await this.userModel.findById(userId).select('role').exec()
return { isAdmin: user?.role === 'admin' }
async checkAdmin(@CurrentUser('role') role: string) {
return { isAdmin: role === 'admin' }
}
@UseGuards(JwtAuthGuard)
@Post('verify')
async verify(@CurrentUser('userId') userId: string) {
const user = await this.userModel.findById(userId).exec()
if (!user || user.role !== 'admin') {
throw new HttpException('无权限访问', HttpStatus.FORBIDDEN)
}
return { ok: true, nickname: user.nickname || '管理员' }
const user = await this.userModel.findById(userId).select('nickname').exec()
return { ok: true, nickname: user?.nickname || '管理员' }
}
@UseGuards(JwtAuthGuard)
@Get('overview')
async overview(@CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
async overview() {
const [userCount, interviewCount, todayUsers, todayInterviews] = await Promise.all([
this.userModel.countDocuments().exec(),
this.interviewModel.countDocuments().exec(),
@@ -52,13 +45,11 @@ export class AdminController {
return { userCount, interviewCount, todayUsers, todayInterviews }
}
@UseGuards(JwtAuthGuard)
@Get('users')
async getUsers(@Query('keyword') keyword: string, @Query('page') page = '1', @Query('limit') limit = '20', @CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
async getUsers(@Query('keyword') keyword: string, @Query('page') page = '1', @Query('limit') limit = '20') {
const filter: any = {}
if (keyword) {
if (keyword.length > 50) throw new HttpException('关键词过长', HttpStatus.BAD_REQUEST)
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
filter.$or = [
{ phone: { $regex: escaped, $options: 'i' } },
@@ -67,17 +58,14 @@ export class AdminController {
}
const skip = (Math.max(1, +page) - 1) * +limit
const [users, total] = await Promise.all([
this.userModel.find(filter).sort({ createdAt: -1 }).skip(skip).limit(+limit).select('-password').lean().exec(),
this.userModel.find(filter).sort({ createdAt: -1 }).skip(skip).limit(+limit).lean().exec(),
this.userModel.countDocuments(filter).exec(),
])
return { users, total, page: +page }
}
@UseGuards(JwtAuthGuard)
@Get('interviews')
async getInterviews(@Query('page') page = '1', @Query('limit') limit = '20', @CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
async getInterviews(@Query('page') page = '1', @Query('limit') limit = '20') {
const skip = (Math.max(1, +page) - 1) * +limit
const [interviews, total] = await Promise.all([
this.interviewModel.find().sort({ createdAt: -1 }).skip(skip).limit(+limit).populate('userId', 'phone nickname').lean().exec(),
@@ -86,11 +74,8 @@ export class AdminController {
return { interviews, total, page: +page }
}
@UseGuards(JwtAuthGuard)
@Post('set-vip')
async setVip(@Body('userId') targetUserId: string, @CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
async setVip(@Body('userId') targetUserId: string) {
const user = await this.userModel.findById(targetUserId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
const expireAt = new Date()
@@ -102,20 +87,14 @@ export class AdminController {
return { success: true, plan: 'growth', expireAt }
}
@UseGuards(JwtAuthGuard)
@Get('admins')
async getAdmins(@CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
async getAdmins() {
const admins = await this.userModel.find({ role: 'admin' }).select('phone nickname email createdAt isSystemAdmin').lean().exec()
return { admins }
}
@UseGuards(JwtAuthGuard)
@Post('set-admin')
async setAdmin(@Body('userId') targetUserId: string, @CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
async setAdmin(@Body('userId') targetUserId: string) {
const user = await this.userModel.findById(targetUserId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
if (user.role === 'admin') throw new HttpException('该用户已是管理员', HttpStatus.BAD_REQUEST)
@@ -124,13 +103,8 @@ export class AdminController {
return { success: true, message: '已设为管理员' }
}
// ─── 订单管理 ──────────────
@UseGuards(JwtAuthGuard)
@Get('orders')
async getOrders(@Query('page') page = '1', @Query('limit') limit = '20', @Query('status') status: string, @CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
async getOrders(@Query('page') page = '1', @Query('limit') limit = '20', @Query('status') status: string) {
const filter: any = {}
if (status) filter.status = status
const skip = (Math.max(1, +page) - 1) * +limit
@@ -141,11 +115,8 @@ export class AdminController {
return { orders, total, page: +page }
}
@UseGuards(JwtAuthGuard)
@Post('order/sync')
async syncOrder(@Body('outTradeNo') outTradeNo: string, @CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
async syncOrder(@Body('outTradeNo') outTradeNo: string) {
const wxResult = await this.wechatPay.queryOrder(outTradeNo)
const tradeState = wxResult?.trade_state
const order = await this.orderModel.findOne({ outTradeNo }).exec()
@@ -168,24 +139,15 @@ export class AdminController {
return { order, wxResult }
}
// ─── 系统配置(支持管理后台编辑) ──────
@UseGuards(JwtAuthGuard)
@Get('config')
async getConfig(@CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
// 从数据库读,不存在则返回默认值
async getConfig() {
const cfg = await this.configModel.findOne({ key: 'site_config' }).exec()
if (cfg) return cfg.value
return DEFAULT_CONFIG
}
@UseGuards(JwtAuthGuard)
@Post('config/save')
async saveConfig(@Body() body: any, @CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
async saveConfig(@Body() body: any) {
await this.configModel.findOneAndUpdate(
{ key: 'site_config' },
{ key: 'site_config', value: body, description: '站点配置' },
@@ -194,22 +156,14 @@ export class AdminController {
return { success: true }
}
// ─── 每日一题管理 ──────────────
@UseGuards(JwtAuthGuard)
@Get('questions')
async getQuestions(@CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
async getQuestions() {
const cfg = await this.configModel.findOne({ key: 'daily_questions' }).exec()
return cfg?.value || DEFAULT_QUESTIONS
}
@UseGuards(JwtAuthGuard)
@Post('questions/save')
async saveQuestions(@Body() body: any, @CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
async saveQuestions(@Body() body: any) {
await this.configModel.findOneAndUpdate(
{ key: 'daily_questions' },
{ key: 'daily_questions', value: body, description: '每日一题题库' },
+2 -2
View File
@@ -5,7 +5,7 @@ import { User, UserSchema } from '../user/user.schema'
import { Interview, InterviewSchema } from '../interview/interview.schema'
import { PaymentOrder, PaymentOrderSchema } from '../payment/payment-order.schema'
import { WechatPayService } from '../payment/wechat-pay.service'
import { AdminGuard } from '../../common/guards/admin.guard'
import { SiteConfig, SiteConfigSchema } from '../schemas/site-config.schema'
@Module({
@@ -18,6 +18,6 @@ import { SiteConfig, SiteConfigSchema } from '../schemas/site-config.schema'
]),
],
controllers: [AdminController],
providers: [WechatPayService],
providers: [WechatPayService, AdminGuard],
})
export class AdminModule {}
@@ -2,14 +2,12 @@ import { Controller, Post, UseInterceptors, UploadedFile, HttpException, HttpSta
import { FileInterceptor } from '@nestjs/platform-express'
import * as mammoth from 'mammoth'
import { memoryStorage } from 'multer'
import { Public } from '../../common/decorators/public.decorator'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pdfParse = require('pdf-parse')
@Controller('upload')
export class UploadController {
@Public()
@Post()
@UseInterceptors(FileInterceptor('file', {
storage: memoryStorage(),
+1 -1
View File
@@ -44,7 +44,7 @@ export class User {
@Prop({ sparse: true })
email?: string
@Prop({ default: '' })
@Prop({ default: '', select: false })
password?: string
}
@@ -26,12 +26,12 @@ describe('UserService', () => {
}
beforeEach(async () => {
const chainable = (value: any) => ({ exec: jest.fn().mockResolvedValue(value), select: jest.fn().mockReturnThis() })
mockUserModel = {
findOne: jest.fn().mockReturnThis(),
findById: jest.fn().mockReturnThis(),
findByIdAndUpdate: jest.fn().mockReturnThis(),
findOne: jest.fn().mockReturnValue(chainable(null)),
findById: jest.fn().mockReturnValue(chainable(null)),
findByIdAndUpdate: jest.fn().mockReturnValue(chainable(null)),
create: jest.fn().mockResolvedValue(mockUser),
exec: jest.fn().mockResolvedValue(null),
}
mockJwtService = {
sign: jest.fn().mockReturnValue('mock-jwt-token'),
@@ -70,7 +70,7 @@ describe('UserService', () => {
})
it('should create user on first login and return token', async () => {
mockUserModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null) })
mockUserModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null), select: jest.fn().mockReturnThis() })
await service.sendCode('13800138000')
const result = await service.loginByPhone('13800138000', '123456')
expect(result).toHaveProperty('token', 'mock-jwt-token')
@@ -79,7 +79,7 @@ describe('UserService', () => {
})
it('should login existing user', async () => {
mockUserModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser) })
mockUserModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser), select: jest.fn().mockReturnThis() })
await service.sendCode('13800138000')
const result = await service.loginByPhone('13800138000', '123456')
expect(result).toHaveProperty('token')
@@ -105,7 +105,7 @@ describe('UserService', () => {
})
it('should login with valid email code', async () => {
mockUserModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser) })
mockUserModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser), select: jest.fn().mockReturnThis() })
const spy = jest.spyOn(mockEmailService, 'sendVerificationCode')
await service.sendEmailCode('test@example.com')
@@ -119,7 +119,7 @@ describe('UserService', () => {
describe('loginByPassword', () => {
it('should throw for nonexistent user', async () => {
mockUserModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null) })
mockUserModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null), select: jest.fn().mockReturnThis() })
await expect(service.loginByPassword('test@example.com', 'pass'))
.rejects.toThrow(HttpException)
})
+4 -4
View File
@@ -108,7 +108,7 @@ export class UserService {
emailCodeStore.delete(email)
// 按邮箱查找或创建用户
let user = await this.userModel.findOne({ email }).exec()
let user = await this.userModel.findOne({ email }).select('+password').exec()
let isNew = false
if (!user) {
isNew = true
@@ -120,7 +120,7 @@ export class UserService {
// 🔐 密码登录
async loginByPassword(email: string, password: string) {
const user = await this.userModel.findOne({ email }).exec()
const user = await this.userModel.findOne({ email }).select('+password').exec()
if (!user) throw new HttpException('账号不存在', HttpStatus.NOT_FOUND)
if (!user.password) throw new HttpException('该账号未设置密码,请使用验证码登录', HttpStatus.UNAUTHORIZED)
const match = await bcrypt.compare(password, user.password)
@@ -136,7 +136,7 @@ export class UserService {
if (!password || password.length < 6) {
throw new HttpException('密码至少6位', HttpStatus.BAD_REQUEST)
}
const existing = await this.userModel.findOne({ email }).exec()
const existing = await this.userModel.findOne({ email }).select('+password').exec()
if (existing) {
if (existing.password) {
throw new HttpException('该邮箱已注册,请直接登录', HttpStatus.CONFLICT)
@@ -192,7 +192,7 @@ export class UserService {
}
private generateAuthResponse(user: UserDocument) {
const payload = { userId: user._id.toString(), phone: user.phone || '' }
const payload = { userId: user._id.toString(), phone: user.phone || '', role: user.role || 'user' }
return {
token: this.jwtService.sign(payload),
user: this.safeUser(user),