forked from Yara724/api
Merge pull request 'FIX Captcha' (#82) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#82
This commit is contained in:
@@ -20,9 +20,9 @@ import {
|
|||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
||||||
import { CaptchaAccountService } from "src/auth/auth-services/captcha-account.service";
|
import { CaptchaChallengeService } from "src/captcha/captcha-challenge.service";
|
||||||
import { CaptchaResponseDto } from "src/auth/dto/captcha-response.dto";
|
import { CaptchaResponseDto } from "src/auth/dto/captcha-response.dto";
|
||||||
import { GetActorCaptchaQueryDto } from "src/auth/dto/get-actor-captcha.dto";
|
import { GetCaptchaImageQueryDto } from "src/auth/dto/get-captcha-image-query.dto";
|
||||||
import {
|
import {
|
||||||
ForgetPasswordSendCodeDto,
|
ForgetPasswordSendCodeDto,
|
||||||
ForgetPasswordVerifyCodeDto,
|
ForgetPasswordVerifyCodeDto,
|
||||||
@@ -45,33 +45,29 @@ import { CurrentUser } from "src/decorators/user.decorator";
|
|||||||
export class ActorAuthController {
|
export class ActorAuthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly actorAuthService: ActorAuthService,
|
private readonly actorAuthService: ActorAuthService,
|
||||||
private readonly captchaAccountService: CaptchaAccountService,
|
private readonly captchaChallengeService: CaptchaChallengeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get("captcha")
|
@Get("captcha")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get login captcha for an actor account",
|
summary: "Get a login captcha",
|
||||||
description:
|
description:
|
||||||
"Returns an SVG captcha stored on the actor record.\n\n" +
|
"Issues a new captcha challenge. Returns `captchaId`, `image`, and `expiresAt`. " +
|
||||||
"**Viewing the image:** Swagger displays `image` as plain text. To see the captcha:\n" +
|
"Send `captchaId` and the typed characters as `captcha` on POST /actor/login.\n\n" +
|
||||||
"- Add `format=raw` and open the request URL in a new browser tab, or\n" +
|
"Optional `format=raw` returns image/svg+xml for browser preview (same captchaId is in JSON when omitted).",
|
||||||
"- Copy the full `image` value from the JSON response and paste it into the browser address bar, or\n" +
|
|
||||||
"- In your app: `<img src={response.image} alt=\"captcha\" />`\n\n" +
|
|
||||||
"Type the characters shown in the image into the `captcha` field on POST /actor/login.",
|
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 200, type: CaptchaResponseDto })
|
@ApiResponse({ status: 200, type: CaptchaResponseDto })
|
||||||
async getCaptcha(
|
async getCaptcha(
|
||||||
@Query() query: GetActorCaptchaQueryDto,
|
@Query() query: GetCaptchaImageQueryDto,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
) {
|
) {
|
||||||
const result = await this.captchaAccountService.issueActorCaptcha(
|
const result = await this.captchaChallengeService.issue();
|
||||||
query.username,
|
|
||||||
query.role,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (query.format === "raw") {
|
if (query.format === "raw") {
|
||||||
const base64 = result.image.replace(/^data:image\/svg\+xml;base64,/, "");
|
const svg = await this.captchaChallengeService.getImageById(
|
||||||
const svg = Buffer.from(base64, "base64").toString("utf8");
|
result.captchaId,
|
||||||
|
);
|
||||||
|
res.setHeader("X-Captcha-Id", result.captchaId);
|
||||||
res.type("image/svg+xml");
|
res.type("image/svg+xml");
|
||||||
res.send(svg);
|
res.send(svg);
|
||||||
return;
|
return;
|
||||||
@@ -80,6 +76,20 @@ export class ActorAuthController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("captcha/:captchaId/image")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "View captcha image by id",
|
||||||
|
description: "Returns raw SVG for a previously issued captcha challenge.",
|
||||||
|
})
|
||||||
|
async getCaptchaImage(
|
||||||
|
@Param("captchaId") captchaId: string,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
const svg = await this.captchaChallengeService.getImageById(captchaId);
|
||||||
|
res.type("image/svg+xml");
|
||||||
|
res.send(svg);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated Use the unified actor onboarding flow instead. This endpoint
|
* @deprecated Use the unified actor onboarding flow instead. This endpoint
|
||||||
* will be removed in a future release.
|
* will be removed in a future release.
|
||||||
@@ -154,6 +164,7 @@ export class ActorAuthController {
|
|||||||
role: "company",
|
role: "company",
|
||||||
username: "saman_insurer@gmail.com",
|
username: "saman_insurer@gmail.com",
|
||||||
password: "123321",
|
password: "123321",
|
||||||
|
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||||
captcha: "a7bx2",
|
captcha: "a7bx2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -164,6 +175,7 @@ export class ActorAuthController {
|
|||||||
role: "expert",
|
role: "expert",
|
||||||
username: "blame@gmail.com",
|
username: "blame@gmail.com",
|
||||||
password: "123321",
|
password: "123321",
|
||||||
|
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||||
captcha: "a7bx2",
|
captcha: "a7bx2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -174,6 +186,7 @@ export class ActorAuthController {
|
|||||||
role: "damage_expert",
|
role: "damage_expert",
|
||||||
username: "claim@gmail.com",
|
username: "claim@gmail.com",
|
||||||
password: "123321",
|
password: "123321",
|
||||||
|
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||||
captcha: "a7bx2",
|
captcha: "a7bx2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
RegisterDtoRs,
|
RegisterDtoRs,
|
||||||
} from "src/auth/dto/actor/register.actor.dto";
|
} from "src/auth/dto/actor/register.actor.dto";
|
||||||
import { StateListDtoRs } from "src/auth/dto/actor/states.dto";
|
import { StateListDtoRs } from "src/auth/dto/actor/states.dto";
|
||||||
import { CaptchaAccountService } from "src/auth/auth-services/captcha-account.service";
|
import { CaptchaChallengeService } from "src/captcha/captcha-challenge.service";
|
||||||
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
|
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import { UserType } from "src/Types&Enums/userType.enum";
|
import { UserType } from "src/Types&Enums/userType.enum";
|
||||||
@@ -53,7 +53,7 @@ export class ActorAuthService {
|
|||||||
// private readonly mailService: MailService, // Mailer disabled – not used
|
// private readonly mailService: MailService, // Mailer disabled – not used
|
||||||
private readonly clientDbService: ClientDbService,
|
private readonly clientDbService: ClientDbService,
|
||||||
private readonly otpService: OtpGeneratorService,
|
private readonly otpService: OtpGeneratorService,
|
||||||
private readonly captchaAccountService: CaptchaAccountService,
|
private readonly captchaChallengeService: CaptchaChallengeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// TODO convrt to class for dynamic controller
|
// TODO convrt to class for dynamic controller
|
||||||
@@ -178,13 +178,11 @@ export class ActorAuthService {
|
|||||||
if (typeof password !== "string" || !password) {
|
if (typeof password !== "string" || !password) {
|
||||||
throw new BadRequestException("password is required");
|
throw new BadRequestException("password is required");
|
||||||
}
|
}
|
||||||
|
const captchaId =
|
||||||
|
typeof body?.captchaId === "string" ? body.captchaId : undefined;
|
||||||
const captcha =
|
const captcha =
|
||||||
typeof body?.captcha === "string" ? body.captcha : undefined;
|
typeof body?.captcha === "string" ? body.captcha : undefined;
|
||||||
await this.captchaAccountService.verifyActorCaptcha(
|
await this.captchaChallengeService.verify(captchaId, captcha);
|
||||||
username,
|
|
||||||
role,
|
|
||||||
captcha,
|
|
||||||
);
|
|
||||||
const actor = await this.validateActor(username, password, role);
|
const actor = await this.validateActor(username, password, role);
|
||||||
return this.issueActorTokens(actor);
|
return this.issueActorTokens(actor);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
|
||||||
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 { RoleEnum } from "src/Types&Enums/role.enum";
|
|
||||||
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
|
||||||
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
|
|
||||||
import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service";
|
|
||||||
import { InsurerExpertDbService } from "src/users/entities/db-service/insurer-expert.db.service";
|
|
||||||
import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service";
|
|
||||||
import { HashService } from "src/utils/hash/hash.service";
|
|
||||||
|
|
||||||
type CaptchaRecord = {
|
|
||||||
captcha?: string | null;
|
|
||||||
captchaExpire?: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CaptchaAccountService {
|
|
||||||
constructor(
|
|
||||||
private readonly captchaService: CaptchaService,
|
|
||||||
private readonly hashService: HashService,
|
|
||||||
private readonly expertDbService: ExpertDbService,
|
|
||||||
private readonly damageExpertDbService: DamageExpertDbService,
|
|
||||||
private readonly fieldExpertDbService: FieldExpertDbService,
|
|
||||||
private readonly registrarDbService: RegistrarDbService,
|
|
||||||
private readonly insurerExpertDbService: InsurerExpertDbService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async issueActorCaptcha(
|
|
||||||
username: string,
|
|
||||||
role: RoleEnum,
|
|
||||||
): Promise<CaptchaResponseDto> {
|
|
||||||
const actor = await this.findActor(role, username);
|
|
||||||
if (!actor) {
|
|
||||||
throw new NotFoundException("Actor account not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const generated = this.captchaService.generate();
|
|
||||||
const captchaHash = await this.hashService.hash(
|
|
||||||
this.captchaService.normalizeAnswer(generated.text),
|
|
||||||
);
|
|
||||||
await this.updateActorCaptcha(role, username, captchaHash, generated.expiresAt);
|
|
||||||
|
|
||||||
return { image: generated.image, expiresAt: generated.expiresAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifyActorCaptcha(
|
|
||||||
username: string,
|
|
||||||
role: RoleEnum,
|
|
||||||
answer: string | undefined,
|
|
||||||
): Promise<void> {
|
|
||||||
const actor = await this.findActor(role, username);
|
|
||||||
if (!actor) {
|
|
||||||
throw new NotFoundException("Actor account not found");
|
|
||||||
}
|
|
||||||
await this.assertCaptcha(actor, answer);
|
|
||||||
await this.updateActorCaptcha(role, username, null, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertCaptcha(
|
|
||||||
record: CaptchaRecord | null | undefined,
|
|
||||||
answer: string | undefined,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!answer?.trim()) {
|
|
||||||
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_REQUIRED);
|
|
||||||
}
|
|
||||||
if (!record?.captcha) {
|
|
||||||
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_REQUIRED);
|
|
||||||
}
|
|
||||||
const now = Date.now();
|
|
||||||
if (!record.captchaExpire || record.captchaExpire < now) {
|
|
||||||
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_EXPIRED);
|
|
||||||
}
|
|
||||||
const ok = await this.hashService.compare(
|
|
||||||
this.captchaService.normalizeAnswer(answer),
|
|
||||||
record.captcha,
|
|
||||||
);
|
|
||||||
if (!ok) {
|
|
||||||
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_INVALID);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async findActor(role: RoleEnum, username: string) {
|
|
||||||
switch (role) {
|
|
||||||
case RoleEnum.EXPERT:
|
|
||||||
return this.expertDbService.findOne({ email: username });
|
|
||||||
case RoleEnum.DAMAGE_EXPERT:
|
|
||||||
return this.damageExpertDbService.findOne({ email: username });
|
|
||||||
case RoleEnum.FIELD_EXPERT:
|
|
||||||
return this.fieldExpertDbService.findOne({ email: username });
|
|
||||||
case RoleEnum.REGISTRAR:
|
|
||||||
return this.registrarDbService.findOne({ email: username });
|
|
||||||
case RoleEnum.COMPANY:
|
|
||||||
return this.insurerExpertDbService.findOne({ email: username });
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async updateActorCaptcha(
|
|
||||||
role: RoleEnum,
|
|
||||||
username: string,
|
|
||||||
captcha: string | null,
|
|
||||||
captchaExpire: number,
|
|
||||||
) {
|
|
||||||
const update = { captcha, captchaExpire };
|
|
||||||
switch (role) {
|
|
||||||
case RoleEnum.EXPERT:
|
|
||||||
return this.expertDbService.findOneAndUpdate({ email: username }, update);
|
|
||||||
case RoleEnum.DAMAGE_EXPERT:
|
|
||||||
return this.damageExpertDbService.findOneAndUpdate({ email: username }, update);
|
|
||||||
case RoleEnum.FIELD_EXPERT:
|
|
||||||
return this.fieldExpertDbService.findOneAndUpdate({ email: username }, update);
|
|
||||||
case RoleEnum.REGISTRAR:
|
|
||||||
return this.registrarDbService.findOneAndUpdate({ email: username }, update);
|
|
||||||
case RoleEnum.COMPANY:
|
|
||||||
return this.insurerExpertDbService.updateOne({ email: username }, update);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
|
|
||||||
export enum CaptchaAuthErrorCode {
|
export enum CaptchaAuthErrorCode {
|
||||||
CAPTCHA_REQUIRED = "CAPTCHA_REQUIRED",
|
CAPTCHA_REQUIRED = "CAPTCHA_REQUIRED",
|
||||||
|
CAPTCHA_NOT_FOUND = "CAPTCHA_NOT_FOUND",
|
||||||
CAPTCHA_EXPIRED = "CAPTCHA_EXPIRED",
|
CAPTCHA_EXPIRED = "CAPTCHA_EXPIRED",
|
||||||
CAPTCHA_INVALID = "CAPTCHA_INVALID",
|
CAPTCHA_INVALID = "CAPTCHA_INVALID",
|
||||||
}
|
}
|
||||||
@@ -12,6 +13,8 @@ export enum CaptchaAuthErrorCode {
|
|||||||
const messages: Record<CaptchaAuthErrorCode, string> = {
|
const messages: Record<CaptchaAuthErrorCode, string> = {
|
||||||
[CaptchaAuthErrorCode.CAPTCHA_REQUIRED]:
|
[CaptchaAuthErrorCode.CAPTCHA_REQUIRED]:
|
||||||
"Captcha is required. Request a new captcha image first.",
|
"Captcha is required. Request a new captcha image first.",
|
||||||
|
[CaptchaAuthErrorCode.CAPTCHA_NOT_FOUND]:
|
||||||
|
"Captcha id was not found. Request a new captcha image.",
|
||||||
[CaptchaAuthErrorCode.CAPTCHA_EXPIRED]:
|
[CaptchaAuthErrorCode.CAPTCHA_EXPIRED]:
|
||||||
"Captcha has expired. Request a new captcha image.",
|
"Captcha has expired. Request a new captcha image.",
|
||||||
[CaptchaAuthErrorCode.CAPTCHA_INVALID]: "Captcha is invalid.",
|
[CaptchaAuthErrorCode.CAPTCHA_INVALID]: "Captcha is invalid.",
|
||||||
@@ -25,7 +28,10 @@ export function captchaAuthErrorBody(code: CaptchaAuthErrorCode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function throwCaptchaAuthError(code: CaptchaAuthErrorCode): never {
|
export function throwCaptchaAuthError(code: CaptchaAuthErrorCode): never {
|
||||||
if (code === CaptchaAuthErrorCode.CAPTCHA_REQUIRED) {
|
if (
|
||||||
|
code === CaptchaAuthErrorCode.CAPTCHA_REQUIRED ||
|
||||||
|
code === CaptchaAuthErrorCode.CAPTCHA_NOT_FOUND
|
||||||
|
) {
|
||||||
throw new BadRequestException(captchaAuthErrorBody(code));
|
throw new BadRequestException(captchaAuthErrorBody(code));
|
||||||
}
|
}
|
||||||
throw new UnauthorizedException(captchaAuthErrorBody(code));
|
throw new UnauthorizedException(captchaAuthErrorBody(code));
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { UserAuthController } from "src/auth/auth-controllers/user/user.auth.con
|
|||||||
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
||||||
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
||||||
import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service";
|
import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service";
|
||||||
import { CaptchaAccountService } from "src/auth/auth-services/captcha-account.service";
|
|
||||||
import {
|
import {
|
||||||
ClaimRequestManagementModel,
|
ClaimRequestManagementModel,
|
||||||
ClaimRequestManagementSchema,
|
ClaimRequestManagementSchema,
|
||||||
@@ -55,7 +54,6 @@ import { CaptchaModule } from "src/captcha/captcha.module";
|
|||||||
providers: [
|
providers: [
|
||||||
UserAuthService,
|
UserAuthService,
|
||||||
UserLinkAccessService,
|
UserLinkAccessService,
|
||||||
CaptchaAccountService,
|
|
||||||
ActorAuthService,
|
ActorAuthService,
|
||||||
LocalStrategy,
|
LocalStrategy,
|
||||||
LocalActorStrategy,
|
LocalActorStrategy,
|
||||||
|
|||||||
@@ -12,9 +12,18 @@ export class LoginActorDto {
|
|||||||
@ApiProperty({})
|
@ApiProperty({})
|
||||||
password: string;
|
password: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||||
|
description: "Captcha id from GET /actor/captcha.",
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(64)
|
||||||
|
captchaId: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: "a7bx2",
|
example: "a7bx2",
|
||||||
description: "Captcha text from GET /actor/captcha (same username + role).",
|
description: "Characters shown in the captcha image.",
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
|
||||||
export class CaptchaResponseDto {
|
export class CaptchaResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Captcha challenge id — send back with POST /actor/login.",
|
||||||
|
example: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||||
|
})
|
||||||
|
captchaId: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
description:
|
||||||
"SVG captcha as a data URI — use as `<img src={image} />` in the frontend. " +
|
"SVG captcha as a data URI — use as `<img src={image} />` in the frontend.",
|
||||||
"Swagger shows this as text only; paste the full string into a browser tab, " +
|
|
||||||
"or call GET /actor/captcha?format=raw to view the image directly.",
|
|
||||||
example: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iLi4u",
|
example: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iLi4u",
|
||||||
})
|
})
|
||||||
image: string;
|
image: string;
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
|
||||||
import { IsEnum, IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
|
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
|
||||||
|
|
||||||
export class GetActorCaptchaQueryDto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: "expert@gmail.com",
|
|
||||||
description: "Actor email / username",
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@MaxLength(128)
|
|
||||||
username: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
enum: RoleEnum,
|
|
||||||
example: RoleEnum.EXPERT,
|
|
||||||
description: "Actor role",
|
|
||||||
})
|
|
||||||
@IsEnum(RoleEnum)
|
|
||||||
role: RoleEnum;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
enum: ["json", "raw"],
|
|
||||||
default: "json",
|
|
||||||
description:
|
|
||||||
"Use `raw` to open the captcha directly in the browser (image/svg+xml). " +
|
|
||||||
"Default `json` returns `{ image, expiresAt }` for the frontend.",
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["json", "raw"])
|
|
||||||
format?: "json" | "raw";
|
|
||||||
}
|
|
||||||
15
src/auth/dto/get-captcha-image-query.dto.ts
Normal file
15
src/auth/dto/get-captcha-image-query.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsIn, IsOptional } from "class-validator";
|
||||||
|
|
||||||
|
export class GetCaptchaImageQueryDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: ["json", "raw"],
|
||||||
|
default: "json",
|
||||||
|
description:
|
||||||
|
"On GET /actor/captcha, use `raw` to return image/svg+xml (for browser preview). " +
|
||||||
|
"The JSON response includes `captchaId` either way.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(["json", "raw"])
|
||||||
|
format?: "json" | "raw";
|
||||||
|
}
|
||||||
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 { 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 { CaptchaService } from "./captcha.service";
|
||||||
|
import { CaptchaChallengeDbService } from "./entities/db-service/captcha-challenge.db.service";
|
||||||
|
import {
|
||||||
|
CaptchaChallenge,
|
||||||
|
CaptchaChallengeSchema,
|
||||||
|
} from "./entities/schema/captcha-challenge.schema";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [CaptchaService],
|
imports: [
|
||||||
exports: [CaptchaService],
|
HashModule,
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{ name: CaptchaChallenge.name, schema: CaptchaChallengeSchema },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
CaptchaService,
|
||||||
|
CaptchaChallengeDbService,
|
||||||
|
CaptchaChallengeService,
|
||||||
|
],
|
||||||
|
exports: [CaptchaChallengeService],
|
||||||
})
|
})
|
||||||
export class CaptchaModule {}
|
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);
|
||||||
@@ -150,12 +150,6 @@ export class DamageExpertModel {
|
|||||||
@Prop({ type: "string" })
|
@Prop({ type: "string" })
|
||||||
otp: string;
|
otp: string;
|
||||||
|
|
||||||
@Prop({ default: null })
|
|
||||||
captcha?: string | null;
|
|
||||||
|
|
||||||
@Prop({ default: 0 })
|
|
||||||
captchaExpire?: number;
|
|
||||||
|
|
||||||
@Prop({ type: "string" })
|
@Prop({ type: "string" })
|
||||||
address: string;
|
address: string;
|
||||||
|
|
||||||
|
|||||||
@@ -72,13 +72,6 @@ export class ExpertModel {
|
|||||||
@Prop({ type: "string" })
|
@Prop({ type: "string" })
|
||||||
otp: string;
|
otp: string;
|
||||||
|
|
||||||
@Prop({ default: null })
|
|
||||||
captcha?: string | null;
|
|
||||||
|
|
||||||
@Prop({ default: 0 })
|
|
||||||
captchaExpire?: number;
|
|
||||||
|
|
||||||
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,12 +37,6 @@ export class FieldExpertModel {
|
|||||||
@Prop({ type: "string", default: "" })
|
@Prop({ type: "string", default: "" })
|
||||||
otp: string;
|
otp: string;
|
||||||
|
|
||||||
@Prop({ default: null })
|
|
||||||
captcha?: string | null;
|
|
||||||
|
|
||||||
@Prop({ default: 0 })
|
|
||||||
captchaExpire?: number;
|
|
||||||
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,12 +41,6 @@ export class InsurerExpertModel {
|
|||||||
@Prop()
|
@Prop()
|
||||||
address?: string;
|
address?: string;
|
||||||
|
|
||||||
@Prop({ default: null })
|
|
||||||
captcha?: string | null;
|
|
||||||
|
|
||||||
@Prop({ default: 0 })
|
|
||||||
captchaExpire?: number;
|
|
||||||
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
export const InsurerExpertDbSchema =
|
export const InsurerExpertDbSchema =
|
||||||
|
|||||||
@@ -27,12 +27,6 @@ export class RegistrarModel {
|
|||||||
|
|
||||||
@Prop({ type: "string", default: "" })
|
@Prop({ type: "string", default: "" })
|
||||||
otp: string;
|
otp: string;
|
||||||
|
|
||||||
@Prop({ default: null })
|
|
||||||
captcha?: string | null;
|
|
||||||
|
|
||||||
@Prop({ default: 0 })
|
|
||||||
captchaExpire?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RegistrarDbSchema = SchemaFactory.createForClass(RegistrarModel);
|
export const RegistrarDbSchema = SchemaFactory.createForClass(RegistrarModel);
|
||||||
|
|||||||
Reference in New Issue
Block a user