From 57654e295ccfc63ca7404f042867128280b56681 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 5 Aug 2026 17:11:55 +0330 Subject: [PATCH] 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 --- .env.example | 3 + src/auth/remote-sms-auth.guard.ts | 200 ++++++++++++++++++++++++++++++ src/sms/remote-sms.controller.ts | 11 +- src/sms/sms.module.ts | 4 +- 4 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 src/auth/remote-sms-auth.guard.ts diff --git a/.env.example b/.env.example index b2b0285..8e1cd7c 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,9 @@ CENTRAL_SMS_SECRET="replace-with-central-sms-secret" CENTRAL_SMS_HOST="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_PORT=3306 HAMKAR_DB_USER=root diff --git a/src/auth/remote-sms-auth.guard.ts b/src/auth/remote-sms-auth.guard.ts new file mode 100644 index 0000000..fe5454b --- /dev/null +++ b/src/auth/remote-sms-auth.guard.ts @@ -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 { + const req = context.switchToHttp().getRequest(); + const shared = String( + this.config.get('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(); + 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; + } + } +} diff --git a/src/sms/remote-sms.controller.ts b/src/sms/remote-sms.controller.ts index e956bc6..3a5c051 100644 --- a/src/sms/remote-sms.controller.ts +++ b/src/sms/remote-sms.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; 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 { SmsService } from './sms.service'; @@ -13,14 +13,17 @@ type RemoteTenantInfo = { }; /** - * Product apps (Hamkar/Khanegi) → SuperAdmin SMS wallet. - * Auth: Authorization: Bearer {Tenant.remoteApiToken} + * Product apps → SuperAdmin SMS wallet. + * + * 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 * POST /api/remote/sms/consume */ @Controller('remote/sms') -@UseGuards(RemoteTokenGuard) +@UseGuards(RemoteSmsAuthGuard) export class RemoteSmsController { constructor(private readonly smsService: SmsService) {} diff --git a/src/sms/sms.module.ts b/src/sms/sms.module.ts index 97e1136..38c864b 100644 --- a/src/sms/sms.module.ts +++ b/src/sms/sms.module.ts @@ -1,14 +1,14 @@ import { Module } from '@nestjs/common'; import { NotificationsModule } from '../notifications/notifications.module'; +import { RemoteSmsAuthGuard } from '../auth/remote-sms-auth.guard'; import { RemoteSmsController } from './remote-sms.controller'; import { SmsController } from './sms.controller'; import { SmsService } from './sms.service'; -import { RemoteTokenGuard } from '../auth/remote-token.guard'; @Module({ imports: [NotificationsModule], controllers: [SmsController, RemoteSmsController], - providers: [SmsService, RemoteTokenGuard], + providers: [SmsService, RemoteSmsAuthGuard], exports: [SmsService], }) export class SmsModule {}