[Feature] 白名单功能服务端 (#302)

* feat: 添加问卷信息字段、去掉C端获取问卷信息的敏感字段

* feat: 白名单验证接口

* test: 白名单验证单元测试、参数类型优化

* test: 增加白名单验证单元测试

* feat: 提交问卷时校验白名单

* test: 提交问卷验证verifyId

* test: verifyId不匹配测试
This commit is contained in:
Stahsf 2024-06-19 14:31:18 +08:00 committed by GitHub
parent 2e1af4ae3a
commit 667d13962c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 483 additions and 8 deletions

View File

@ -12,6 +12,7 @@ export enum EXCEPTION_CODE {
SURVEY_NOT_FOUND = 3004, // 问卷不存在 SURVEY_NOT_FOUND = 3004, // 问卷不存在
SURVEY_CONTENT_NOT_ALLOW = 3005, // 存在禁用内容 SURVEY_CONTENT_NOT_ALLOW = 3005, // 存在禁用内容
CAPTCHA_INCORRECT = 4001, // 验证码不正确 CAPTCHA_INCORRECT = 4001, // 验证码不正确
WHITELIST_ERROR = 4002, // 白名单校验错误
RESPONSE_SIGN_ERROR = 9001, // 签名不正确 RESPONSE_SIGN_ERROR = 9001, // 签名不正确
RESPONSE_CURRENT_TIME_NOT_ALLOW = 9002, // 当前时间不允许提交 RESPONSE_CURRENT_TIME_NOT_ALLOW = 9002, // 当前时间不允许提交

View File

@ -94,6 +94,23 @@ export interface SubmitConf {
msgContent: MsgContent; msgContent: MsgContent;
} }
// 白名单类型
export enum WhitelistType {
ALL = 'ALL',
// 空间成员
MEMBER = 'MEMBER',
// 自定义
CUSTOM = 'CUSTOM',
}
// 白名单用户类型
export enum MemberType {
// 手机号
MOBILE = 'MOBILE',
// 邮箱
EMAIL = 'EMAIL',
}
export interface BaseConf { export interface BaseConf {
begTime: string; begTime: string;
endTime: string; endTime: string;
@ -101,6 +118,18 @@ export interface BaseConf {
answerEndTime: string; answerEndTime: string;
tLimit: number; tLimit: number;
language: string; language: string;
// 访问密码开关
passwordSwitch?: boolean;
// 密码
password?: string | null;
// 白名单类型
whitelistType?: WhitelistType;
// 白名单用户类型
memberType?: MemberType;
// 白名单列表
whitelist?: string[];
// 提示语
whitelistTip?: string;
} }
export interface SkinConf { export interface SkinConf {

View File

@ -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;
}

View File

@ -11,11 +11,16 @@ import { Captcha } from 'src/models/captcha.entity';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { WhitelistService } from './services/whitelist.service';
import { WhitelistVerify } from 'src/models/whitelistVerify.entity';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([User, Captcha]), ConfigModule], imports: [
TypeOrmModule.forFeature([User, Captcha, WhitelistVerify]),
ConfigModule,
],
controllers: [AuthController, UserController], controllers: [AuthController, UserController],
providers: [UserService, AuthService, CaptchaService], providers: [UserService, AuthService, CaptchaService, WhitelistService],
exports: [UserService, AuthService], exports: [UserService, AuthService, WhitelistService],
}) })
export class AuthModule {} export class AuthModule {}

View File

@ -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<WhitelistVerify>,
) {}
// 创建
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,
},
});
}
}

View File

@ -6,23 +6,72 @@ import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { RECORD_STATUS } from 'src/enums'; import { RECORD_STATUS } from 'src/enums';
import { ResponseSchema } from 'src/models/responseSchema.entity'; 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'); jest.mock('../services/responseScheme.service');
describe('ResponseSchemaController', () => { describe('ResponseSchemaController', () => {
let controller: ResponseSchemaController; let controller: ResponseSchemaController;
let responseSchemaService: ResponseSchemaService; let responseSchemaService: ResponseSchemaService;
let whitelistService: WhitelistService;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [ResponseSchemaController], 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(); }).compile();
controller = module.get<ResponseSchemaController>(ResponseSchemaController); controller = module.get<ResponseSchemaController>(ResponseSchemaController);
responseSchemaService = module.get<ResponseSchemaService>( responseSchemaService = module.get<ResponseSchemaService>(
ResponseSchemaService, ResponseSchemaService,
); );
whitelistService = module.get<WhitelistService>(WhitelistService);
}); });
describe('getSchema', () => { describe('getSchema', () => {
@ -66,5 +115,157 @@ describe('ResponseSchemaController', () => {
new HttpException('问卷已删除', EXCEPTION_CODE.RESPONSE_SCHEMA_REMOVED), 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);
}); });
}); });

View File

