186 lines
4.9 KiB
TypeScript
186 lines
4.9 KiB
TypeScript
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<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 = 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;
|
|
}
|
|
}
|
|
}
|