Add shared HMAC+domain auth for remote SMS wallet API.

Products can authenticate with one shared secret and tenant domain instead of per-version tokens; Bearer token remains as fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-05 17:11:55 +03:30
co-authored by Cursor
parent 14d9c5d143
commit 57654e295c
4 changed files with 212 additions and 6 deletions
+3
View File
@@ -26,6 +26,9 @@ CENTRAL_SMS_SECRET="replace-with-central-sms-secret"
CENTRAL_SMS_HOST="superadmin" CENTRAL_SMS_HOST="superadmin"
CENTRAL_SMS_PLATFORM="superadmin" CENTRAL_SMS_PLATFORM="superadmin"
# رمز مشترک SMS ریموت (HMAC + دامنه) — همان مقدار روی همه نسخه‌های همکار/خانگی
SMS_REMOTE_SHARED_SECRET="replace-with-long-random-shared-secret"
HAMKAR_DB_HOST=127.0.0.1 HAMKAR_DB_HOST=127.0.0.1
HAMKAR_DB_PORT=3306 HAMKAR_DB_PORT=3306
HAMKAR_DB_USER=root HAMKAR_DB_USER=root
+200
View File
@@ -0,0 +1,200 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHmac, timingSafeEqual } from 'crypto';
import { PrismaService } from '../prisma/prisma.service';
const MAX_SKEW_SECONDS = 300;
/**
* Auth for product → SuperAdmin SMS:
* 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}
*/
@Injectable()
export class RemoteSmsAuthGuard implements CanActivate {
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const shared = String(
this.config.get<string>('SMS_REMOTE_SHARED_SECRET') || '',
).trim();
const domainHeader = String(
req.headers['x-abshode-domain'] || '',
).trim();
const tsHeader = String(
req.headers['x-abshode-timestamp'] || '',
).trim();
const sigHeader = String(
req.headers['x-abshode-signature'] || '',
).trim();
if (shared && domainHeader && tsHeader && sigHeader) {
req.remoteTenant = await this.authByHmac(
req,
shared,
domainHeader,
tsHeader,
sigHeader,
);
return true;
}
const bearerTenant = await this.authByBearer(req);
if (bearerTenant) {
req.remoteTenant = bearerTenant;
return true;
}
throw new UnauthorizedException(
'احراز هویت ریموت نامعتبر است (HMAC دامنه یا Bearer توکن)',
);
}
private async authByHmac(
req: any,
sharedSecret: string,
domainRaw: string,
tsRaw: string,
sigRaw: string,
) {
const timestamp = Number(tsRaw);
if (!Number.isFinite(timestamp)) {
throw new UnauthorizedException('timestamp نامعتبر است');
}
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > MAX_SKEW_SECONDS) {
throw new UnauthorizedException('timestamp منقضی است');
}
const domain = this.normalizeHost(domainRaw);
if (!domain) {
throw new UnauthorizedException('دامنه نامعتبر است');
}
const method = String(req.method || 'GET').toUpperCase();
const pathOnly = String(req.originalUrl || req.url || '')
.split('?')[0]
.replace(/\/+$/, '');
const canonicalPath = pathOnly.startsWith('/api/')
? pathOnly
: pathOnly.startsWith('/remote/')
? `/api${pathOnly}`
: pathOnly || '/api/remote/sms/status';
const payload = `${timestamp}.${method}.${canonicalPath}.${domain}`;
const expected = createHmac('sha256', sharedSecret)
.update(payload, 'utf8')
.digest('hex');
if (!this.equalHex(expected, sigRaw)) {
throw new UnauthorizedException('امضای درخواست نامعتبر است');
}
const tenant = await this.findTenantByDomain(domain);
if (!tenant) {
throw new UnauthorizedException('نسخه‌ای با این دامنه یافت نشد');
}
return tenant;
}
private async authByBearer(req: any) {
const header = String(req.headers?.authorization || '');
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
const token = match?.[1]?.trim();
if (!token || token.length < 16) return null;
return this.prisma.tenant.findFirst({
where: { remoteApiToken: token, isActive: true },
select: {
id: true,
nameFa: true,
domain: true,
product: true,
isActive: true,
},
});
}
private async findTenantByDomain(host: string) {
const tenants = await this.prisma.tenant.findMany({
where: { isActive: true },
select: {
id: true,
nameFa: true,
domain: true,
domains: true,
product: true,
isActive: true,
},
});
const hit = tenants.find((row) => this.matchesHost(row, host));
if (!hit) return null;
return {
id: hit.id,
nameFa: hit.nameFa,
domain: hit.domain,
product: hit.product,
isActive: hit.isActive,
};
}
private matchesHost(
tenant: { domain: string | null; domains: unknown },
host: string,
) {
const candidates = new Set<string>();
for (const value of [tenant.domain, ...this.readDomains(tenant.domains)]) {
const normalized = this.normalizeHost(value);
if (normalized) candidates.add(normalized);
}
return candidates.has(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 | null) {
if (!value) return '';
return String(value)
.trim()
.toLowerCase()
.replace(/^https?:\/\//, '')
.split('/')[0]
.split(':')[0];
}
private equalHex(a: string, b: string) {
try {
const left = Buffer.from(String(a).toLowerCase(), 'utf8');
const right = Buffer.from(String(b).toLowerCase(), 'utf8');
if (left.length !== right.length) return false;
return timingSafeEqual(left, right);
} catch {
return false;
}
}
}
+7 -4
View File
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { RemoteTenant } from '../auth/remote-tenant.decorator'; import { RemoteTenant } from '../auth/remote-tenant.decorator';
import { RemoteTokenGuard } from '../auth/remote-token.guard'; import { RemoteSmsAuthGuard } from '../auth/remote-sms-auth.guard';
import { RemoteSmsConsumeDto } from './dto/sms-account.dto'; import { RemoteSmsConsumeDto } from './dto/sms-account.dto';
import { SmsService } from './sms.service'; import { SmsService } from './sms.service';
@@ -13,14 +13,17 @@ type RemoteTenantInfo = {
}; };
/** /**
* Product apps (Hamkar/Khanegi) → SuperAdmin SMS wallet. * Product apps → SuperAdmin SMS wallet.
* Auth: Authorization: Bearer {Tenant.remoteApiToken} *
* Auth (preferred): shared HMAC
* X-Abshode-Domain, X-Abshode-Timestamp, X-Abshode-Signature
* Fallback: Authorization Bearer {Tenant.remoteApiToken}
* *
* GET /api/remote/sms/status?parts=1 * GET /api/remote/sms/status?parts=1
* POST /api/remote/sms/consume * POST /api/remote/sms/consume
*/ */
@Controller('remote/sms') @Controller('remote/sms')
@UseGuards(RemoteTokenGuard) @UseGuards(RemoteSmsAuthGuard)
export class RemoteSmsController { export class RemoteSmsController {
constructor(private readonly smsService: SmsService) {} constructor(private readonly smsService: SmsService) {}
+2 -2
View File
@@ -1,14 +1,14 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { RemoteSmsAuthGuard } from '../auth/remote-sms-auth.guard';
import { RemoteSmsController } from './remote-sms.controller'; import { RemoteSmsController } from './remote-sms.controller';
import { SmsController } from './sms.controller'; import { SmsController } from './sms.controller';
import { SmsService } from './sms.service'; import { SmsService } from './sms.service';
import { RemoteTokenGuard } from '../auth/remote-token.guard';
@Module({ @Module({
imports: [NotificationsModule], imports: [NotificationsModule],
controllers: [SmsController, RemoteSmsController], controllers: [SmsController, RemoteSmsController],
providers: [SmsService, RemoteTokenGuard], providers: [SmsService, RemoteSmsAuthGuard],
exports: [SmsService], exports: [SmsService],
}) })
export class SmsModule {} export class SmsModule {}