chore: add domain verification system

This commit is contained in:
Mahyargdz
2025-05-20 17:01:46 +03:30
parent ed4a5ffff5
commit 858f55bb0b
7 changed files with 410 additions and 4 deletions
+7
View File
@@ -592,4 +592,11 @@ export const enum BusinessMessage {
BUSINESS_ID_REQUIRED = "شناسه کسب و کار مورد نیاز است", BUSINESS_ID_REQUIRED = "شناسه کسب و کار مورد نیاز است",
SLUG_REQUIRED = "اسلاگ مورد نیاز است", SLUG_REQUIRED = "اسلاگ مورد نیاز است",
SLUG_MUST_BE_STRING = "اسلاگ باید یک رشته باشد", SLUG_MUST_BE_STRING = "اسلاگ باید یک رشته باشد",
DOMAIN_INVALID = "دامنه وارد شده معتبر نیست",
DOMAIN_UPDATED = "دامنه با موفقیت به‌روزرسانی شد",
DOMAIN_VERIFICATION_INITIATED = "فرآیند تایید دامنه آغاز شد",
DOMAIN_VERIFICATION_FAILED = "تایید دامنه با خطا مواجه شد",
DOMAIN_VERIFICATION_SUCCESS = "دامنه با موفقیت تایید شد",
DOMAIN_VERIFICATION_METHOD_INVALID = "روش تایید دامنه نامعتبر است",
DOMAIN_ALREADY_VERIFIED = "این دامنه قبلا تایید شده است",
} }
@@ -0,0 +1,26 @@
import { IsEnum, IsNotEmpty, IsOptional, IsString, Matches } from "class-validator";
export enum DomainVerificationMethod {
DNS_TXT = "DNS_TXT",
FILE = "FILE",
META_TAG = "META_TAG",
}
export class UpdateBusinessDomainDto {
@IsNotEmpty()
@IsString()
@Matches(/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, {
message: "Domain must be a valid domain name (e.g., example.com)",
})
domain: string;
}
export class VerifyDomainDto {
@IsNotEmpty()
@IsEnum(DomainVerificationMethod)
method: DomainVerificationMethod;
@IsOptional()
@IsString()
businessId?: string;
}
@@ -1,14 +1,51 @@
import { Controller, Get, Param } from "@nestjs/common"; import { Body, Controller, Get, Param, Post } from "@nestjs/common";
import { UpdateBusinessDomainDto, VerifyDomainDto } from "./DTO/business-domain.dto";
import { BusinessSlugParamDto } from "./DTO/business-slug-param.dto"; import { BusinessSlugParamDto } from "./DTO/business-slug-param.dto";
import { Business } from "./entities/business.entity";
import { BusinessesService } from "./services/businesses.service"; import { BusinessesService } from "./services/businesses.service";
import { DomainVerificationService } from "./services/domain-verification.service";
import { BusinessDec } from "../../common/decorators/business.decorator";
@Controller("business") @Controller("business")
export class BusinessesController { export class BusinessesController {
constructor(private readonly businessesService: BusinessesService) {} constructor(
private readonly businessesService: BusinessesService,
private readonly domainVerificationService: DomainVerificationService,
) {}
@Get("slug/:slug") @Get("slug/:slug")
async getBusinessBySlug(@Param() params: BusinessSlugParamDto) { async getBusinessBySlug(@Param() params: BusinessSlugParamDto) {
return this.businessesService.getBusinessBySlug(params.slug); return this.businessesService.getBusinessBySlug(params.slug);
} }
@Post()
async updateBusinessDomain(@BusinessDec() business: Business, @Body() domainDto: UpdateBusinessDomainDto) {
return this.domainVerificationService.updateBusinessDomain(business.id, domainDto);
}
@Get("status")
async getDomainStatus(@BusinessDec() business: Business) {
return this.domainVerificationService.getDomainVerificationStatus(business.id);
}
@Post("verify")
async verifyDomain(@BusinessDec() business: Business, @Body() verifyDto: VerifyDomainDto) {
return this.domainVerificationService.initiateVerification(business.id, verifyDto);
}
@Post("verify/check")
async checkVerification(@BusinessDec() business: Business) {
return this.domainVerificationService.verifyDomain(business.id);
}
@Post("admin/:businessId/verify")
async adminVerifyDomain(@Param("businessId") businessId: string, @Body() verifyDto: VerifyDomainDto) {
return this.domainVerificationService.initiateVerification(businessId, verifyDto);
}
@Post("admin/:businessId/verify/check")
async adminCheckVerification(@Param("businessId") businessId: string) {
return this.domainVerificationService.verifyDomain(businessId);
}
} }
+2 -1
View File
@@ -7,6 +7,7 @@ import { SUBSCRIPTIONS } from "./constant";
import { Business } from "./entities/business.entity"; import { Business } from "./entities/business.entity";
import { BusinessProvisioningProcessor } from "./queue/business-provisioning.processor"; import { BusinessProvisioningProcessor } from "./queue/business-provisioning.processor";
import { BusinessesService } from "./services/businesses.service"; import { BusinessesService } from "./services/businesses.service";
import { DomainVerificationService } from "./services/domain-verification.service";
@Module({ @Module({
imports: [ imports: [
@@ -16,7 +17,7 @@ import { BusinessesService } from "./services/businesses.service";
prefix: SUBSCRIPTIONS.PROVISIONING_QUEUE_PREFIX, prefix: SUBSCRIPTIONS.PROVISIONING_QUEUE_PREFIX,
}), }),
], ],
providers: [BusinessesService, BusinessProvisioningProcessor], providers: [BusinessesService, BusinessProvisioningProcessor, DomainVerificationService],
exports: [BusinessesService], exports: [BusinessesService],
controllers: [BusinessesController], controllers: [BusinessesController],
}) })
@@ -1,4 +1,4 @@
import { Cascade, Collection, Entity, EntityRepositoryType, OneToMany, Property, Unique } from "@mikro-orm/core"; import { Cascade, Collection, Entity, EntityRepositoryType, Enum, OneToMany, Opt, Property, Unique } from "@mikro-orm/core";
import { BaseEntity } from "../../../common/entities/base.entity"; import { BaseEntity } from "../../../common/entities/base.entity";
import { Announcement } from "../../announcements/entities/announcement.entity"; import { Announcement } from "../../announcements/entities/announcement.entity";
@@ -9,6 +9,7 @@ import { Invoice } from "../../invoices/entities/invoice.entity";
import { TicketCategory } from "../../tickets/entities/ticket-category.entity"; import { TicketCategory } from "../../tickets/entities/ticket-category.entity";
import { Ticket } from "../../tickets/entities/ticket.entity"; import { Ticket } from "../../tickets/entities/ticket.entity";
import { User } from "../../users/entities/user.entity"; import { User } from "../../users/entities/user.entity";
import { DomainVerificationMethod } from "../DTO/business-domain.dto";
import { BusinessRepository } from "../repositories/business.repository"; import { BusinessRepository } from "../repositories/business.repository";
@Entity({ repository: () => BusinessRepository }) @Entity({ repository: () => BusinessRepository })
@@ -26,6 +27,18 @@ export class Business extends BaseEntity {
@Property({ type: "varchar", length: 255, nullable: true }) @Property({ type: "varchar", length: 255, nullable: true })
domain?: string; domain?: string;
@Property({ type: "boolean", default: false })
isDomainVerified: boolean & Opt;
@Enum({ items: () => DomainVerificationMethod, nullable: true })
domainVerificationMethod?: DomainVerificationMethod;
@Property({ type: "varchar", length: 255, nullable: true })
domainVerificationToken?: string;
@Property({ type: "datetime", nullable: true })
domainVerifiedAt?: Date;
@Property({ type: "varchar", length: 255, nullable: true }) @Property({ type: "varchar", length: 255, nullable: true })
logoUrl?: string; logoUrl?: string;
@@ -20,4 +20,15 @@ export class BusinessesService {
return { business }; return { business };
} }
async getBusinessById(id: string) {
const business = await this.businessRepository.findOne({ id, deletedAt: null });
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
return business;
}
async flushChanges() {
await this.businessRepository.getEntityManager().flush();
}
} }
@@ -0,0 +1,311 @@
import * as crypto from "node:crypto";
import * as dns from "node:dns";
import { promisify } from "node:util";
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import axios from "axios";
import { BusinessesService } from "./businesses.service";
import { BusinessMessage } from "../../../common/enums/message.enum";
import { DomainVerificationMethod, UpdateBusinessDomainDto, VerifyDomainDto } from "../DTO/business-domain.dto";
@Injectable()
export class DomainVerificationService {
private readonly logger = new Logger(DomainVerificationService.name);
private readonly resolveTxt = promisify(dns.resolveTxt);
constructor(private readonly businessesService: BusinessesService) {}
/**
* Update business domain
*/
async updateBusinessDomain(businessId: string, domainDto: UpdateBusinessDomainDto) {
const business = await this.businessesService.getBusinessById(businessId);
// Domain hasn't changed, check if it's already verified
if (business.domain === domainDto.domain && business.isDomainVerified) {
return {
message: BusinessMessage.DOMAIN_ALREADY_VERIFIED,
domain: business.domain,
isVerified: business.isDomainVerified,
};
}
// Generate verification token if domain changed or not verified
const verificationToken = this.generateVerificationToken();
// Update the domain and verification data
business.domain = domainDto.domain;
business.isDomainVerified = false;
business.domainVerificationToken = verificationToken;
business.domainVerifiedAt = undefined;
business.domainVerificationMethod = undefined;
await this.businessesService.flushChanges();
return {
message: BusinessMessage.DOMAIN_UPDATED,
domain: business.domain,
isVerified: business.isDomainVerified,
verificationMethods: this.getVerificationInstructions(business.domain, verificationToken),
};
}
/**
* Initiate domain verification with specific method
*/
async initiateVerification(businessId: string, verifyDto: VerifyDomainDto) {
const business = await this.businessesService.getBusinessById(businessId);
if (!business.domain) {
throw new BadRequestException(BusinessMessage.DOMAIN_INVALID);
}
if (business.isDomainVerified) {
return {
message: BusinessMessage.DOMAIN_ALREADY_VERIFIED,
domain: business.domain,
isVerified: true,
verifiedAt: business.domainVerifiedAt,
};
}
// If no token exists, generate one
if (!business.domainVerificationToken) {
business.domainVerificationToken = this.generateVerificationToken();
await this.businessesService.flushChanges();
}
// Update verification method
business.domainVerificationMethod = verifyDto.method;
await this.businessesService.flushChanges();
return {
message: BusinessMessage.DOMAIN_VERIFICATION_INITIATED,
domain: business.domain,
method: verifyDto.method,
instructions: this.getVerificationInstructions(business.domain, business.domainVerificationToken)[verifyDto.method],
};
}
/**
* Check domain verification status
*/
async verifyDomain(businessId: string) {
const business = await this.businessesService.getBusinessById(businessId);
if (!business.domain || !business.domainVerificationToken || !business.domainVerificationMethod) {
throw new BadRequestException(BusinessMessage.DOMAIN_VERIFICATION_METHOD_INVALID);
}
if (business.isDomainVerified) {
return {
message: BusinessMessage.DOMAIN_ALREADY_VERIFIED,
domain: business.domain,
isVerified: true,
verifiedAt: business.domainVerifiedAt,
};
}
try {
let isVerified = false;
switch (business.domainVerificationMethod) {
case DomainVerificationMethod.DNS_TXT:
isVerified = await this.verifyDnsTxtRecord(business.domain, business.domainVerificationToken);
break;
case DomainVerificationMethod.FILE:
isVerified = await this.verifyFileMethod(business.domain, business.domainVerificationToken);
break;
case DomainVerificationMethod.META_TAG:
isVerified = await this.verifyMetaTagMethod(business.domain, business.domainVerificationToken);
break;
default:
throw new BadRequestException(BusinessMessage.DOMAIN_VERIFICATION_METHOD_INVALID);
}
if (isVerified) {
business.isDomainVerified = true;
business.domainVerifiedAt = new Date();
await this.businessesService.flushChanges();
return {
message: BusinessMessage.DOMAIN_VERIFICATION_SUCCESS,
domain: business.domain,
isVerified: true,
verifiedAt: business.domainVerifiedAt,
};
} else {
const verificationMethod = business.domainVerificationMethod;
return {
message: BusinessMessage.DOMAIN_VERIFICATION_FAILED,
domain: business.domain,
isVerified: false,
instructions: this.getVerificationInstructions(business.domain, business.domainVerificationToken)[verificationMethod],
};
}
} catch (error: unknown) {
this.logger.error(`Domain verification failed: ${error instanceof Error ? error.message : String(error)}`);
throw new BadRequestException(BusinessMessage.DOMAIN_VERIFICATION_FAILED);
}
}
/**
* Get domain verification status
*/
async getDomainVerificationStatus(businessId: string) {
const business = await this.businessesService.getBusinessById(businessId);
if (!business.domain) {
return {
hasDomain: false,
};
}
return {
hasDomain: true,
domain: business.domain,
isVerified: business.isDomainVerified,
verificationMethod: business.domainVerificationMethod,
verifiedAt: business.domainVerifiedAt,
instructions: business.domainVerificationToken ? this.getVerificationInstructions(business.domain, business.domainVerificationToken) : null,
};
}
/**
* Generate a random verification token
*/
private generateVerificationToken(): string {
return `dzone-verify-${crypto.randomBytes(16).toString("hex")}`;
}
/**
* Get verification instructions for all methods
*/
private getVerificationInstructions(domain: string, token: string) {
return {
[DomainVerificationMethod.DNS_TXT]: {
title: "Verify using DNS TXT record",
description: "Add a TXT record to your domain with the following values:",
host: "@", // or "_dzone-verification"
type: "TXT",
value: token,
},
[DomainVerificationMethod.FILE]: {
title: "Verify using a file",
description: "Create a file at the root of your domain with the following properties:",
path: `/.well-known/${token}.html`,
content: token,
fullUrl: `https://${domain}/.well-known/${token}.html`,
},
[DomainVerificationMethod.META_TAG]: {
title: "Verify using meta tag",
description: "Add the following meta tag to the <head> section of your homepage:",
tag: `<meta name="dzone-verification" content="${token}">`,
},
};
}
/**
* Verify domain using DNS TXT record
*/
private async verifyDnsTxtRecord(domain: string, token: string): Promise<boolean> {
try {
const txtRecords = await this.resolveTxt(domain);
// Flatten the results and check if any match our token
const records = txtRecords.flat();
return records.some((record) => record === token);
} catch (error: unknown) {
this.logger.error(`Error verifying DNS TXT record: ${error instanceof Error ? error.message : String(error)}`);
return false;
}
}
/**
* Verify domain using file method
*/
private async verifyFileMethod(domain: string, token: string): Promise<boolean> {
try {
// Try HTTPS first
try {
const response = await axios.get(`https://${domain}/.well-known/${token}.html`, {
timeout: 5000, // 5 seconds timeout
headers: {
"User-Agent": "Mozilla/5.0 (compatible; DzoneVerification/1.0; +https://dzone.com/domain-verification)",
},
});
if (response.status === 200 && response.data.toString().trim() === token.trim()) {
return true;
}
} catch (error) {
this.logger.debug(`HTTPS file verification failed, trying HTTP: ${error instanceof Error ? error.message : String(error)}`);
}
// Try HTTP if HTTPS fails
const httpResponse = await axios.get(`http://${domain}/.well-known/${token}.html`, {
timeout: 5000,
headers: {
"User-Agent": "Mozilla/5.0 (compatible; DzoneVerification/1.0; +https://dzone.com/domain-verification)",
},
});
if (httpResponse.status === 200 && httpResponse.data.toString().trim() === token.trim()) {
return true;
}
return false;
} catch (error: unknown) {
this.logger.error(`Error verifying file method: ${error instanceof Error ? error.message : String(error)}`);
return false;
}
}
/**
* Verify domain using meta tag method
*/
private async verifyMetaTagMethod(domain: string, token: string): Promise<boolean> {
try {
// Try HTTPS first
try {
const response = await axios.get(`https://${domain}`, {
timeout: 5000, // 5 seconds timeout
headers: {
"User-Agent": "Mozilla/5.0 (compatible; DzoneVerification/1.0; +https://dzone.com/domain-verification)",
},
});
if (response.status === 200) {
// Check for meta tag in the HTML
const content = response.data.toString();
const metaTagRegex = new RegExp(`<meta[^>]*name=["']dzone-verification["'][^>]*content=["']${token}["']`, "i");
if (metaTagRegex.test(content)) {
return true;
}
}
} catch (error) {
this.logger.debug(`HTTPS meta tag verification failed, trying HTTP: ${error instanceof Error ? error.message : String(error)}`);
}
// Try HTTP if HTTPS fails
const httpResponse = await axios.get(`http://${domain}`, {
timeout: 5000,
headers: {
"User-Agent": "Mozilla/5.0 (compatible; DzoneVerification/1.0; +https://dzone.com/domain-verification)",
},
});
if (httpResponse.status === 200) {
// Check for meta tag in the HTML
const content = httpResponse.data.toString();
const metaTagRegex = new RegExp(`<meta[^>]*name=["']dzone-verification["'][^>]*content=["']${token}["']`, "i");
return metaTagRegex.test(content);
}
return false;
} catch (error: unknown) {
this.logger.error(`Error verifying meta tag method: ${error instanceof Error ? error.message : String(error)}`);
return false;
}
}
}