Slave/upload api #5
Generated
+73
-7
@@ -13,11 +13,14 @@
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/throttler": "^6.7.1",
|
||||
"@nestjs/typeorm": "^12.0.2",
|
||||
"altcha-lib": "^2.6.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"dayjs": "^1.11.23",
|
||||
"file-type": "^22.1.1",
|
||||
"pg": "^8.23.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"typeorm": "^0.3.28"
|
||||
@@ -1841,6 +1844,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/common/node_modules/file-type": {
|
||||
"version": "21.3.4",
|
||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
|
||||
"integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tokenizer/inflate": "^0.4.1",
|
||||
"strtok3": "^10.3.4",
|
||||
"token-types": "^6.1.1",
|
||||
"uint8array-extras": "^1.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/config": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz",
|
||||
@@ -1916,6 +1937,19 @@
|
||||
"@nestjs/core": "^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schedule": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.3.tgz",
|
||||
"integrity": "sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron": "4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"@nestjs/core": "^10.0.0 || ^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
|
||||
@@ -2603,6 +2637,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/luxon": {
|
||||
"version": "3.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.6.tgz",
|
||||
"integrity": "sha512-6KSjliQAXK8ZLgFQO4B7iE4Rpf/B3rItzCFuXezBY/XIAGxOdLd7vRNK9/StRPwiS/yJsVbB7HKpW46B8tcEuQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz",
|
||||
@@ -4251,6 +4291,23 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cron": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz",
|
||||
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/luxon": "~3.7.0",
|
||||
"luxon": "~3.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.x"
|
||||
},
|
||||
"funding": {
|
||||
"type": "ko-fi",
|
||||
"url": "https://ko-fi.com/intcreator"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -4815,18 +4872,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/file-type": {
|
||||
"version": "21.3.4",
|
||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
|
||||
"integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==",
|
||||
"version": "22.1.1",
|
||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-22.1.1.tgz",
|
||||
"integrity": "sha512-sEB0oStmeLGtpmSAD88RDNuLsvhv4iC3u0lE25CUvfCULvj49xoQ3l3K525Vn+ZzsZ6DOLajQCV6RmhwwhqiJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tokenizer/inflate": "^0.4.1",
|
||||
"strtok3": "^10.3.4",
|
||||
"token-types": "^6.1.1",
|
||||
"uint8array-extras": "^1.4.0"
|
||||
"strtok3": "^10.3.5",
|
||||
"token-types": "^6.1.2",
|
||||
"uint8array-extras": "^1.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
|
||||
@@ -6417,6 +6474,15 @@
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.17",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
||||
|
||||
@@ -24,11 +24,14 @@
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/throttler": "^6.7.1",
|
||||
"@nestjs/typeorm": "^12.0.2",
|
||||
"altcha-lib": "^2.6.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"dayjs": "^1.11.23",
|
||||
"file-type": "^22.1.1",
|
||||
"pg": "^8.23.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"typeorm": "^0.3.28"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService, registerAs } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
@@ -9,10 +10,11 @@ import { AltchaModule } from './modules/altcha/AltchaModule';
|
||||
import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule';
|
||||
import { StorageModule } from './modules/storage/StorageModule';
|
||||
import { UploadModule } from './modules/upload/UploadModule';
|
||||
import { Config } from './types/Config';
|
||||
import { Config } from './types/config/Config';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ScheduleModule.forRoot(),
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validate,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const ALTCHA_REQUEST_HEADER = 'x-altcha';
|
||||
+36
-5
@@ -1,17 +1,48 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import express, { type NextFunction, type Request, type Response } from 'express';
|
||||
import { AppModule } from './AppModule';
|
||||
import { Config } from './types/Config';
|
||||
import { altchaMiddleware } from './middlewares/altchaMiddleware';
|
||||
import { AltchaService } from './modules/altcha/services/AltchaService';
|
||||
import { Config } from './types/config/Config';
|
||||
import { HttpMethod } from './types/HttpMethod';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
const { port, corsOrigins } = configService.get('app') as Config['app'];
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, { bodyParser: false });
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
const altchaService = app.get(AltchaService);
|
||||
|
||||
const {
|
||||
port,
|
||||
corsOrigins,
|
||||
validation: {
|
||||
upload: { maxRequestBodyBytes }
|
||||
}
|
||||
} = configService.get('app') as Config['app'];
|
||||
|
||||
const defaultJsonParser = express.json();
|
||||
const uploadJsonParser = express.json({ limit: maxRequestBodyBytes });
|
||||
const uploadAltchaMiddleware = altchaMiddleware(altchaService);
|
||||
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.method === HttpMethod.Post && req.path.startsWith('/api/uploads')) {
|
||||
return uploadAltchaMiddleware(req, res, (err: unknown) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
return uploadJsonParser(req, res, next);
|
||||
});
|
||||
}
|
||||
|
||||
return defaultJsonParser(req, res, next);
|
||||
});
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import type { RequestHandler } from 'express';
|
||||
import { ALTCHA_REQUEST_HEADER } from '../consts/altchaHeader';
|
||||
import type { AltchaService } from '../modules/altcha/services/AltchaService';
|
||||
import { AltchaVerificationStatus } from '../modules/altcha/types/AltchaVerificationStatus';
|
||||
|
||||
const altchaVerificationMessage: Record<Exclude<AltchaVerificationStatus, AltchaVerificationStatus.Ok>, string> = {
|
||||
[AltchaVerificationStatus.Missing]: 'ALTCHA verification required',
|
||||
[AltchaVerificationStatus.Invalid]: 'ALTCHA verification failed',
|
||||
[AltchaVerificationStatus.AlreadyUsed]: 'ALTCHA already used'
|
||||
};
|
||||
|
||||
export const altchaMiddleware = (altchaService: AltchaService): RequestHandler => {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const altchaHeader = req.get(ALTCHA_REQUEST_HEADER);
|
||||
|
||||
const status = await altchaService.verifySolution(altchaHeader);
|
||||
|
||||
if (status === AltchaVerificationStatus.Ok) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(HttpStatus.BAD_REQUEST).send({ message: altchaVerificationMessage[status] });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AltchaController } from './controllers/AltchaController';
|
||||
import { AltchaGuard } from './guards/AltchaGuard';
|
||||
import { AltchaService } from './services/AltchaService';
|
||||
import { UsedAltchaStoreService } from './services/UsedAltchaStoreService';
|
||||
|
||||
@Module({
|
||||
controllers: [AltchaController],
|
||||
providers: [AltchaService, AltchaGuard],
|
||||
exports: [AltchaGuard]
|
||||
providers: [AltchaService, UsedAltchaStoreService]
|
||||
})
|
||||
export class AltchaModule {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Controller, Get, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Config } from '../../../types/config/Config';
|
||||
import { AltchaService } from '../services/AltchaService';
|
||||
import { Config } from '../../../types/Config';
|
||||
|
||||
@Controller('altcha')
|
||||
export class AltchaController {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { BadRequestException, CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
import { AltchaService } from '../services/AltchaService';
|
||||
import { Config } from '../../../types/Config';
|
||||
|
||||
@Injectable()
|
||||
export class AltchaGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly altchaService: AltchaService,
|
||||
private readonly configService: ConfigService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const { enabled } = this.configService.get('altcha') as Config['altcha'];
|
||||
|
||||
if (!enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const altcha = request.body?.altcha;
|
||||
|
||||
if (typeof altcha !== 'string' || altcha.length === 0) {
|
||||
throw new BadRequestException('ALTCHA verification required');
|
||||
}
|
||||
|
||||
const verified = await this.altchaService.verifyPayload(altcha);
|
||||
|
||||
if (!verified) {
|
||||
throw new BadRequestException('ALTCHA verification failed');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,138 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createChallenge, solveChallenge } from 'altcha-lib/v1';
|
||||
import type { Payload } from 'altcha-lib/v1/types';
|
||||
import { createChallenge, extractParams, solveChallenge } from 'altcha-lib/v1';
|
||||
import type { Challenge, Payload } from 'altcha-lib/v1/types';
|
||||
import { AltchaVerificationStatus } from '../types/AltchaVerificationStatus';
|
||||
import { AltchaService } from './AltchaService';
|
||||
import { UsedAltchaStoreService } from './UsedAltchaStoreService';
|
||||
|
||||
const hmacKey = 'test-hmac-key';
|
||||
|
||||
const encodePayload = (payload: Payload): string =>
|
||||
Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
|
||||
|
||||
describe('AltchaService', () => {
|
||||
const configService = {
|
||||
get: jest.fn(() => ({ enabled: true, hmacKey, maxNumber: 100_000 }))
|
||||
} as unknown as ConfigService;
|
||||
|
||||
const service = new AltchaService(configService);
|
||||
|
||||
it('verifies a valid ALTCHA payload', async () => {
|
||||
const challenge = await createChallenge({ hmacKey, number: 42, maxNumber: 100_000 });
|
||||
const encodeSolvedChallenge = async (challenge: Challenge): Promise<string> => {
|
||||
const { promise } = solveChallenge(challenge.challenge, challenge.salt, challenge.algorithm, 100_000);
|
||||
const solution = await promise;
|
||||
|
||||
expect(solution).not.toBeNull();
|
||||
|
||||
const payload: Payload = {
|
||||
return encodePayload({
|
||||
algorithm: challenge.algorithm,
|
||||
challenge: challenge.challenge,
|
||||
number: solution!.number,
|
||||
salt: challenge.salt,
|
||||
signature: challenge.signature
|
||||
});
|
||||
};
|
||||
|
||||
await expect(service.verifyPayload(encodePayload(payload))).resolves.toBe(true);
|
||||
const solveChallengePayload = async (): Promise<string> =>
|
||||
encodeSolvedChallenge(await createChallenge({ hmacKey, number: 42, maxNumber: 100_000 }));
|
||||
|
||||
const createConfigService = (enabled: boolean): ConfigService =>
|
||||
({
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'altcha') {
|
||||
return {
|
||||
enabled,
|
||||
hmacKey,
|
||||
maxNumber: 100_000,
|
||||
challengeTtlMs: 600_000
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})
|
||||
}) as unknown as ConfigService;
|
||||
|
||||
const createAltchaService = (enabled: boolean): AltchaService => {
|
||||
const configService = createConfigService(enabled);
|
||||
|
||||
return new AltchaService(configService, new UsedAltchaStoreService(configService));
|
||||
};
|
||||
|
||||
describe('AltchaService', () => {
|
||||
it('verifies and consumes a valid ALTCHA payload', async () => {
|
||||
const service = createAltchaService(true);
|
||||
const altcha = await solveChallengePayload();
|
||||
|
||||
await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Ok);
|
||||
});
|
||||
|
||||
it('rejects an invalid ALTCHA payload', async () => {
|
||||
await expect(service.verifyPayload('not-valid-base64-json')).resolves.toBe(false);
|
||||
const service = createAltchaService(true);
|
||||
|
||||
await expect(service.verifySolution('not-valid-base64-json')).resolves.toBe(
|
||||
AltchaVerificationStatus.Invalid
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an expired ALTCHA payload', async () => {
|
||||
const service = createAltchaService(true);
|
||||
const altcha = await encodeSolvedChallenge(
|
||||
await createChallenge({
|
||||
hmacKey,
|
||||
number: 42,
|
||||
maxNumber: 100_000,
|
||||
expires: new Date(Date.now() - 60_000)
|
||||
})
|
||||
);
|
||||
|
||||
await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Invalid);
|
||||
});
|
||||
|
||||
it('rejects a payload signed with a different hmac key', async () => {
|
||||
const service = createAltchaService(true);
|
||||
const altcha = await encodeSolvedChallenge(
|
||||
await createChallenge({ hmacKey: 'other-key', number: 42, maxNumber: 100_000 })
|
||||
);
|
||||
|
||||
await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Invalid);
|
||||
});
|
||||
|
||||
it('rejects a missing ALTCHA payload when enabled', async () => {
|
||||
const service = createAltchaService(true);
|
||||
|
||||
await expect(service.verifySolution(undefined)).resolves.toBe(AltchaVerificationStatus.Missing);
|
||||
await expect(service.verifySolution('')).resolves.toBe(AltchaVerificationStatus.Missing);
|
||||
});
|
||||
|
||||
it('rejects an immediate replay', async () => {
|
||||
const service = createAltchaService(true);
|
||||
const altcha = await solveChallengePayload();
|
||||
|
||||
await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Ok);
|
||||
await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.AlreadyUsed);
|
||||
});
|
||||
|
||||
it('skips verification when ALTCHA is disabled', async () => {
|
||||
const service = createAltchaService(false);
|
||||
|
||||
await expect(service.verifySolution(undefined)).resolves.toBe(AltchaVerificationStatus.Ok);
|
||||
});
|
||||
|
||||
it('does not enforce replay protection when ALTCHA is disabled', async () => {
|
||||
const service = createAltchaService(false);
|
||||
const altcha = await solveChallengePayload();
|
||||
|
||||
await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Ok);
|
||||
await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Ok);
|
||||
});
|
||||
|
||||
describe('createChallenge', () => {
|
||||
it('applies maxNumber and challenge TTL from config', async () => {
|
||||
const service = createAltchaService(true);
|
||||
const before = Date.now();
|
||||
|
||||
const challenge = await service.createChallenge();
|
||||
|
||||
expect(challenge.maxnumber).toBe(100_000);
|
||||
expect(challenge.signature).toBeTruthy();
|
||||
|
||||
const { expires } = extractParams(challenge);
|
||||
const expiresAtMs = Number(expires) * 1000;
|
||||
|
||||
expect(expiresAtMs).toBeGreaterThanOrEqual(before + 600_000 - 1000);
|
||||
expect(expiresAtMs).toBeLessThanOrEqual(before + 600_000 + 5000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,52 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createChallenge, verifySolution } from 'altcha-lib/v1';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Challenge } from 'altcha-lib/v1/types';
|
||||
import { Config } from '../../../types/Config';
|
||||
import { Config } from '../../../types/config/Config';
|
||||
import { AltchaVerificationStatus } from '../types/AltchaVerificationStatus';
|
||||
import { UsedAltchaStoreService } from './UsedAltchaStoreService';
|
||||
|
||||
@Injectable()
|
||||
export class AltchaService {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly usedAltchaStore: UsedAltchaStoreService
|
||||
) {}
|
||||
|
||||
async createChallenge(): Promise<Challenge> {
|
||||
const { hmacKey, maxNumber } = this.configService.get('altcha') as Config['altcha'];
|
||||
const { hmacKey, maxNumber, challengeTtlMs } = this.configService.get('altcha') as Config['altcha'];
|
||||
|
||||
return createChallenge({
|
||||
hmacKey,
|
||||
maxNumber
|
||||
maxNumber,
|
||||
expires: dayjs().add(challengeTtlMs, 'millisecond').toDate()
|
||||
});
|
||||
}
|
||||
|
||||
async verifyPayload(altcha: string): Promise<boolean> {
|
||||
const { hmacKey } = this.configService.get('altcha') as Config['altcha'];
|
||||
async verifySolution(altcha: string | undefined): Promise<AltchaVerificationStatus> {
|
||||
const { enabled, hmacKey } = this.configService.get('altcha') as Config['altcha'];
|
||||
|
||||
return verifySolution(altcha, hmacKey);
|
||||
if (!enabled) {
|
||||
return AltchaVerificationStatus.Ok;
|
||||
}
|
||||
|
||||
if (!altcha) {
|
||||
return AltchaVerificationStatus.Missing;
|
||||
}
|
||||
|
||||
const verified = await verifySolution(altcha, hmacKey);
|
||||
|
||||
if (!verified) {
|
||||
return AltchaVerificationStatus.Invalid;
|
||||
}
|
||||
|
||||
const consumed = this.usedAltchaStore.tryConsume(altcha);
|
||||
|
||||
if (!consumed) {
|
||||
return AltchaVerificationStatus.AlreadyUsed;
|
||||
}
|
||||
|
||||
return AltchaVerificationStatus.Ok;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { UsedAltchaStoreService } from './UsedAltchaStoreService';
|
||||
|
||||
const createConfigService = (challengeTtlMs: number): ConfigService =>
|
||||
({
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'altcha') {
|
||||
return { challengeTtlMs };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})
|
||||
}) as unknown as ConfigService;
|
||||
|
||||
describe('UsedAltchaStoreService', () => {
|
||||
it('allows the first consume and rejects an immediate replay', () => {
|
||||
const store = new UsedAltchaStoreService(createConfigService(60_000));
|
||||
const altcha = 'valid-solution-payload';
|
||||
|
||||
expect(store.tryConsume(altcha)).toBe(true);
|
||||
expect(store.tryConsume(altcha)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects replay until prune removes the entry after expiry', () => {
|
||||
const store = new UsedAltchaStoreService(createConfigService(1));
|
||||
const altcha = 'expires-quickly';
|
||||
|
||||
expect(store.tryConsume(altcha)).toBe(true);
|
||||
|
||||
return new Promise<void>(resolve => {
|
||||
setTimeout(() => {
|
||||
expect(store.tryConsume(altcha)).toBe(false);
|
||||
store.pruneExpired();
|
||||
expect(store.tryConsume(altcha)).toBe(true);
|
||||
resolve();
|
||||
}, 5);
|
||||
});
|
||||
});
|
||||
|
||||
it('pruneExpired removes expired entries', () => {
|
||||
const store = new UsedAltchaStoreService(createConfigService(1));
|
||||
const altcha = 'prune-me';
|
||||
|
||||
expect(store.tryConsume(altcha)).toBe(true);
|
||||
|
||||
return new Promise<void>(resolve => {
|
||||
setTimeout(() => {
|
||||
store.pruneExpired();
|
||||
expect(store.tryConsume(altcha)).toBe(true);
|
||||
resolve();
|
||||
}, 5);
|
||||
});
|
||||
});
|
||||
|
||||
it('tracks different payloads independently', () => {
|
||||
const store = new UsedAltchaStoreService(createConfigService(60_000));
|
||||
|
||||
expect(store.tryConsume('payload-a')).toBe(true);
|
||||
expect(store.tryConsume('payload-b')).toBe(true);
|
||||
expect(store.tryConsume('payload-a')).toBe(false);
|
||||
expect(store.tryConsume('payload-b')).toBe(false);
|
||||
});
|
||||
|
||||
it('pruneExpired does not remove entries before their expiry', () => {
|
||||
const store = new UsedAltchaStoreService(createConfigService(60_000));
|
||||
|
||||
expect(store.tryConsume('still-active')).toBe(true);
|
||||
store.pruneExpired();
|
||||
|
||||
expect(store.tryConsume('still-active')).toBe(false);
|
||||
});
|
||||
|
||||
it('pruneExpired clears every expired entry in one pass', () => {
|
||||
const store = new UsedAltchaStoreService(createConfigService(1));
|
||||
|
||||
expect(store.tryConsume('first')).toBe(true);
|
||||
expect(store.tryConsume('second')).toBe(true);
|
||||
|
||||
return new Promise<void>(resolve => {
|
||||
setTimeout(() => {
|
||||
store.pruneExpired();
|
||||
expect(store.tryConsume('first')).toBe(true);
|
||||
expect(store.tryConsume('second')).toBe(true);
|
||||
resolve();
|
||||
}, 5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import dayjs from 'dayjs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Config } from '../../../types/config/Config';
|
||||
|
||||
@Injectable()
|
||||
export class UsedAltchaStoreService {
|
||||
private readonly entries = new Map<string, number>();
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
tryConsume(altcha: string): boolean {
|
||||
const key = createHash('sha256').update(altcha, 'utf8').digest('hex');
|
||||
|
||||
if (this.entries.has(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { challengeTtlMs } = this.configService.get('altcha') as Config['altcha'];
|
||||
|
||||
this.entries.set(key, dayjs().add(challengeTtlMs, 'millisecond').valueOf());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
pruneExpired(): void {
|
||||
const now = dayjs();
|
||||
|
||||
for (const [key, expiresAt] of this.entries) {
|
||||
if (now.isAfter(expiresAt)) {
|
||||
this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum AltchaVerificationStatus {
|
||||
Ok = 'ok',
|
||||
Missing = 'missing',
|
||||
Invalid = 'invalid',
|
||||
AlreadyUsed = 'already_used'
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export enum HttpMethod {
|
||||
Get = 'GET',
|
||||
Head = 'HEAD',
|
||||
Post = 'POST',
|
||||
Put = 'PUT',
|
||||
Patch = 'PATCH',
|
||||
Delete = 'DELETE',
|
||||
Options = 'OPTIONS'
|
||||
}
|
||||
Reference in New Issue
Block a user