@ -21,6 +21,11 @@ import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugi
import { RECORD_STATUS } from 'src/enums'; import { RECORD_STATUS } from 'src/enums';
import { SurveyResponse } from 'src/models/surveyResponse.entity'; import { SurveyResponse } from 'src/models/surveyResponse.entity';
import { Logger } from 'src/logger'; 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 = { const mockDecryptErrorBody = {
surveyPath: 'EBzdmnSp', surveyPath: 'EBzdmnSp',
@ -76,6 +81,7 @@ describe('SurveyResponseController', () => {
let responseSchemaService: ResponseSchemaService; let responseSchemaService: ResponseSchemaService;
let surveyResponseService: SurveyResponseService; let surveyResponseService: SurveyResponseService;
let clientEncryptService: ClientEncryptService; let clientEncryptService: ClientEncryptService;
let whitelistService: WhitelistService;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
@ -124,6 +130,12 @@ describe('SurveyResponseController', () => {
info: jest.fn(), info: jest.fn(),
}, },
}, },
{
provide: WhitelistService,
useValue: {
match: jest.fn(),
},
},
], ],
}).compile(); }).compile();
@ -136,6 +148,7 @@ describe('SurveyResponseController', () => {
); );
clientEncryptService = clientEncryptService =
module.get<ClientEncryptService>(ClientEncryptService); module.get<ClientEncryptService>(ClientEncryptService);
whitelistService = module.get<WhitelistService>(WhitelistService);
const pluginManager = module.get<XiaojuSurveyPluginManager>( const pluginManager = module.get<XiaojuSurveyPluginManager>(
XiaojuSurveyPluginManager, XiaojuSurveyPluginManager,
@ -306,5 +319,61 @@ describe('SurveyResponseController', () => {
HttpException, 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),
);
});
}); });
}); });

View File

@ -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 { ResponseSchemaService } from '../services/responseScheme.service';
import { HttpException } from 'src/exceptions/httpException'; import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { RECORD_STATUS } from 'src/enums'; import { RECORD_STATUS } from 'src/enums';
import { ApiTags } from '@nestjs/swagger'; 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') @ApiTags('surveyResponse')
@Controller('/api/responseSchema') @Controller('/api/responseSchema')
export class ResponseSchemaController { 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') @Get('/getSchema')
@HttpCode(200) @HttpCode(200)
@ -34,9 +55,81 @@ export class ResponseSchemaController {
EXCEPTION_CODE.RESPONSE_SCHEMA_REMOVED, EXCEPTION_CODE.RESPONSE_SCHEMA_REMOVED,
); );
} }
// 去掉C端的敏感字段
if (responseSchema.code?.baseConf) {
responseSchema.code.baseConf.password = null;
responseSchema.code.baseConf.whitelist = [];
}
return { return {
code: 200, code: 200,
data: responseSchema, data: responseSchema,
}; };
} }
// 白名单验证
@Post('/:surveyPath/validate')
@HttpCode(200)
async whitelistValidate(
@Param('surveyPath') surveyPath,
@Body() body,
): Promise<string> {
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();
}
} }

View File

@ -17,6 +17,8 @@ import * as Joi from 'joi';
import * as forge from 'node-forge'; import * as forge from 'node-forge';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Logger } from 'src/logger'; import { Logger } from 'src/logger';
import { WhitelistType } from 'src/interfaces/survey';
import { WhitelistService } from 'src/modules/auth/services/whitelist.service';
@ApiTags('surveyResponse') @ApiTags('surveyResponse')
@Controller('/api/surveyResponse') @Controller('/api/surveyResponse')
@ -28,6 +30,7 @@ export class SurveyResponseController {
private readonly clientEncryptService: ClientEncryptService, private readonly clientEncryptService: ClientEncryptService,
private readonly messagePushingTaskService: MessagePushingTaskService, private readonly messagePushingTaskService: MessagePushingTaskService,
private readonly logger: Logger, private readonly logger: Logger,
private readonly whitelistService: WhitelistService,
) {} ) {}
@Post('/createResponse') @Post('/createResponse')
@ -43,6 +46,7 @@ export class SurveyResponseController {
sessionId: Joi.string(), sessionId: Joi.string(),
clientTime: Joi.number().required(), clientTime: Joi.number().required(),
difTime: Joi.number(), difTime: Joi.number(),
verifyId: Joi.string().allow(null, ''),
}).validate(reqBody, { allowUnknown: true }); }).validate(reqBody, { allowUnknown: true });
if (error) { if (error) {
@ -52,8 +56,15 @@ export class SurveyResponseController {
throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR); throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR);
} }
const { surveyPath, encryptType, data, sessionId, clientTime, difTime } = const {
value; surveyPath,
encryptType,
data,
sessionId,
clientTime,
difTime,
verifyId,
} = value;
// 查询schema // 查询schema
const responseSchema = const responseSchema =
@ -62,6 +73,30 @@ export class SurveyResponseController {
throw new SurveyNotFoundException('该问卷不存在,无法提交'); 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 now = Date.now();
// 提交时间限制 // 提交时间限制
const begTime = responseSchema.code?.baseConf?.begTime || 0; const begTime = responseSchema.code?.baseConf?.begTime || 0;

View File

@ -20,6 +20,8 @@ import { CounterController } from './controllers/counter.controller';
import { ResponseSchemaController } from './controllers/responseSchema.controller'; import { ResponseSchemaController } from './controllers/responseSchema.controller';
import { SurveyResponseController } from './controllers/surveyResponse.controller'; import { SurveyResponseController } from './controllers/surveyResponse.controller';
import { SurveyResponseUIController } from './controllers/surveyResponseUI.controller'; import { SurveyResponseUIController } from './controllers/surveyResponseUI.controller';
import { AuthModule } from '../auth/auth.module';
import { WorkspaceModule } from '../workspace/workspace.module';
@Module({ @Module({
imports: [ imports: [
@ -31,6 +33,8 @@ import { SurveyResponseUIController } from './controllers/surveyResponseUI.contr
]), ]),
ConfigModule, ConfigModule,
MessageModule, MessageModule,
AuthModule,
WorkspaceModule,
], ],
controllers: [ controllers: [
ClientEncryptController, ClientEncryptController,