forked from Yara724/api
113 lines
3.8 KiB
TypeScript
113 lines
3.8 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;
|
|
private readonly captchaEnabled: 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";
|
|
this.captchaEnabled = this.configService.get<string>("CAPTCHA_ENABLED") !== "false";
|
|
}
|
|
|
|
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),
|
|
);
|
|
|
|
// expireAt is the MongoDB TTL sentinel. The TTL reaper fires every ~60 s, so
|
|
// setting it equal to expiresAt means Mongo can delete the document up to 60 s
|
|
// BEFORE the application-level expiry check runs — causing the intermittent
|
|
// "captchaId not found" error under load. Adding a 120 s grace buffer ensures
|
|
// the document is always present when verify() runs its own expiresAt check.
|
|
await this.captchaChallengeDbService.create({
|
|
captchaId,
|
|
answerHash,
|
|
image: generated.image,
|
|
expiresAt: generated.expiresAt,
|
|
expireAt: new Date(generated.expiresAt + 120_000),
|
|
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> {
|
|
// Skip captcha verification if disabled via environment variable
|
|
if (!this.captchaEnabled) {
|
|
return;
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|