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 { constructor( private readonly captchaService: CaptchaService, private readonly hashService: HashService, private readonly captchaChallengeDbService: CaptchaChallengeDbService, private readonly configService: ConfigService, ) {} async issue(): Promise { const generated = this.captchaService.generate(); const captchaId = randomUUID(); if (this.configService.get("NODE_ENV") === "development") { const answerHash = await this.hashService.hash( this.captchaService.normalizeAnswer(generated.text), ); const result = { captchaId, answerHash: this.configService.get("NODE_ENV") === "development" ? answerHash : "", image: generated.image, expiresAt: generated.expiresAt, expireAt: new Date(generated.expiresAt), usedAt: null, }; } const result = { captchaId, image: generated.image, expiresAt: generated.expiresAt, expireAt: new Date(generated.expiresAt), usedAt: null, }; await this.captchaChallengeDbService.create(result); return { captchaId, text: generated.text /* TODO: REMOVE THIS FOR PROD*/, image: generated.image, expiresAt: generated.expiresAt, }; } 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"); } }