From b8474becbbba3573fc127e2995dc17d5ed353ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Fri, 13 Oct 2023 14:36:10 +0200 Subject: [PATCH] Add signature to all webhook calls in Job Launcher --- .../server/src/common/enums/role.ts | 5 + .../src/common/guards/signature.auth.spec.ts | 51 +++++++--- .../src/common/guards/signature.auth.ts | 25 +++-- .../server/src/modules/job/job.controller.ts | 5 +- .../src/modules/job/job.service.spec.ts | 56 +++++++---- .../server/src/modules/job/job.service.ts | 92 ++++++++++++------- 6 files changed, 158 insertions(+), 76 deletions(-) create mode 100644 packages/apps/job-launcher/server/src/common/enums/role.ts diff --git a/packages/apps/job-launcher/server/src/common/enums/role.ts b/packages/apps/job-launcher/server/src/common/enums/role.ts new file mode 100644 index 0000000000..a4b6517e49 --- /dev/null +++ b/packages/apps/job-launcher/server/src/common/enums/role.ts @@ -0,0 +1,5 @@ +export enum Role { + Exchange = 'exchange', + Recording = 'recording', + Reputation = 'reputation', +} diff --git a/packages/apps/job-launcher/server/src/common/guards/signature.auth.spec.ts b/packages/apps/job-launcher/server/src/common/guards/signature.auth.spec.ts index 505bf876bc..415a2ee27c 100644 --- a/packages/apps/job-launcher/server/src/common/guards/signature.auth.spec.ts +++ b/packages/apps/job-launcher/server/src/common/guards/signature.auth.spec.ts @@ -1,25 +1,38 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { ExecutionContext, UnauthorizedException, BadRequestException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; import { SignatureAuthGuard } from './signature.auth'; import { verifySignature } from '../utils/signature'; +import { ChainId, EscrowUtils } from '@human-protocol/sdk'; import { MOCK_ADDRESS } from '../../../test/constants'; +import { Role } from '../enums/role'; jest.mock('../../common/utils/signature'); +jest.mock('@human-protocol/sdk', () => ({ + ...jest.requireActual('@human-protocol/sdk'), + EscrowUtils: { + getEscrow: jest.fn().mockResolvedValue({ + launcher: '0x1234567890123456789012345678901234567890', + exchangeOracle: '0x1234567890123456789012345678901234567891', + reputationOracle: '0x1234567890123456789012345678901234567892', + }), + }, +})); + describe('SignatureAuthGuard', () => { let guard: SignatureAuthGuard; - let mockConfigService: Partial; - - beforeEach(async () => { - mockConfigService = { - get: jest.fn(), - }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ - SignatureAuthGuard, - { provide: ConfigService, useValue: mockConfigService } + { + provide: SignatureAuthGuard, + useValue: new SignatureAuthGuard([ + Role.Exchange, + Role.Recording, + Role.Reputation, + ]), + }, ], }).compile(); @@ -44,27 +57,39 @@ describe('SignatureAuthGuard', () => { }; context = { switchToHttp: jest.fn().mockReturnThis(), - getRequest: jest.fn(() => mockRequest) + getRequest: jest.fn(() => mockRequest), } as any as ExecutionContext; }); it('should return true if signature is verified', async () => { mockRequest.headers['header-signature-key'] = 'validSignature'; + mockRequest.body = { + escrowAddress: MOCK_ADDRESS, + chainId: ChainId.LOCALHOST, + }; (verifySignature as jest.Mock).mockReturnValue(true); const result = await guard.canActivate(context as any); expect(result).toBeTruthy(); + expect(EscrowUtils.getEscrow).toHaveBeenCalledWith( + ChainId.LOCALHOST, + MOCK_ADDRESS, + ); }); it('should throw unauthorized exception if signature is not verified', async () => { (verifySignature as jest.Mock).mockReturnValue(false); - await expect(guard.canActivate(context as any)).rejects.toThrow(UnauthorizedException); + await expect(guard.canActivate(context as any)).rejects.toThrow( + UnauthorizedException, + ); }); it('should throw unauthorized exception for unrecognized oracle type', async () => { mockRequest.originalUrl = '/some/random/path'; - await expect(guard.canActivate(context as any)).rejects.toThrow(UnauthorizedException); + await expect(guard.canActivate(context as any)).rejects.toThrow( + UnauthorizedException, + ); }); }); }); diff --git a/packages/apps/job-launcher/server/src/common/guards/signature.auth.ts b/packages/apps/job-launcher/server/src/common/guards/signature.auth.ts index 3ccf99c703..fb7eba7986 100644 --- a/packages/apps/job-launcher/server/src/common/guards/signature.auth.ts +++ b/packages/apps/job-launcher/server/src/common/guards/signature.auth.ts @@ -6,26 +6,31 @@ import { } from '@nestjs/common'; import { verifySignature } from '../utils/signature'; import { HEADER_SIGNATURE_KEY } from '../constants'; -import { ConfigService } from '@nestjs/config'; -import { ConfigNames } from '../config'; +import { EscrowUtils } from '@human-protocol/sdk'; +import { Role } from '../enums/role'; @Injectable() export class SignatureAuthGuard implements CanActivate { - constructor(public readonly configService: ConfigService) {} + constructor(private role: Role[]) {} public async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const data = request.body; const signature = request.headers[HEADER_SIGNATURE_KEY]; - const oracleAdresses = [ - this.configService.get( - ConfigNames.FORTUNE_EXCHANGE_ORACLE_ADDRESS, - )!, - this.configService.get(ConfigNames.CVAT_EXCHANGE_ORACLE_ADDRESS)!, - ]; - + const oracleAdresses: string[] = []; try { + const escrowData = await EscrowUtils.getEscrow( + data.chainId, + data.escrowAddress, + ); + if (this.role.includes(Role.Exchange)) + oracleAdresses.push(escrowData.exchangeOracle!); + if (this.role.includes(Role.Recording)) + oracleAdresses.push(escrowData.recordingOracle!); + if (this.role.includes(Role.Reputation)) + oracleAdresses.push(escrowData.reputationOracle!); + const isVerified = verifySignature(data, signature, oracleAdresses); if (isVerified) { diff --git a/packages/apps/job-launcher/server/src/modules/job/job.controller.ts b/packages/apps/job-launcher/server/src/modules/job/job.controller.ts index 693ea579b3..eebced0c87 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.controller.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.controller.ts @@ -28,6 +28,7 @@ import { JobRequestType, JobStatusFilter } from '../../common/enums/job'; import { Public } from '../../common/decorators'; import { HEADER_SIGNATURE_KEY } from '../../common/constants'; import { ChainId } from '@human-protocol/sdk'; +import { Role } from '../../common/enums/role'; @ApiBearerAuth() @UseGuards(JwtAuthGuard) @@ -109,9 +110,9 @@ export class JobController { } @Public() - @UseGuards(SignatureAuthGuard) + @UseGuards(new SignatureAuthGuard([Role.Exchange])) @Post('/escrow-failed-webhook') - public async ( + public async( @Headers(HEADER_SIGNATURE_KEY) _: string, @Body() data: EscrowFailedWebhookDto, ): Promise { diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts index 7aefaef8f4..3f8e64d62e 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts @@ -654,7 +654,6 @@ describe('JobService', () => { }); }); - describe('cancelCronJob', () => { let escrowClientMock: any, getManifestMock: any, @@ -673,13 +672,13 @@ describe('JobService', () => { getBalance: jest.fn().mockResolvedValue(new Decimal(10)), }; - jobEntityMock = { - status: JobStatus.TO_CANCEL, - fundAmount: 100, - userId: 1, - id: 1, - manifestUrl: MOCK_FILE_URL, - escrowAddress: MOCK_ADDRESS, + jobEntityMock = { + status: JobStatus.TO_CANCEL, + fundAmount: 100, + userId: 1, + id: 1, + manifestUrl: MOCK_FILE_URL, + escrowAddress: MOCK_ADDRESS, chainId: ChainId.LOCALHOST, save: jest.fn(), }; @@ -717,7 +716,12 @@ describe('JobService', () => { it('should return true when the job is successfully canceled', async () => { findOneJobMock.mockResolvedValue(jobEntityMock as any); - jest.spyOn(jobService, 'processEscrowCancellation').mockResolvedValueOnce({ txHash: MOCK_TRANSACTION_HASH, amountRefunded: BigNumber.from(1) }); + jest + .spyOn(jobService, 'processEscrowCancellation') + .mockResolvedValueOnce({ + txHash: MOCK_TRANSACTION_HASH, + amountRefunded: BigNumber.from(1), + }); const manifestMock = { requestType: JobRequestType.FORTUNE, }; @@ -727,7 +731,9 @@ describe('JobService', () => { const result = await jobService.cancelCronJob(); expect(result).toBeTruthy(); - expect(jobService.processEscrowCancellation).toHaveBeenCalledWith(jobEntityMock); + expect(jobService.processEscrowCancellation).toHaveBeenCalledWith( + jobEntityMock, + ); expect(jobEntityMock.save).toHaveBeenCalled(); }); @@ -737,8 +743,12 @@ describe('JobService', () => { escrowAddress: undefined, }; - jest.spyOn(jobRepository, 'findOne').mockResolvedValueOnce(jobEntityWithoutEscrow as any); - jest.spyOn(jobService, 'processEscrowCancellation').mockResolvedValueOnce(undefined as any); + jest + .spyOn(jobRepository, 'findOne') + .mockResolvedValueOnce(jobEntityWithoutEscrow as any); + jest + .spyOn(jobService, 'processEscrowCancellation') + .mockResolvedValueOnce(undefined as any); const manifestMock = { requestType: JobRequestType.FORTUNE, }; @@ -754,7 +764,9 @@ describe('JobService', () => { getStatus: jest.fn().mockResolvedValue(EscrowStatus.Complete), })); - await expect(jobService.processEscrowCancellation(jobEntityMock as any)).rejects.toThrow(BadRequestException); + await expect( + jobService.processEscrowCancellation(jobEntityMock as any), + ).rejects.toThrow(BadRequestException); }); it('should throw bad request exception if escrowStatus is Paid', async () => { @@ -762,7 +774,9 @@ describe('JobService', () => { getStatus: jest.fn().mockResolvedValue(EscrowStatus.Paid), })); - await expect(jobService.processEscrowCancellation(jobEntityMock as any)).rejects.toThrow(BadRequestException); + await expect( + jobService.processEscrowCancellation(jobEntityMock as any), + ).rejects.toThrow(BadRequestException); }); it('should throw bad request exception if escrowStatus is Cancelled', async () => { @@ -770,7 +784,9 @@ describe('JobService', () => { getStatus: jest.fn().mockResolvedValue(EscrowStatus.Cancelled), })); - await expect(jobService.processEscrowCancellation(jobEntityMock as any)).rejects.toThrow(BadRequestException); + await expect( + jobService.processEscrowCancellation(jobEntityMock as any), + ).rejects.toThrow(BadRequestException); }); it('should throw bad request exception if escrow balance is zero', async () => { @@ -779,7 +795,9 @@ describe('JobService', () => { getBalance: jest.fn().mockResolvedValue({ eq: () => true }), })); - await expect(jobService.processEscrowCancellation(jobEntityMock as any)).rejects.toThrow(BadRequestException); + await expect( + jobService.processEscrowCancellation(jobEntityMock as any), + ).rejects.toThrow(BadRequestException); }); }); @@ -1274,7 +1292,11 @@ describe('JobService', () => { escrowAddress: 'address', reason: 'invalid manifest', }; - const mockJobEntity = { status: JobStatus.LAUNCHED, failedReason: dto.reason, save: jest.fn() }; + const mockJobEntity = { + status: JobStatus.LAUNCHED, + failedReason: dto.reason, + save: jest.fn(), + }; jobRepository.findOne = jest.fn().mockResolvedValue(mockJobEntity); const result = await jobService.escrowFailedWebhook(dto); diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.ts index def792caec..77f605ebb4 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.ts @@ -11,7 +11,6 @@ import { StorageParams, UploadFile, } from '@human-protocol/sdk'; -import { TransactionReceipt } from "@ethersproject/abstract-provider"; import { HttpService } from '@nestjs/axios'; import { BadGatewayException, @@ -70,7 +69,11 @@ import { import { JobEntity } from './job.entity'; import { JobRepository } from './job.repository'; import { RoutingProtocolService } from './routing-protocol.service'; -import { CANCEL_JOB_STATUSES, JOB_RETRIES_COUNT_THRESHOLD } from '../../common/constants'; +import { + CANCEL_JOB_STATUSES, + HEADER_SIGNATURE_KEY, + JOB_RETRIES_COUNT_THRESHOLD, +} from '../../common/constants'; import { SortDirection } from '../../common/enums/collection'; import { EventType } from '../../common/enums/webhook'; import { @@ -80,6 +83,7 @@ import { import Decimal from 'decimal.js'; import { EscrowData } from '@human-protocol/sdk/dist/graphql'; import { filterToEscrowStatus } from '../../common/utils/status'; +import { signMessage } from '../../common/utils/signature'; @Injectable() export class JobService { @@ -411,8 +415,14 @@ export class JobService { webhookUrl: string, webhookData: SendWebhookDto, ): Promise { + const signedBody = await signMessage( + webhookData, + this.configService.get(ConfigNames.WEB3_PRIVATE_KEY)!, + ); const { data } = await firstValueFrom( - await this.httpService.post(webhookUrl, webhookData), + await this.httpService.post(webhookUrl, webhookData, { + headers: { [HEADER_SIGNATURE_KEY]: signedBody }, + }), ); if (!data) { @@ -634,58 +644,72 @@ export class JobService { }, { order: { - waitUntil: SortDirection.ASC, + waitUntil: SortDirection.ASC, }, - } + }, ); if (!jobEntity) return; if (jobEntity.escrowAddress) { - const { amountRefunded } = await this.processEscrowCancellation(jobEntity); - await this.paymentService.createRefundPayment({ refundAmount: Number(ethers.utils.formatEther(amountRefunded)), userId: jobEntity.userId, jobId: jobEntity.id }); + const { amountRefunded } = await this.processEscrowCancellation( + jobEntity, + ); + await this.paymentService.createRefundPayment({ + refundAmount: Number(ethers.utils.formatEther(amountRefunded)), + userId: jobEntity.userId, + jobId: jobEntity.id, + }); } else { - await this.paymentService.createRefundPayment({ refundAmount: jobEntity.fundAmount, userId: jobEntity.userId, jobId: jobEntity.id }); + await this.paymentService.createRefundPayment({ + refundAmount: jobEntity.fundAmount, + userId: jobEntity.userId, + jobId: jobEntity.id, + }); } jobEntity.status = JobStatus.CANCELED; await jobEntity.save(); const manifest = await this.getManifest(jobEntity.manifestUrl); - const configKey = (manifest as FortuneManifestDto).requestType === JobRequestType.FORTUNE - ? ConfigNames.FORTUNE_EXCHANGE_ORACLE_WEBHOOK_URL - : ConfigNames.CVAT_EXCHANGE_ORACLE_WEBHOOK_URL; + const configKey = + (manifest as FortuneManifestDto).requestType === JobRequestType.FORTUNE + ? ConfigNames.FORTUNE_EXCHANGE_ORACLE_WEBHOOK_URL + : ConfigNames.CVAT_EXCHANGE_ORACLE_WEBHOOK_URL; - await this.sendWebhook( - this.configService.get(configKey)!, - { - escrowAddress: jobEntity.escrowAddress, - chainId: jobEntity.chainId, - eventType: EventType.ESCROW_CANCELED, - } - ); + await this.sendWebhook(this.configService.get(configKey)!, { + escrowAddress: jobEntity.escrowAddress, + chainId: jobEntity.chainId, + eventType: EventType.ESCROW_CANCELED, + }); return true; } - public async processEscrowCancellation(jobEntity: JobEntity): Promise { - const { chainId, escrowAddress } = jobEntity; + public async processEscrowCancellation( + jobEntity: JobEntity, + ): Promise { + const { chainId, escrowAddress } = jobEntity; - const signer = this.web3Service.getSigner(chainId); - const escrowClient = await EscrowClient.build(signer); + const signer = this.web3Service.getSigner(chainId); + const escrowClient = await EscrowClient.build(signer); - const escrowStatus = await escrowClient.getStatus(escrowAddress); - if (escrowStatus === EscrowStatus.Complete || escrowStatus === EscrowStatus.Paid || escrowStatus === EscrowStatus.Cancelled) { - this.logger.log(ErrorEscrow.InvalidStatusCancellation, JobService.name); - throw new BadRequestException(ErrorEscrow.InvalidStatusCancellation); - } + const escrowStatus = await escrowClient.getStatus(escrowAddress); + if ( + escrowStatus === EscrowStatus.Complete || + escrowStatus === EscrowStatus.Paid || + escrowStatus === EscrowStatus.Cancelled + ) { + this.logger.log(ErrorEscrow.InvalidStatusCancellation, JobService.name); + throw new BadRequestException(ErrorEscrow.InvalidStatusCancellation); + } - const balance = await escrowClient.getBalance(escrowAddress); - if (balance.eq(0)) { - this.logger.log(ErrorEscrow.InvalidBalanceCancellation, JobService.name); - throw new BadRequestException(ErrorEscrow.InvalidBalanceCancellation); - } + const balance = await escrowClient.getBalance(escrowAddress); + if (balance.eq(0)) { + this.logger.log(ErrorEscrow.InvalidBalanceCancellation, JobService.name); + throw new BadRequestException(ErrorEscrow.InvalidBalanceCancellation); + } - return escrowClient.cancel(escrowAddress); + return escrowClient.cancel(escrowAddress); } public async escrowFailedWebhook(