fix(plugins): match remote HMAC tenants with the same host rules as get_user
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,6 +7,10 @@ import {
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { createHmac, timingSafeEqual } from 'crypto';
|
import { createHmac, timingSafeEqual } from 'crypto';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
findTenantForHost,
|
||||||
|
normalizeTenantHost,
|
||||||
|
} from './tenant-host.util';
|
||||||
|
|
||||||
const MAX_SKEW_SECONDS = 300;
|
const MAX_SKEW_SECONDS = 300;
|
||||||
|
|
||||||
@@ -88,7 +92,7 @@ export class RemoteSmsAuthGuard implements CanActivate {
|
|||||||
throw new UnauthorizedException('timestamp منقضی است');
|
throw new UnauthorizedException('timestamp منقضی است');
|
||||||
}
|
}
|
||||||
|
|
||||||
const domain = this.normalizeHost(domainRaw);
|
const domain = normalizeTenantHost(domainRaw);
|
||||||
if (!domain) {
|
if (!domain) {
|
||||||
throw new UnauthorizedException('دامنه نامعتبر است');
|
throw new UnauthorizedException('دامنه نامعتبر است');
|
||||||
}
|
}
|
||||||
@@ -138,7 +142,7 @@ export class RemoteSmsAuthGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async findTenantByDomain(host: string) {
|
private async findTenantByDomain(host: string) {
|
||||||
const normalizedHost = this.normalizeHost(host);
|
const normalizedHost = normalizeTenantHost(host);
|
||||||
if (!normalizedHost) return null;
|
if (!normalizedHost) return null;
|
||||||
|
|
||||||
const tenants = await this.prisma.tenant.findMany({
|
const tenants = await this.prisma.tenant.findMany({
|
||||||
@@ -156,89 +160,18 @@ export class RemoteSmsAuthGuard implements CanActivate {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let best: { row: TenantHostRow; score: number } | null = null;
|
const hit = findTenantForHost(tenants as TenantHostRow[], normalizedHost);
|
||||||
for (const row of tenants) {
|
if (!hit) return null;
|
||||||
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: best.row.id,
|
id: hit.id,
|
||||||
nameFa: best.row.nameFa,
|
nameFa: hit.nameFa,
|
||||||
domain: best.row.domain,
|
domain: hit.domain,
|
||||||
product: best.row.product,
|
product: hit.product,
|
||||||
isActive: best.row.isActive,
|
isActive: hit.isActive,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* امتیاز بالاتر = هویت دقیقتر نسخه.
|
|
||||||
* nodeServer ضعیف است چون چند نسخه ممکن است یک API مشترک داشته باشند.
|
|
||||||
*/
|
|
||||||
private scoreHostMatch(tenant: TenantHostRow, host: string): number {
|
|
||||||
const hostNoWww = host.replace(/^www\./, '');
|
|
||||||
const eq = (raw?: string | null) => {
|
|
||||||
const normalized = this.normalizeHost(raw);
|
|
||||||
if (!normalized) return false;
|
|
||||||
return (
|
|
||||||
normalized === host || normalized.replace(/^www\./, '') === hostNoWww
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
if (!normalized || !normalized.includes('.')) return false;
|
|
||||||
if (/^\d+(\.\d+)*$/.test(normalized)) return false;
|
|
||||||
if (/^09\d{9}$/.test(normalized)) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readDomains(raw: unknown): string[] {
|
|
||||||
if (!raw) return [];
|
|
||||||
if (Array.isArray(raw)) return raw.map(String);
|
|
||||||
if (typeof raw === 'string') {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return Array.isArray(parsed) ? parsed.map(String) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeHost(value?: string | null) {
|
|
||||||
if (!value) return '';
|
|
||||||
return String(value)
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/^https?:\/\//, '')
|
|
||||||
.split('/')[0]
|
|
||||||
.split(':')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
private equalHex(a: string, b: string) {
|
private equalHex(a: string, b: string) {
|
||||||
try {
|
try {
|
||||||
const left = Buffer.from(String(a).toLowerCase(), 'utf8');
|
const left = Buffer.from(String(a).toLowerCase(), 'utf8');
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
export type TenantHostFields = {
|
||||||
|
domain?: string | null;
|
||||||
|
domains?: unknown;
|
||||||
|
adminUrl?: string | null;
|
||||||
|
appUrl?: string | null;
|
||||||
|
nodeServer?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeTenantHost(value?: string | null): string {
|
||||||
|
if (!value) return '';
|
||||||
|
return String(value)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^https?:\/\//, '')
|
||||||
|
.split('/')[0]
|
||||||
|
.split(':')[0]
|
||||||
|
.replace(/\.$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** aliases JSON array, object map, CSV, or a single host/URL */
|
||||||
|
export function readHostList(raw: unknown): string[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
if (Array.isArray(raw)) return raw.flatMap((item) => readHostList(item));
|
||||||
|
if (typeof raw === 'object') {
|
||||||
|
return Object.values(raw as Record<string, unknown>).flatMap(readHostList);
|
||||||
|
}
|
||||||
|
const text = String(raw).trim();
|
||||||
|
if (!text) return [];
|
||||||
|
if (
|
||||||
|
(text.startsWith('[') && text.endsWith(']')) ||
|
||||||
|
(text.startsWith('{') && text.endsWith('}'))
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return readHostList(JSON.parse(text));
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
.split(/[\s,;]+/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeHostname(value: string): boolean {
|
||||||
|
if (!value.includes('.')) return false;
|
||||||
|
if (/^\d+(\.\d+)*$/.test(value)) return false;
|
||||||
|
if (/^09\d{9}$/.test(value)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostsEqual(left: string, right: string): boolean {
|
||||||
|
if (!left || !right) return false;
|
||||||
|
if (left === right) return true;
|
||||||
|
return left.replace(/^www\./, '') === right.replace(/^www\./, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same identity as GET /get_user?domain= — primary domain, then aliases,
|
||||||
|
* then app/admin/node URLs so HMAC and get_user cannot disagree.
|
||||||
|
*/
|
||||||
|
export function scoreTenantHost(
|
||||||
|
tenant: TenantHostFields,
|
||||||
|
hostRaw: string,
|
||||||
|
): number {
|
||||||
|
const host = normalizeTenantHost(hostRaw);
|
||||||
|
if (!host) return 0;
|
||||||
|
const eq = (raw?: string | null) =>
|
||||||
|
hostsEqual(normalizeTenantHost(raw), host);
|
||||||
|
|
||||||
|
if (eq(tenant.domain)) return 100;
|
||||||
|
for (const alias of readHostList(tenant.domains)) {
|
||||||
|
if (eq(alias)) return 90;
|
||||||
|
}
|
||||||
|
for (const value of readHostList(tenant.appUrl)) {
|
||||||
|
if (eq(value)) return 80;
|
||||||
|
}
|
||||||
|
for (const value of readHostList(tenant.adminUrl)) {
|
||||||
|
const normalized = normalizeTenantHost(value);
|
||||||
|
if (looksLikeHostname(normalized) && hostsEqual(normalized, host)) {
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const value of readHostList(tenant.nodeServer)) {
|
||||||
|
if (eq(value)) return 10;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findTenantForHost<T extends TenantHostFields>(
|
||||||
|
tenants: T[],
|
||||||
|
host: string,
|
||||||
|
): T | undefined {
|
||||||
|
let best: { row: T; score: number } | undefined;
|
||||||
|
for (const row of tenants) {
|
||||||
|
const score = scoreTenantHost(row, host);
|
||||||
|
if (score <= 0) continue;
|
||||||
|
if (!best || score > best.score) best = { row, score };
|
||||||
|
}
|
||||||
|
return best?.row;
|
||||||
|
}
|
||||||
@@ -4,6 +4,10 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { applyKhanegiNavSample } from './khanegi-nav-sample';
|
import { applyKhanegiNavSample } from './khanegi-nav-sample';
|
||||||
import { rewriteLegacyUploadUrl } from '../uploads/uploads.util';
|
import { rewriteLegacyUploadUrl } from '../uploads/uploads.util';
|
||||||
import { PLUGIN_STORE_SETTING_KEY } from '../program-settings/runtime-setting-keys';
|
import { PLUGIN_STORE_SETTING_KEY } from '../program-settings/runtime-setting-keys';
|
||||||
|
import {
|
||||||
|
findTenantForHost as matchTenantForHost,
|
||||||
|
normalizeTenantHost,
|
||||||
|
} from '../auth/tenant-host.util';
|
||||||
|
|
||||||
type UiSetting = { key: string; value: string };
|
type UiSetting = { key: string; value: string };
|
||||||
|
|
||||||
@@ -28,7 +32,7 @@ export class GetUserService {
|
|||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async resolveByHost(hostHeader?: string, domainQuery?: string) {
|
async resolveByHost(hostHeader?: string, domainQuery?: string) {
|
||||||
const host = this.normalizeHost(domainQuery || hostHeader || '');
|
const host = normalizeTenantHost(domainQuery || hostHeader || '');
|
||||||
if (!host) {
|
if (!host) {
|
||||||
throw new NotFoundException('domain required');
|
throw new NotFoundException('domain required');
|
||||||
}
|
}
|
||||||
@@ -374,75 +378,12 @@ export class GetUserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* اول دامنهٔ اصلی (Tenant.domain)، بعد aliases داخل domains[].
|
* همان منطق HMAC ریموت: دامنهٔ اصلی، aliases، بعد admin/app/node URL.
|
||||||
* اگر چند نسخه یک دامنه را داشته باشند، نسخهٔ primary برنده است.
|
|
||||||
*/
|
*/
|
||||||
private findTenantForHost<
|
private findTenantForHost<T extends { domain: string; domains: unknown }>(
|
||||||
T extends {
|
tenants: T[],
|
||||||
domain: string;
|
|
||||||
domains: unknown;
|
|
||||||
},
|
|
||||||
>(tenants: T[], host: string): T | undefined {
|
|
||||||
const normalizedHost = this.normalizeHost(host);
|
|
||||||
const hostNoWww = normalizedHost.replace(/^www\./, '');
|
|
||||||
|
|
||||||
const primary = tenants.find((item) => {
|
|
||||||
const d = this.normalizeHost(item.domain);
|
|
||||||
return d === normalizedHost || d.replace(/^www\./, '') === hostNoWww;
|
|
||||||
});
|
|
||||||
if (primary) return primary;
|
|
||||||
|
|
||||||
return tenants.find((item) => this.matchesAliasHost(item, normalizedHost));
|
|
||||||
}
|
|
||||||
|
|
||||||
private matchesAliasHost(
|
|
||||||
tenant: {
|
|
||||||
domain: string;
|
|
||||||
domains: unknown;
|
|
||||||
},
|
|
||||||
host: string,
|
host: string,
|
||||||
) {
|
): T | undefined {
|
||||||
const hostNoWww = host.replace(/^www\./, '');
|
return matchTenantForHost(tenants, host);
|
||||||
for (const value of this.readDomains(tenant.domains)) {
|
|
||||||
const normalized = this.normalizeHost(value);
|
|
||||||
if (!normalized) continue;
|
|
||||||
if (normalized === host || normalized.replace(/^www\./, '') === hostNoWww) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private matchesHost(
|
|
||||||
tenant: {
|
|
||||||
domain: string;
|
|
||||||
domains: unknown;
|
|
||||||
},
|
|
||||||
host: string,
|
|
||||||
) {
|
|
||||||
return Boolean(this.findTenantForHost([tenant], host));
|
|
||||||
}
|
|
||||||
|
|
||||||
private readDomains(raw: unknown): string[] {
|
|
||||||
if (!raw) return [];
|
|
||||||
if (Array.isArray(raw)) return raw.map(String);
|
|
||||||
if (typeof raw === 'string') {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return Array.isArray(parsed) ? parsed.map(String) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeHost(value: string) {
|
|
||||||
return value
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/^https?:\/\//, '')
|
|
||||||
.split('/')[0]
|
|
||||||
.split(':')[0];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user