From 5cd81b9663f9cf689f106240cc71ef3a98e847cb Mon Sep 17 00:00:00 2001 From: Mahdi Date: Tue, 11 Aug 2026 19:33:30 +0330 Subject: [PATCH] fix(super): accept SMS shared HMAC for kill-switch auth Allow SUPERADMIN_SMS_SHARED_SECRET (+ domain) like SMS remote, with Bearer SUPERADMIN_REMOTE_TOKEN as fallback. Co-authored-by: Cursor --- .../EnsureSuperAdminRemoteToken.php | 111 ++++++++++++++++-- 1 file changed, 101 insertions(+), 10 deletions(-) diff --git a/app/Http/Middleware/EnsureSuperAdminRemoteToken.php b/app/Http/Middleware/EnsureSuperAdminRemoteToken.php index 93af0e2..cd6f599 100644 --- a/app/Http/Middleware/EnsureSuperAdminRemoteToken.php +++ b/app/Http/Middleware/EnsureSuperAdminRemoteToken.php @@ -6,29 +6,120 @@ use Closure; use Illuminate\Http\Request; /** - * Bearer token must match SUPERADMIN_REMOTE_TOKEN (same as SuperAdmin Tenant.remoteApiToken). + * Auth for SuperAdmin → product (kill-switch / super settings): + * 1) Preferred: shared HMAC (same secret as SMS) + * X-Abshode-Domain, X-Abshode-Timestamp, X-Abshode-Signature + * payload = `{ts}.{METHOD}./api/super/settings.{domain}` + * 2) Fallback: Authorization Bearer {SUPERADMIN_REMOTE_TOKEN} */ class EnsureSuperAdminRemoteToken { + private const MAX_SKEW_SECONDS = 300; + + private const CANONICAL_PATH = '/api/super/settings'; + public function handle(Request $request, Closure $next) { - $expected = trim((string) config('services.superadmin.remote_token', '')); - if ($expected === '') { + if ($this->authByHmac($request)) { + return $next($request); + } + + if ($this->authByBearer($request)) { + return $next($request); + } + + $hasSecret = $this->sharedSecret() !== ''; + $hasToken = $this->bearerToken() !== ''; + if (! $hasSecret && ! $hasToken) { return response()->json([ - 'message' => 'SUPERADMIN_REMOTE_TOKEN is not configured', + 'message' => 'SUPERADMIN_SMS_SHARED_SECRET or SUPERADMIN_REMOTE_TOKEN is not configured', ], 503); } + return response()->json(['message' => 'Unauthorized'], 401); + } + + private function authByHmac(Request $request): bool + { + $secret = $this->sharedSecret(); + if ($secret === '') { + return false; + } + + $domainHeader = trim((string) $request->header('X-Abshode-Domain', '')); + $tsRaw = trim((string) $request->header('X-Abshode-Timestamp', '')); + $sigRaw = trim((string) $request->header('X-Abshode-Signature', '')); + if ($domainHeader === '' || $tsRaw === '' || $sigRaw === '') { + return false; + } + + if (! ctype_digit($tsRaw)) { + return false; + } + $timestamp = (int) $tsRaw; + if (abs(time() - $timestamp) > self::MAX_SKEW_SECONDS) { + return false; + } + + $domain = $this->normalizeHost($domainHeader); + $expectedDomain = $this->configuredDomain(); + if ($domain === '' || $expectedDomain === '' || ! hash_equals($expectedDomain, $domain)) { + return false; + } + + $method = strtoupper($request->getMethod()); + $payload = $timestamp.'.'.$method.'.'.self::CANONICAL_PATH.'.'.$domain; + $expected = hash_hmac('sha256', $payload, $secret); + + return hash_equals(strtolower($expected), strtolower($sigRaw)); + } + + private function authByBearer(Request $request): bool + { + $expected = $this->bearerToken(); + if ($expected === '') { + return false; + } + $auth = (string) $request->header('Authorization', ''); - $token = ''; - if (preg_match('/^\s*Bearer\s+(.+)\s*$/i', $auth, $m)) { - $token = trim($m[1]); + if (! preg_match('/^\s*Bearer\s+(.+)\s*$/i', $auth, $m)) { + return false; } - if ($token === '' || ! hash_equals($expected, $token)) { - return response()->json(['message' => 'Unauthorized'], 401); + $token = trim($m[1]); + + return $token !== '' && hash_equals($expected, $token); + } + + private function sharedSecret(): string + { + return trim((string) config('services.superadmin.sms_shared_secret', '')); + } + + private function bearerToken(): string + { + return trim((string) config('services.superadmin.remote_token', '')); + } + + private function configuredDomain(): string + { + $override = trim((string) config('services.superadmin.sms_domain', '')); + if ($override !== '') { + return $this->normalizeHost($override); } - return $next($request); + $fromApp = parse_url((string) config('app.url'), PHP_URL_HOST); + + return $this->normalizeHost(is_string($fromApp) ? $fromApp : ''); + } + + private function normalizeHost(string $value): string + { + $host = strtolower(trim($value)); + $host = preg_replace('#^https?://#', '', $host) ?? $host; + $host = explode('/', $host)[0] ?? $host; + $host = explode(':', $host)[0] ?? $host; + + return trim($host); } }