forked from Yara724/api
FIX Catcha
This commit is contained in:
88
src/captcha/captcha-challenge.service.ts
Normal file
88
src/captcha/captcha-challenge.service.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
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,
|
||||
) {}
|
||||
|
||||
async issue(): Promise<CaptchaResponseDto> {
|
||||
const generated = this.captchaService.generate();
|
||||
const captchaId = randomUUID();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,26 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { HashModule } from "src/utils/hash/hash.module";
|
||||
import { CaptchaChallengeService } from "./captcha-challenge.service";
|
||||
import { CaptchaService } from "./captcha.service";
|
||||
import { CaptchaChallengeDbService } from "./entities/db-service/captcha-challenge.db.service";
|
||||
import {
|
||||
CaptchaChallenge,
|
||||
CaptchaChallengeSchema,
|
||||
} from "./entities/schema/captcha-challenge.schema";
|
||||
|
||||
@Module({
|
||||
providers: [CaptchaService],
|
||||
exports: [CaptchaService],
|
||||
imports: [
|
||||
HashModule,
|
||||
MongooseModule.forFeature([
|
||||
{ name: CaptchaChallenge.name, schema: CaptchaChallengeSchema },
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
CaptchaService,
|
||||
CaptchaChallengeDbService,
|
||||
CaptchaChallengeService,
|
||||
],
|
||||
exports: [CaptchaChallengeService],
|
||||
})
|
||||
export class CaptchaModule {}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectModel } from "@nestjs/mongoose";
|
||||
import { Model } from "mongoose";
|
||||
import {
|
||||
CaptchaChallenge,
|
||||
CaptchaChallengeDocument,
|
||||
} from "../schema/captcha-challenge.schema";
|
||||
|
||||
@Injectable()
|
||||
export class CaptchaChallengeDbService {
|
||||
constructor(
|
||||
@InjectModel(CaptchaChallenge.name)
|
||||
private readonly captchaChallengeModel: Model<CaptchaChallengeDocument>,
|
||||
) {}
|
||||
|
||||
create(data: CaptchaChallenge): Promise<CaptchaChallengeDocument> {
|
||||
return this.captchaChallengeModel.create(data);
|
||||
}
|
||||
|
||||
findByCaptchaId(
|
||||
captchaId: string,
|
||||
): Promise<CaptchaChallengeDocument | null> {
|
||||
return this.captchaChallengeModel.findOne({ captchaId });
|
||||
}
|
||||
|
||||
markUsed(captchaId: string): Promise<CaptchaChallengeDocument | null> {
|
||||
return this.captchaChallengeModel.findOneAndUpdate(
|
||||
{ captchaId, usedAt: null },
|
||||
{ $set: { usedAt: new Date() } },
|
||||
{ new: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
28
src/captcha/entities/schema/captcha-challenge.schema.ts
Normal file
28
src/captcha/entities/schema/captcha-challenge.schema.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import { HydratedDocument } from "mongoose";
|
||||
|
||||
@Schema({ collection: "captcha-challenges", timestamps: true, versionKey: false })
|
||||
export class CaptchaChallenge {
|
||||
@Prop({ required: true, unique: true, index: true })
|
||||
captchaId: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
answerHash: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
image: string;
|
||||
|
||||
@Prop({ required: true, index: true })
|
||||
expiresAt: number;
|
||||
|
||||
/** Mongo TTL — document removed shortly after this time. */
|
||||
@Prop({ required: true, expires: 0 })
|
||||
expireAt: Date;
|
||||
|
||||
@Prop({ default: null })
|
||||
usedAt?: Date | null;
|
||||
}
|
||||
|
||||
export type CaptchaChallengeDocument = HydratedDocument<CaptchaChallenge>;
|
||||
export const CaptchaChallengeSchema =
|
||||
SchemaFactory.createForClass(CaptchaChallenge);
|
||||
Reference in New Issue
Block a user