diff --git a/server/src/enums/exceptionCode.ts b/server/src/enums/exceptionCode.ts index 30d943ba..85cffaa7 100644 --- a/server/src/enums/exceptionCode.ts +++ b/server/src/enums/exceptionCode.ts @@ -12,6 +12,7 @@ export enum EXCEPTION_CODE { SURVEY_NOT_FOUND = 3004, // 问卷不存在 SURVEY_CONTENT_NOT_ALLOW = 3005, // 存在禁用内容 CAPTCHA_INCORRECT = 4001, // 验证码不正确 + WHITELIST_ERROR = 4002, // 白名单校验错误 RESPONSE_SIGN_ERROR = 9001, // 签名不正确 RESPONSE_CURRENT_TIME_NOT_ALLOW = 9002, // 当前时间不允许提交 diff --git a/server/src/interfaces/survey.ts b/server/src/interfaces/survey.ts index 30403597..1e68afcc 100644 --- a/server/src/interfaces/survey.ts +++ b/server/src/interfaces/survey.ts @@ -94,6 +94,23 @@ export interface SubmitConf { msgContent: MsgContent; } +// 白名单类型 +export enum WhitelistType { + ALL = 'ALL', + // 空间成员 + MEMBER = 'MEMBER', + // 自定义 + CUSTOM = 'CUSTOM', +} + +// 白名单用户类型 +export enum MemberType { + // 手机号 + MOBILE = 'MOBILE', + // 邮箱 + EMAIL = 'EMAIL', +} + export interface BaseConf { begTime: string; endTime: string; @@ -101,6 +118,18 @@ export interface BaseConf { answerEndTime: string; tLimit: number; language: string; + // 访问密码开关 + passwordSwitch?: boolean; + // 密码 + password?: string | null; + // 白名单类型 + whitelistType?: WhitelistType; + // 白名单用户类型 + memberType?: MemberType; + // 白名单列表 + whitelist?: string[]; + // 提示语 + whitelistTip?: string; } export interface SkinConf { diff --git a/server/src/models/whitelistVerify.entity.ts b/server/src/models/whitelistVerify.entity.ts new file mode 100644 index 00000000..9005ea7a --- /dev/null +++ b/server/src/models/whitelistVerify.entity.ts @@ -0,0 +1,8 @@ +import { Entity, Column } from 'typeorm'; +import { BaseEntity } from './base.entity'; + +@Entity({ name: 'whitelistVerify' }) +export class WhitelistVerify extends BaseEntity { + @Column() + surveyPath: string; +} diff --git a/server/src/modules/auth/auth.module.ts b/server/src/modules/auth/auth.module.ts index 07d983b2..abdaf609 100644 --- a/server/src/modules/auth/auth.module.ts +++ b/server/src/modules/auth/auth.module.ts @@ -11,11 +11,16 @@ import { Captcha } from 'src/models/captcha.entity'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule } from '@nestjs/config'; +import { WhitelistService } from './services/whitelist.service'; +import { WhitelistVerify } from 'src/models/whitelistVerify.entity'; @Module({ - imports: [TypeOrmModule.forFeature([User, Captcha]), ConfigModule], + imports: [ + TypeOrmModule.forFeature([User, Captcha, WhitelistVerify]), + ConfigModule, + ], controllers: [AuthController, UserController], - providers: [UserService, AuthService, CaptchaService], - exports: [UserService, AuthService], + providers: [UserService, AuthService, CaptchaService, WhitelistService], + exports: [UserService, AuthService, WhitelistService], }) export class AuthModule {} diff --git a/server/src/modules/auth/services/whitelist.service.ts b/server/src/modules/auth/services/whitelist.service.ts new file mode 100644 index 00000000..e534e36d --- /dev/null +++ b/server/src/modules/auth/services/whitelist.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MongoRepository } from 'typeorm'; +import { WhitelistVerify } from 'src/models/whitelistVerify.entity'; + +@Injectable() +export class WhitelistService { + constructor( + @InjectRepository(WhitelistVerify) + private readonly whitelistVerifyRepo: MongoRepository, + ) {} + + // 创建 + async create(surveyPath: string) { + const data = this.whitelistVerifyRepo.create({ + surveyPath, + }); + return await this.whitelistVerifyRepo.save(data); + } + + // 匹配 + async match(surveyPath: string, verifyId: string) { + return await this.whitelistVerifyRepo.findOne({ + where: { + surveyPath, + _id: verifyId, + }, + }); + } +} diff --git a/server/src/modules/surveyResponse/__test/responseSchema.controller.spec.ts b/server/src/modules/surveyResponse/__test/responseSchema.controller.spec.ts index bb1395e9..d74c43eb 100644 --- a/server/src/modules/surveyResponse/__test/responseSchema.controller.spec.ts +++ b/server/src/modules/surveyResponse/__test/responseSchema.controller.spec.ts @@ -6,23 +6,72 @@ import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { RECORD_STATUS } from 'src/enums'; import { ResponseSchema } from 'src/models/responseSchema.entity'; +import { Logger } from 'src/logger'; +import { UserService } from 'src/modules/auth/services/user.service'; +import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service'; +import { WhitelistService } from 'src/modules/auth/services/whitelist.service'; +import { AuthService } from 'src/modules/auth/services/auth.service'; +import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException'; +import { ObjectId } from 'mongodb'; +import { WhitelistVerify } from 'src/models/whitelistVerify.entity'; jest.mock('../services/responseScheme.service'); describe('ResponseSchemaController', () => { let controller: ResponseSchemaController; let responseSchemaService: ResponseSchemaService; + let whitelistService: WhitelistService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [ResponseSchemaController], - providers: [ResponseSchemaService], + providers: [ + ResponseSchemaService, + AuthService, + { + provide: Logger, + useValue: { + info: jest.fn(), + }, + }, + { + provide: UserService, + useValue: { + getUserByUsername: jest.fn(), + }, + }, + { + provide: WorkspaceMemberService, + useValue: { + findAllByUserId: jest.fn(), + }, + }, + { + provide: WhitelistService, + useValue: { + create: jest.fn(), + }, + }, + { + provide: AuthService, + useValue: { + create: jest.fn(), + }, + }, + { + provide: Logger, + useValue: { + error: jest.fn(), + }, + }, + ], }).compile(); controller = module.get(ResponseSchemaController); responseSchemaService = module.get( ResponseSchemaService, ); + whitelistService = module.get(WhitelistService); }); describe('getSchema', () => { @@ -66,5 +115,157 @@ describe('ResponseSchemaController', () => { new HttpException('问卷已删除', EXCEPTION_CODE.RESPONSE_SCHEMA_REMOVED), ); }); + + it('whitelistValidate should throw SurveyNotFoundException when survey is removed', async () => { + const surveyPath = ''; + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPath') + .mockResolvedValue(null); + await expect( + controller.whitelistValidate(surveyPath, { + password: '123456', + }), + ).rejects.toThrow(new SurveyNotFoundException('该问卷不存在,无法提交')); + }); + + it('whitelistValidate should throw WHITELIST_ERROR code when password is incorrect', async () => { + const surveyPath = ''; + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPath') + .mockResolvedValue({ + curStatus: { + status: 'published', + }, + code: { + baseConf: { + passwordSwitch: true, + password: '123456', + }, + }, + } as ResponseSchema); + await expect( + controller.whitelistValidate(surveyPath, { + password: '123457', + }), + ).rejects.toThrow( + new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR), + ); + }); + + it('whitelistValidate should return verifyId successfully', async () => { + const surveyPath = 'test'; + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPath') + .mockResolvedValue({ + curStatus: { + status: 'published', + }, + code: { + baseConf: { + passwordSwitch: true, + password: '123456', + }, + }, + } as ResponseSchema); + + const id = 'c79c6fee22cbed6f0b087a27'; + jest.spyOn(whitelistService, 'create').mockResolvedValue({ + _id: new ObjectId(id), + surveyPath, + } as WhitelistVerify); + await expect( + controller.whitelistValidate(surveyPath, { + password: '123456', + }), + ).resolves.toBe(id); + }); + + it('whitelistValidate should throw WHITELIST_ERROR code when mobile or email is incorrect', async () => { + const surveyPath = ''; + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPath') + .mockResolvedValue({ + curStatus: { + status: 'published', + }, + code: { + baseConf: { + passwordSwitch: true, + password: '123456', + whitelistType: 'CUSTOM', + memberType: 'MOBILE', + whitelist: ['13500000000'], + }, + }, + } as ResponseSchema); + await expect( + controller.whitelistValidate(surveyPath, { + password: '123456', + value: '13500000001', + }), + ).rejects.toThrow( + new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR), + ); + }); + + it('whitelistValidate should throw WHITELIST_ERROR code when member is incorrect', async () => { + const surveyPath = ''; + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPath') + .mockResolvedValue({ + curStatus: { + status: 'published', + }, + code: { + baseConf: { + passwordSwitch: true, + password: '123456', + whitelistType: 'MEMBER', + whitelist: ['Jack'], + }, + }, + } as ResponseSchema); + await expect( + controller.whitelistValidate(surveyPath, { + password: '123456', + value: 'James', + }), + ).rejects.toThrow( + new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR), + ); + }); + }); + + it('whitelistValidate should return verifyId successfully', async () => { + const surveyPath = ''; + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPath') + .mockResolvedValue({ + curStatus: { + status: 'published', + }, + code: { + baseConf: { + passwordSwitch: true, + password: '123456', + whitelistType: 'CUSTOM', + memberType: 'MOBILE', + whitelist: ['13500000000'], + }, + }, + } as ResponseSchema); + + const id = 'c79c6fee22cbed6f0b087a27'; + jest.spyOn(whitelistService, 'create').mockResolvedValue({ + _id: new ObjectId(id), + surveyPath, + } as WhitelistVerify); + + await expect( + controller.whitelistValidate(surveyPath, { + password: '123456', + value: '13500000000', + }), + ).resolves.toBe(id); }); }); diff --git a/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts b/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts index 5cb5a950..a6894a36 100644 --- a/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts +++ b/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts @@ -21,6 +21,11 @@ import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugi import { RECORD_STATUS } from 'src/enums'; import { SurveyResponse } from 'src/models/surveyResponse.entity'; import { Logger } from 'src/logger'; +import { WhitelistService } from 'src/modules/auth/services/whitelist.service'; +import { MemberType, WhitelistType } from 'src/interfaces/survey'; +import { ResponseSchema } from 'src/models/responseSchema.entity'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { WhitelistVerify } from 'src/models/whitelistVerify.entity'; const mockDecryptErrorBody = { surveyPath: 'EBzdmnSp', @@ -76,6 +81,7 @@ describe('SurveyResponseController', () => { let responseSchemaService: ResponseSchemaService; let surveyResponseService: SurveyResponseService; let clientEncryptService: ClientEncryptService; + let whitelistService: WhitelistService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -124,6 +130,12 @@ describe('SurveyResponseController', () => { info: jest.fn(), }, }, + { + provide: WhitelistService, + useValue: { + match: jest.fn(), + }, + }, ], }).compile(); @@ -136,6 +148,7 @@ describe('SurveyResponseController', () => { ); clientEncryptService = module.get(ClientEncryptService); + whitelistService = module.get(WhitelistService); const pluginManager = module.get( XiaojuSurveyPluginManager, @@ -306,5 +319,61 @@ describe('SurveyResponseController', () => { HttpException, ); }); + + it('should throw HttpException if verifyId is empty', async () => { + const reqBody = { ...mockSubmitData }; + + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPath') + .mockResolvedValueOnce({ + curStatus: { + status: RECORD_STATUS.PUBLISHED, + }, + code: { + baseConf: { + passwordSwitch: true, + password: '123456', + whitelistType: WhitelistType.CUSTOM, + memberType: MemberType.MOBILE, + whitelist: ['13500000000'], + }, + }, + } as ResponseSchema); + + await expect(controller.createResponse(reqBody, {})).rejects.toThrow( + new HttpException('白名单验证失败', EXCEPTION_CODE.WHITELIST_ERROR), + ); + }); + + it('should throw HttpException if verifyId does not match', async () => { + const reqBody = { + ...mockSubmitData, + verifyId: 'xxx', + sign: '6e9fda60c7fd9466eda480e3c5a03c2de0e33becc193b82f6aa6d25ff3b69146.1710400229589', + }; + + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPath') + .mockResolvedValueOnce({ + curStatus: { + status: RECORD_STATUS.PUBLISHED, + }, + code: { + baseConf: { + passwordSwitch: true, + password: '123456', + whitelistType: WhitelistType.CUSTOM, + memberType: MemberType.MOBILE, + whitelist: ['13500000000'], + }, + }, + } as ResponseSchema); + + jest.spyOn(whitelistService, 'match').mockResolvedValueOnce(null); + + await expect(controller.createResponse(reqBody, {})).rejects.toThrow( + new HttpException('白名单验证失败', EXCEPTION_CODE.WHITELIST_ERROR), + ); + }); }); }); diff --git a/server/src/modules/surveyResponse/controllers/responseSchema.controller.ts b/server/src/modules/surveyResponse/controllers/responseSchema.controller.ts index 30fd2055..9cd8f452 100644 --- a/server/src/modules/surveyResponse/controllers/responseSchema.controller.ts +++ b/server/src/modules/surveyResponse/controllers/responseSchema.controller.ts @@ -1,14 +1,35 @@ -import { Controller, Get, HttpCode, Query } from '@nestjs/common'; +import { + Body, + Controller, + Get, + HttpCode, + Param, + Post, + Query, +} from '@nestjs/common'; import { ResponseSchemaService } from '../services/responseScheme.service'; import { HttpException } from 'src/exceptions/httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { RECORD_STATUS } from 'src/enums'; import { ApiTags } from '@nestjs/swagger'; +import Joi from 'joi'; +import { Logger } from 'src/logger'; +import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException'; +import { WhitelistType } from 'src/interfaces/survey'; +import { UserService } from 'src/modules/auth/services/user.service'; +import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service'; +import { WhitelistService } from 'src/modules/auth/services/whitelist.service'; @ApiTags('surveyResponse') @Controller('/api/responseSchema') export class ResponseSchemaController { - constructor(private readonly responseSchemaService: ResponseSchemaService) {} + constructor( + private readonly responseSchemaService: ResponseSchemaService, + private readonly logger: Logger, + private readonly userService: UserService, + private readonly workspaceMemberService: WorkspaceMemberService, + private readonly whitelistService: WhitelistService, + ) {} @Get('/getSchema') @HttpCode(200) @@ -34,9 +55,81 @@ export class ResponseSchemaController { EXCEPTION_CODE.RESPONSE_SCHEMA_REMOVED, ); } + + // 去掉C端的敏感字段 + if (responseSchema.code?.baseConf) { + responseSchema.code.baseConf.password = null; + responseSchema.code.baseConf.whitelist = []; + } return { code: 200, data: responseSchema, }; } + + // 白名单验证 + @Post('/:surveyPath/validate') + @HttpCode(200) + async whitelistValidate( + @Param('surveyPath') surveyPath, + @Body() body, + ): Promise { + const { value, error } = Joi.object({ + password: Joi.string().allow(null, ''), + value: Joi.string().allow(null, ''), + }).validate(body, { allowUnknown: true }); + + if (error) { + this.logger.error(`whitelistValidate error: ${error.message}`, {}); + throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR); + } + + // 问卷信息 + const schema = + await this.responseSchemaService.getResponseSchemaByPath(surveyPath); + if (!schema || schema.curStatus.status === 'removed') { + throw new SurveyNotFoundException('该问卷不存在,无法提交'); + } + + const { password, value: val } = value; + const { + passwordSwitch, + password: settingPassword, + whitelistType, + whitelist, + } = schema.code.baseConf; + + // 密码校验 + if (passwordSwitch) { + if (settingPassword !== password) { + throw new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR); + } + } + + // 名单校验(手机号/邮箱) + if (whitelistType === WhitelistType.CUSTOM) { + if (!whitelist.includes(val)) { + throw new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR); + } + } + + // 团队成员昵称校验 + if (whitelistType === WhitelistType.MEMBER) { + const user = await this.userService.getUserByUsername(val); + if (!user) { + throw new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR); + } + + const workspaceMember = await this.workspaceMemberService.findAllByUserId( + { userId: user._id }, + ); + if (!workspaceMember.length) { + throw new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR); + } + } + + // 返回verifyId + const res = await this.whitelistService.create(surveyPath); + return res._id.toString(); + } } diff --git a/server/src/modules/surveyResponse/controllers/surveyResponse.controller.ts b/server/src/modules/surveyResponse/controllers/surveyResponse.controller.ts index 9f3bbb61..0117b80b 100644 --- a/server/src/modules/surveyResponse/controllers/surveyResponse.controller.ts +++ b/server/src/modules/surveyResponse/controllers/surveyResponse.controller.ts @@ -17,6 +17,8 @@ import * as Joi from 'joi'; import * as forge from 'node-forge'; import { ApiTags } from '@nestjs/swagger'; import { Logger } from 'src/logger'; +import { WhitelistType } from 'src/interfaces/survey'; +import { WhitelistService } from 'src/modules/auth/services/whitelist.service'; @ApiTags('surveyResponse') @Controller('/api/surveyResponse') @@ -28,6 +30,7 @@ export class SurveyResponseController { private readonly clientEncryptService: ClientEncryptService, private readonly messagePushingTaskService: MessagePushingTaskService, private readonly logger: Logger, + private readonly whitelistService: WhitelistService, ) {} @Post('/createResponse') @@ -43,6 +46,7 @@ export class SurveyResponseController { sessionId: Joi.string(), clientTime: Joi.number().required(), difTime: Joi.number(), + verifyId: Joi.string().allow(null, ''), }).validate(reqBody, { allowUnknown: true }); if (error) { @@ -52,8 +56,15 @@ export class SurveyResponseController { throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR); } - const { surveyPath, encryptType, data, sessionId, clientTime, difTime } = - value; + const { + surveyPath, + encryptType, + data, + sessionId, + clientTime, + difTime, + verifyId, + } = value; // 查询schema const responseSchema = @@ -62,6 +73,30 @@ export class SurveyResponseController { throw new SurveyNotFoundException('该问卷不存在,无法提交'); } + // 白名单的verifyId校验 + const baseConf = responseSchema.code.baseConf; + const shouldValidateVerifyId = + (baseConf?.passwordSwitch && baseConf.password) || + (baseConf?.whitelistType && baseConf.whitelistType !== WhitelistType.ALL); + if (shouldValidateVerifyId) { + // 无verifyId + if (!verifyId) { + throw new HttpException( + '白名单验证失败', + EXCEPTION_CODE.WHITELIST_ERROR, + ); + } + + // 从数据库中查询是否存在对应的verifyId + const record = await this.whitelistService.match(surveyPath, verifyId); + if (!record) { + throw new HttpException( + '白名单验证失败', + EXCEPTION_CODE.WHITELIST_ERROR, + ); + } + } + const now = Date.now(); // 提交时间限制 const begTime = responseSchema.code?.baseConf?.begTime || 0; diff --git a/server/src/modules/surveyResponse/surveyResponse.module.ts b/server/src/modules/surveyResponse/surveyResponse.module.ts index 6f9b6fc9..faf1a86d 100644 --- a/server/src/modules/surveyResponse/surveyResponse.module.ts +++ b/server/src/modules/surveyResponse/surveyResponse.module.ts @@ -20,6 +20,8 @@ import { CounterController } from './controllers/counter.controller'; import { ResponseSchemaController } from './controllers/responseSchema.controller'; import { SurveyResponseController } from './controllers/surveyResponse.controller'; import { SurveyResponseUIController } from './controllers/surveyResponseUI.controller'; +import { AuthModule } from '../auth/auth.module'; +import { WorkspaceModule } from '../workspace/workspace.module'; @Module({ imports: [ @@ -31,6 +33,8 @@ import { SurveyResponseUIController } from './controllers/surveyResponseUI.contr ]), ConfigModule, MessageModule, + AuthModule, + WorkspaceModule, ], controllers: [ ClientEncryptController,