代码评审 & 安全修复
后端:
- 创建 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:
@@ -0,0 +1,16 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, HttpException, HttpStatus } from '@nestjs/common'
|
||||
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest()
|
||||
const user = request.user
|
||||
if (!user) {
|
||||
throw new HttpException('未登录', HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
if (user.role !== 'admin') {
|
||||
throw new HttpException('无权限访问', HttpStatus.FORBIDDEN)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
})
|
||||
}
|
||||
|
||||
async validate(payload: { userId: string; phone: string }) {
|
||||
return { userId: payload.userId, phone: payload.phone }
|
||||
async validate(payload: { userId: string; phone: string; role?: string }) {
|
||||
return { userId: payload.userId, phone: payload.phone, role: payload.role || 'user' }
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -42,10 +42,10 @@ async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule)
|
||||
|
||||
app.setGlobalPrefix('api')
|
||||
const allowedOrigins = process.env.CORS_ORIGINS?.split(',') || ['http://localhost:8085', 'http://localhost:3006']
|
||||
const allowedOrigins = process.env.CORS_ORIGINS?.split(',') || ['http://localhost:8085', 'http://localhost:3006', 'https://zhiyin.yzrcloud.cn']
|
||||
app.enableCors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin || allowedOrigins.includes(origin) || process.env.NODE_ENV !== 'production') {
|
||||
if (!origin || allowedOrigins.includes(origin)) {
|
||||
callback(null, true)
|
||||
} else {
|
||||
callback(new Error('Not allowed by CORS'))
|
||||
|
||||
@@ -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: '每日一题题库' },
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user