forked from Yara724/api
YARA-913
This commit is contained in:
@@ -5,9 +5,12 @@ import {
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import type { Response } from "express";
|
||||
import {
|
||||
ApiBody,
|
||||
ApiAcceptedResponse,
|
||||
@@ -17,6 +20,9 @@ import {
|
||||
ApiBearerAuth,
|
||||
} from "@nestjs/swagger";
|
||||
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
||||
import { CaptchaAccountService } from "src/auth/auth-services/captcha-account.service";
|
||||
import { CaptchaResponseDto } from "src/auth/dto/captcha-response.dto";
|
||||
import { GetActorCaptchaQueryDto } from "src/auth/dto/get-actor-captcha.dto";
|
||||
import {
|
||||
ForgetPasswordSendCodeDto,
|
||||
ForgetPasswordVerifyCodeDto,
|
||||
@@ -37,7 +43,42 @@ import { CurrentUser } from "src/decorators/user.decorator";
|
||||
@Controller("actor")
|
||||
@ApiTags("actor")
|
||||
export class ActorAuthController {
|
||||
constructor(private readonly actorAuthService: ActorAuthService) {}
|
||||
constructor(
|
||||
private readonly actorAuthService: ActorAuthService,
|
||||
private readonly captchaAccountService: CaptchaAccountService,
|
||||
) {}
|
||||
|
||||
@Get("captcha")
|
||||
@ApiOperation({
|
||||
summary: "Get login captcha for an actor account",
|
||||
description:
|
||||
"Returns an SVG captcha stored on the actor record.\n\n" +
|
||||
"**Viewing the image:** Swagger displays `image` as plain text. To see the captcha:\n" +
|
||||
"- Add `format=raw` and open the request URL in a new browser tab, or\n" +
|
||||
"- 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 })
|
||||
async getCaptcha(
|
||||
@Query() query: GetActorCaptchaQueryDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const result = await this.captchaAccountService.issueActorCaptcha(
|
||||
query.username,
|
||||
query.role,
|
||||
);
|
||||
|
||||
if (query.format === "raw") {
|
||||
const base64 = result.image.replace(/^data:image\/svg\+xml;base64,/, "");
|
||||
const svg = Buffer.from(base64, "base64").toString("utf8");
|
||||
res.type("image/svg+xml");
|
||||
res.send(svg);
|
||||
return;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the unified actor onboarding flow instead. This endpoint
|
||||
@@ -113,6 +154,7 @@ export class ActorAuthController {
|
||||
role: "company",
|
||||
username: "saman_insurer@gmail.com",
|
||||
password: "123321",
|
||||
captcha: "a7bx2",
|
||||
},
|
||||
},
|
||||
expert: {
|
||||
@@ -122,6 +164,7 @@ export class ActorAuthController {
|
||||
role: "expert",
|
||||
username: "blame@gmail.com",
|
||||
password: "123321",
|
||||
captcha: "a7bx2",
|
||||
},
|
||||
},
|
||||
damage_expert: {
|
||||
@@ -131,6 +174,7 @@ export class ActorAuthController {
|
||||
role: "damage_expert",
|
||||
username: "claim@gmail.com",
|
||||
password: "123321",
|
||||
captcha: "a7bx2",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
RegisterDtoRs,
|
||||
} from "src/auth/dto/actor/register.actor.dto";
|
||||
import { StateListDtoRs } from "src/auth/dto/actor/states.dto";
|
||||
import { CaptchaAccountService } from "src/auth/auth-services/captcha-account.service";
|
||||
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { UserType } from "src/Types&Enums/userType.enum";
|
||||
@@ -52,6 +53,7 @@ export class ActorAuthService {
|
||||
// private readonly mailService: MailService, // Mailer disabled – not used
|
||||
private readonly clientDbService: ClientDbService,
|
||||
private readonly otpService: OtpGeneratorService,
|
||||
private readonly captchaAccountService: CaptchaAccountService,
|
||||
) {}
|
||||
|
||||
// TODO convrt to class for dynamic controller
|
||||
@@ -176,6 +178,13 @@ export class ActorAuthService {
|
||||
if (typeof password !== "string" || !password) {
|
||||
throw new BadRequestException("password is required");
|
||||
}
|
||||
const captcha =
|
||||
typeof body?.captcha === "string" ? body.captcha : undefined;
|
||||
await this.captchaAccountService.verifyActorCaptcha(
|
||||
username,
|
||||
role,
|
||||
captcha,
|
||||
);
|
||||
const actor = await this.validateActor(username, password, role);
|
||||
return this.issueActorTokens(actor);
|
||||
}
|
||||
|
||||
126
src/auth/auth-services/captcha-account.service.ts
Normal file
126
src/auth/auth-services/captcha-account.service.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/auth/auth-services/captcha-auth.error.ts
Normal file
32
src/auth/auth-services/captcha-auth.error.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
|
||||
export enum CaptchaAuthErrorCode {
|
||||
CAPTCHA_REQUIRED = "CAPTCHA_REQUIRED",
|
||||
CAPTCHA_EXPIRED = "CAPTCHA_EXPIRED",
|
||||
CAPTCHA_INVALID = "CAPTCHA_INVALID",
|
||||
}
|
||||
|
||||
const messages: Record<CaptchaAuthErrorCode, string> = {
|
||||
[CaptchaAuthErrorCode.CAPTCHA_REQUIRED]:
|
||||
"Captcha is required. Request a new captcha image first.",
|
||||
[CaptchaAuthErrorCode.CAPTCHA_EXPIRED]:
|
||||
"Captcha has expired. Request a new captcha image.",
|
||||
[CaptchaAuthErrorCode.CAPTCHA_INVALID]: "Captcha is invalid.",
|
||||
};
|
||||
|
||||
export function captchaAuthErrorBody(code: CaptchaAuthErrorCode) {
|
||||
return {
|
||||
code,
|
||||
message: messages[code],
|
||||
};
|
||||
}
|
||||
|
||||
export function throwCaptchaAuthError(code: CaptchaAuthErrorCode): never {
|
||||
if (code === CaptchaAuthErrorCode.CAPTCHA_REQUIRED) {
|
||||
throw new BadRequestException(captchaAuthErrorBody(code));
|
||||
}
|
||||
throw new UnauthorizedException(captchaAuthErrorBody(code));
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { UserAuthController } from "src/auth/auth-controllers/user/user.auth.con
|
||||
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
||||
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
||||
import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service";
|
||||
import { CaptchaAccountService } from "src/auth/auth-services/captcha-account.service";
|
||||
import {
|
||||
ClaimRequestManagementModel,
|
||||
ClaimRequestManagementSchema,
|
||||
@@ -26,6 +27,7 @@ import { UsersModule } from "src/users/users.module";
|
||||
import { HashModule } from "src/utils/hash/hash.module";
|
||||
// import { MailModule } from "src/utils/mail/mail.module";
|
||||
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
||||
import { CaptchaModule } from "src/captcha/captcha.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -33,6 +35,7 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.
|
||||
UsersModule,
|
||||
ClientModule,
|
||||
HashModule,
|
||||
CaptchaModule,
|
||||
PassportModule,
|
||||
SmsOrchestrationModule,
|
||||
MongooseModule.forFeature([
|
||||
@@ -52,6 +55,7 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.
|
||||
providers: [
|
||||
UserAuthService,
|
||||
UserLinkAccessService,
|
||||
CaptchaAccountService,
|
||||
ActorAuthService,
|
||||
LocalStrategy,
|
||||
LocalActorStrategy,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsString, MaxLength } from "class-validator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
|
||||
export class LoginActorDto {
|
||||
@@ -10,6 +11,15 @@ export class LoginActorDto {
|
||||
|
||||
@ApiProperty({})
|
||||
password: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: "a7bx2",
|
||||
description: "Captcha text from GET /actor/captcha (same username + role).",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(16)
|
||||
captcha: string;
|
||||
}
|
||||
|
||||
export class LoginActorDtoRs extends LoginActorDto {
|
||||
|
||||
18
src/auth/dto/captcha-response.dto.ts
Normal file
18
src/auth/dto/captcha-response.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class CaptchaResponseDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"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",
|
||||
})
|
||||
image: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Unix timestamp (ms) when this captcha expires.",
|
||||
example: 1710000000000,
|
||||
})
|
||||
expiresAt: number;
|
||||
}
|
||||
33
src/auth/dto/get-actor-captcha.dto.ts
Normal file
33
src/auth/dto/get-actor-captcha.dto.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user