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 <cursoragent@cursor.com>
This commit is contained in:
@@ -6,29 +6,120 @@ use Closure;
|
|||||||
use Illuminate\Http\Request;
|
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
|
class EnsureSuperAdminRemoteToken
|
||||||
{
|
{
|
||||||
|
private const MAX_SKEW_SECONDS = 300;
|
||||||
|
|
||||||
|
private const CANONICAL_PATH = '/api/super/settings';
|
||||||
|
|
||||||
public function handle(Request $request, Closure $next)
|
public function handle(Request $request, Closure $next)
|
||||||
{
|
{
|
||||||
$expected = trim((string) config('services.superadmin.remote_token', ''));
|
if ($this->authByHmac($request)) {
|
||||||
if ($expected === '') {
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->authByBearer($request)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasSecret = $this->sharedSecret() !== '';
|
||||||
|
$hasToken = $this->bearerToken() !== '';
|
||||||
|
if (! $hasSecret && ! $hasToken) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'SUPERADMIN_REMOTE_TOKEN is not configured',
|
'message' => 'SUPERADMIN_SMS_SHARED_SECRET or SUPERADMIN_REMOTE_TOKEN is not configured',
|
||||||
], 503);
|
], 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', '');
|
$auth = (string) $request->header('Authorization', '');
|
||||||
$token = '';
|
if (! preg_match('/^\s*Bearer\s+(.+)\s*$/i', $auth, $m)) {
|
||||||
if (preg_match('/^\s*Bearer\s+(.+)\s*$/i', $auth, $m)) {
|
return false;
|
||||||
$token = trim($m[1]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($token === '' || ! hash_equals($expected, $token)) {
|
$token = trim($m[1]);
|
||||||
return response()->json(['message' => 'Unauthorized'], 401);
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user