1
0
forked from Yara724/api
This commit is contained in:
SepehrYahyaee
2026-05-24 10:10:17 +03:30
parent 91221a6848
commit af875a4773
20 changed files with 439 additions and 9 deletions

View File

@@ -77,14 +77,14 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
provide: APP_INTERCEPTOR,
useClass: UnicodeDigitsNormalizeInterceptor,
},
{
provide: APP_PIPE,
useValue: new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: false,
}),
},
// {
// provide: APP_PIPE,
// useValue: new ValidationPipe({
// transform: true,
// whitelist: true,
// forbidNonWhitelisted: false,
// }),
// },
],
})
export class AppModule {}

View File

@@ -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",
},
},
},

View File

@@ -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);
}

View 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;
}
}
}

View 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));
}

View File

@@ -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,

View File

@@ -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 {

View 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;
}

View 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";
}

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { CaptchaService } from "./captcha.service";
@Module({
providers: [CaptchaService],
exports: [CaptchaService],
})
export class CaptchaModule {}

View File

@@ -0,0 +1,42 @@
import { Injectable } from "@nestjs/common";
import * as svgCaptcha from "svg-captcha";
export interface GeneratedCaptcha {
text: string;
image: string;
expiresAt: number;
}
@Injectable()
export class CaptchaService {
generate(): GeneratedCaptcha {
const captcha = svgCaptcha.create({
size: 5,
ignoreChars: "0oO1ilI",
noise: 3,
color: true,
background: "#f8fafc",
width: 160,
height: 56,
fontSize: 52,
});
return {
text: captcha.text,
image: `data:image/svg+xml;base64,${Buffer.from(captcha.data, "utf8").toString("base64")}`,
expiresAt: this.buildExpireAt(),
};
}
normalizeAnswer(input: string): string {
return input.trim().toLowerCase();
}
buildExpireAt(): number {
const raw = Number(
process.env.EXP_CAPTCHA_TIME ?? process.env.EXP_OTP_TIME ?? "2",
);
const minutes = Number.isFinite(raw) && raw > 0 ? raw : 2;
return Date.now() + minutes * 60 * 1000;
}
}

21
src/types/svg-captcha.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
declare module "svg-captcha" {
export interface CaptchaOptions {
size?: number;
ignoreChars?: string;
noise?: number;
color?: boolean;
background?: string;
width?: number;
height?: number;
fontSize?: number;
charPreset?: string;
}
export interface CaptchaResult {
data: string;
text: string;
}
export function create(options?: CaptchaOptions): CaptchaResult;
export function createMathExpr(options?: CaptchaOptions): CaptchaResult;
}

View File

@@ -150,6 +150,12 @@ export class DamageExpertModel {
@Prop({ type: "string" })
otp: string;
@Prop({ default: null })
captcha?: string | null;
@Prop({ default: 0 })
captchaExpire?: number;
@Prop({ type: "string" })
address: string;

View File

@@ -72,6 +72,12 @@ export class ExpertModel {
@Prop({ type: "string" })
otp: string;
@Prop({ default: null })
captcha?: string | null;
@Prop({ default: 0 })
captchaExpire?: number;
createdAt: Date;
}

View File

@@ -37,6 +37,12 @@ export class FieldExpertModel {
@Prop({ type: "string", default: "" })
otp: string;
@Prop({ default: null })
captcha?: string | null;
@Prop({ default: 0 })
captchaExpire?: number;
createdAt: Date;
}

View File

@@ -41,6 +41,12 @@ export class InsurerExpertModel {
@Prop()
address?: string;
@Prop({ default: null })
captcha?: string | null;
@Prop({ default: 0 })
captchaExpire?: number;
createdAt: Date;
}
export const InsurerExpertDbSchema =

View File

@@ -27,6 +27,12 @@ export class RegistrarModel {
@Prop({ type: "string", default: "" })
otp: string;
@Prop({ default: null })
captcha?: string | null;
@Prop({ default: 0 })
captchaExpire?: number;
}
export const RegistrarDbSchema = SchemaFactory.createForClass(RegistrarModel);