import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { createHmac, timingSafeEqual } from 'crypto'; import { PrismaService } from '../prisma/prisma.service'; import { findTenantForHost, normalizeTenantHost, } from './tenant-host.util'; 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 / plugin-store: * 1) Preferred: shared HMAC + domain headers * 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 = normalizeTenantHost(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 normalizedHost = normalizeTenantHost(host); if (!normalizedHost) return null; const tenants = await this.prisma.tenant.findMany({ where: { isActive: true }, select: { id: true, nameFa: true, domain: true, domains: true, adminUrl: true, appUrl: true, nodeServer: true, product: true, isActive: true, }, }); const hit = findTenantForHost(tenants as TenantHostRow[], normalizedHost); if (!hit) return null; return { id: hit.id, nameFa: hit.nameFa, domain: hit.domain, product: hit.product, isActive: hit.isActive, }; } 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; } } }