Prefer exact tenant domain over shared nodeServer for remote HMAC auth.

Stops demo and shop rows that share one API URL from colliding, and skips phone-like adminUrl values when resolving X-Abshode-Domain.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-07 22:47:42 +03:30
co-authored by Cursor
parent dd212d01ed
commit 2d43ce6c29
+66 -33
View File
@@ -10,11 +10,21 @@ import { PrismaService } from '../prisma/prisma.service';
const MAX_SKEW_SECONDS = 300; const MAX_SKEW_SECONDS = 300;
type TenantHostRow = {
id: string;
nameFa: string;
domain: string | null;
domains: unknown;
adminUrl?: string | null;
appUrl?: string | null;
nodeServer?: string | null;
product: string;
isActive: boolean;
};
/** /**
* Auth for product → SuperAdmin SMS: * Auth for product → SuperAdmin SMS / plugin-store:
* 1) Preferred: shared HMAC + domain headers * 1) Preferred: shared HMAC + domain headers
* X-Abshode-Domain, X-Abshode-Timestamp, X-Abshode-Signature
* payload = `${timestamp}.${METHOD}.${canonicalPath}.${domain}`
* 2) Fallback: Authorization Bearer {Tenant.remoteApiToken} * 2) Fallback: Authorization Bearer {Tenant.remoteApiToken}
*/ */
@Injectable() @Injectable()
@@ -128,6 +138,9 @@ export class RemoteSmsAuthGuard implements CanActivate {
} }
private async findTenantByDomain(host: string) { private async findTenantByDomain(host: string) {
const normalizedHost = this.normalizeHost(host);
if (!normalizedHost) return null;
const tenants = await this.prisma.tenant.findMany({ const tenants = await this.prisma.tenant.findMany({
where: { isActive: true }, where: { isActive: true },
select: { select: {
@@ -143,44 +156,64 @@ export class RemoteSmsAuthGuard implements CanActivate {
}, },
}); });
const hit = tenants.find((row) => this.matchesHost(row, host)); let best: { row: TenantHostRow; score: number } | null = null;
if (!hit) return null; for (const row of tenants) {
const score = this.scoreHostMatch(row, normalizedHost);
if (score <= 0) continue;
if (!best || score > best.score) {
best = { row, score };
}
}
if (!best) return null;
return { return {
id: hit.id, id: best.row.id,
nameFa: hit.nameFa, nameFa: best.row.nameFa,
domain: hit.domain, domain: best.row.domain,
product: hit.product, product: best.row.product,
isActive: hit.isActive, isActive: best.row.isActive,
}; };
} }
private matchesHost( /**
tenant: { * امتیاز بالاتر = هویت دقیق‌تر نسخه.
domain: string | null; * nodeServer ضعیف است چون چند نسخه ممکن است یک API مشترک داشته باشند.
domains: unknown; */
adminUrl?: string | null; private scoreHostMatch(tenant: TenantHostRow, host: string): number {
appUrl?: string | null;
nodeServer?: string | null;
},
host: string,
) {
const hostNoWww = host.replace(/^www\./, ''); const hostNoWww = host.replace(/^www\./, '');
const values = [ const eq = (raw?: string | null) => {
tenant.domain, const normalized = this.normalizeHost(raw);
tenant.adminUrl, if (!normalized) return false;
tenant.appUrl, return (
tenant.nodeServer, normalized === host || normalized.replace(/^www\./, '') === hostNoWww
...this.readDomains(tenant.domains), );
]; };
for (const value of values) {
if (eq(tenant.domain)) return 100;
for (const alias of this.readDomains(tenant.domains)) {
if (eq(alias)) return 90;
}
if (eq(tenant.appUrl)) return 80;
// adminUrl گاهی موبایل است؛ فقط اگر شبیه میزبان باشد
if (this.looksLikeHostname(tenant.adminUrl) && eq(tenant.adminUrl)) {
return 50;
}
if (eq(tenant.nodeServer)) return 10;
return 0;
}
private looksLikeHostname(value?: string | null) {
const normalized = this.normalizeHost(value); const normalized = this.normalizeHost(value);
if (!normalized) continue; if (!normalized || !normalized.includes('.')) return false;
if (normalized === host || normalized.replace(/^www\./, '') === hostNoWww) { if (/^\d+(\.\d+)*$/.test(normalized)) return false;
if (/^09\d{9}$/.test(normalized)) return false;
return true; return true;
} }
}
return false;
}
private readDomains(raw: unknown): string[] { private readDomains(raw: unknown): string[] {
if (!raw) return []; if (!raw) return [];