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("NODE_ENV") === "development"; } async issue(): Promise { 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 { 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 { 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"); } }