Files
yara724api/src/captcha/captcha-challenge.service.ts
2026-05-31 16:15:55 +03:30

101 lines
3.1 KiB
TypeScript

import { Injectable, NotFoundException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { randomUUID } from "node:crypto";
import {
CaptchaAuthErrorCode,
throwCaptchaAuthError,
} from "src/auth/auth-services/captcha-auth.error";
import { CaptchaResponseDto } from "src/auth/dto/captcha-response.dto";
import { CaptchaService } from "src/captcha/captcha.service";
import { CaptchaChallengeDbService } from "src/captcha/entities/db-service/captcha-challenge.db.service";
import { HashService } from "src/utils/hash/hash.service";
@Injectable()
export class CaptchaChallengeService {
private readonly isDev: boolean;
constructor(
private readonly captchaService: CaptchaService,
private readonly hashService: HashService,
private readonly captchaChallengeDbService: CaptchaChallengeDbService,
private readonly configService: ConfigService,
) {
this.isDev = this.configService.get<string>("NODE_ENV") === "development";
}
async issue(): Promise<CaptchaResponseDto> {
const generated = this.captchaService.generate();
const captchaId = randomUUID();
// Always hash and persist the answer — the env check was the root bug
const answerHash = await this.hashService.hash(
this.captchaService.normalizeAnswer(generated.text),
);
await this.captchaChallengeDbService.create({
captchaId,
answerHash,
image: generated.image,
expiresAt: generated.expiresAt,
expireAt: new Date(generated.expiresAt),
usedAt: null,
});
return {
captchaId,
image: generated.image,
expiresAt: generated.expiresAt,
...(this.isDev && { text: generated.text }),
};
}
async getImageById(captchaId: string): Promise<string> {
const challenge =
await this.captchaChallengeDbService.findByCaptchaId(captchaId);
if (!challenge) {
throw new NotFoundException("Captcha not found");
}
return this.decodeImage(challenge.image);
}
async verify(
captchaId: string | undefined,
answer: string | undefined,
): Promise<void> {
if (!captchaId?.trim()) {
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_REQUIRED);
}
if (!answer?.trim()) {
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_REQUIRED);
}
const challenge = await this.captchaChallengeDbService.findByCaptchaId(
captchaId.trim(),
);
if (!challenge) {
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_NOT_FOUND);
}
if (challenge.usedAt) {
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_INVALID);
}
if (challenge.expiresAt < Date.now()) {
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_EXPIRED);
}
const ok = await this.hashService.compare(
this.captchaService.normalizeAnswer(answer),
challenge.answerHash,
);
if (!ok) {
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_INVALID);
}
await this.captchaChallengeDbService.markUsed(challenge.captchaId);
}
private decodeImage(imageDataUri: string): string {
const base64 = imageDataUri.replace(/^data:image\/svg\+xml;base64,/, "");
return Buffer.from(base64, "base64").toString("utf8");
}
}