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:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
Reference in New Issue
Block a user