fix(settings-proxy): push kill-switch with SMS shared HMAC
When remoteApiToken is empty, sign SuperAdmin→product /super/settings with the same SMS_REMOTE_SHARED_SECRET + domain headers used by SMS. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,26 +3,32 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { createHmac } from 'crypto';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
|
||||||
|
const SUPER_SETTINGS_CANONICAL_PATH = '/api/super/settings';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SettingsProxyService {
|
export class SettingsProxyService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getSettings(tenantId: string) {
|
async getSettings(tenantId: string) {
|
||||||
const tenant = await this.requireTenant(tenantId);
|
const tenant = await this.requireTenant(tenantId);
|
||||||
|
const auth = this.buildAuthHeaders(tenant, 'GET');
|
||||||
|
|
||||||
if (!tenant.remoteApiToken) {
|
if (!auth) {
|
||||||
return {
|
return {
|
||||||
stub: true,
|
stub: true,
|
||||||
message:
|
message:
|
||||||
'remoteApiToken تنظیم نشده. فعلاً stub است تا endpoint سوپر روی Laravel اضافه شود.',
|
'نه remoteApiToken و نه SMS_REMOTE_SHARED_SECRET تنظیم شده؛ push ممکن نیست.',
|
||||||
tenant: {
|
tenant: {
|
||||||
id: tenant.id,
|
id: tenant.id,
|
||||||
slug: tenant.slug,
|
slug: tenant.slug,
|
||||||
@@ -35,7 +41,7 @@ export class SettingsProxyService {
|
|||||||
try {
|
try {
|
||||||
const { data } = await axios.get(this.resolveSettingsUrl(tenant.nodeServer), {
|
const { data } = await axios.get(this.resolveSettingsUrl(tenant.nodeServer), {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tenant.remoteApiToken}`,
|
...auth,
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
@@ -54,8 +60,9 @@ export class SettingsProxyService {
|
|||||||
userId?: string,
|
userId?: string,
|
||||||
) {
|
) {
|
||||||
const tenant = await this.requireTenant(tenantId);
|
const tenant = await this.requireTenant(tenantId);
|
||||||
|
const auth = this.buildAuthHeaders(tenant, 'POST');
|
||||||
|
|
||||||
if (!tenant.remoteApiToken) {
|
if (!auth) {
|
||||||
await this.audit.log({
|
await this.audit.log({
|
||||||
action: 'settings.proxy.stub_update',
|
action: 'settings.proxy.stub_update',
|
||||||
entityType: 'tenant_settings',
|
entityType: 'tenant_settings',
|
||||||
@@ -69,7 +76,7 @@ export class SettingsProxyService {
|
|||||||
return {
|
return {
|
||||||
stub: true,
|
stub: true,
|
||||||
message:
|
message:
|
||||||
'remoteApiToken تنظیم نشده؛ تغییر فقط در audit ثبت شد (stub).',
|
'نه remoteApiToken و نه SMS_REMOTE_SHARED_SECRET تنظیم شده؛ تغییر فقط در audit ثبت شد (stub).',
|
||||||
accepted: payload,
|
accepted: payload,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -80,7 +87,7 @@ export class SettingsProxyService {
|
|||||||
payload,
|
payload,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tenant.remoteApiToken}`,
|
...auth,
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
@@ -95,6 +102,9 @@ export class SettingsProxyService {
|
|||||||
tenantId,
|
tenantId,
|
||||||
userId,
|
userId,
|
||||||
after: payload as Prisma.InputJsonValue,
|
after: payload as Prisma.InputJsonValue,
|
||||||
|
meta: {
|
||||||
|
authMode: auth.Authorization ? 'bearer' : 'hmac',
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { stub: false, result: data };
|
return { stub: false, result: data };
|
||||||
@@ -105,6 +115,89 @@ export class SettingsProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefer per-tenant Bearer; else same shared HMAC used by SMS remote.
|
||||||
|
* Canonical path is always /api/super/settings so product middleware can verify
|
||||||
|
* regardless of /server prefix on nodeServer.
|
||||||
|
*/
|
||||||
|
private buildAuthHeaders(
|
||||||
|
tenant: {
|
||||||
|
remoteApiToken: string | null;
|
||||||
|
domain: string | null;
|
||||||
|
domains: unknown;
|
||||||
|
nodeServer: string | null;
|
||||||
|
},
|
||||||
|
method: 'GET' | 'POST',
|
||||||
|
): Record<string, string> | null {
|
||||||
|
const token = String(tenant.remoteApiToken || '').trim();
|
||||||
|
if (token) {
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const shared = String(
|
||||||
|
this.config.get<string>('SMS_REMOTE_SHARED_SECRET') || '',
|
||||||
|
).trim();
|
||||||
|
const domain = this.resolveTenantDomain(tenant);
|
||||||
|
if (!shared || !domain) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||||
|
const payload = `${timestamp}.${method}.${SUPER_SETTINGS_CANONICAL_PATH}.${domain}`;
|
||||||
|
const signature = createHmac('sha256', shared)
|
||||||
|
.update(payload, 'utf8')
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
return {
|
||||||
|
'X-Abshode-Domain': domain,
|
||||||
|
'X-Abshode-Timestamp': timestamp,
|
||||||
|
'X-Abshode-Signature': signature,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveTenantDomain(tenant: {
|
||||||
|
domain: string | null;
|
||||||
|
domains: unknown;
|
||||||
|
nodeServer: string | null;
|
||||||
|
}): string {
|
||||||
|
// Prefer host of the URL we call — must match product SUPERADMIN_SMS_DOMAIN
|
||||||
|
const fromNode = this.normalizeHost(tenant.nodeServer);
|
||||||
|
if (fromNode) return fromNode;
|
||||||
|
|
||||||
|
const primary = this.normalizeHost(tenant.domain);
|
||||||
|
if (primary) return primary;
|
||||||
|
|
||||||
|
for (const value of this.readDomains(tenant.domains)) {
|
||||||
|
const host = this.normalizeHost(value);
|
||||||
|
if (host) return host;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 async requireTenant(tenantId: string) {
|
private async requireTenant(tenantId: string) {
|
||||||
const tenant = await this.prisma.tenant.findUnique({
|
const tenant = await this.prisma.tenant.findUnique({
|
||||||
where: { id: tenantId },
|
where: { id: tenantId },
|
||||||
|
|||||||
Reference in New Issue
Block a user