验证对 Azure 批处理 REST 接口的请求 (php)

Authenticating requests to azure's batch REST interface (php)

我正在尝试从我的 php 服务器对 azures 批处理 REST 接口进行身份验证。 根据文档 (https://docs.microsoft.com/en-us/rest/api/batchservice/authenticate-requests-to-the-azure-batch-service) 我想出了这个函数:

use GuzzleHttp\Client;
const BATCH_ACCOUNT_NAME = "myAccount";
const BATCH_ACCOUNT_KEY = "mySuperSecretKey";
const BATCH_ENDPOINT = "https://myAccount.theRegion.batch.azure.com";

// Pool and Job constants
const  POOL_ID = "MyTestPool";
const  POOL_VM_SIZE = "STANDARD_A1_v2";

private function createPoolIfNotExists()
{
    echo "-- creating batch pool --\n\n";

    $client = new Client();
    $body = [
        "id" => POOL_ID,
        "vmSize" => POOL_VM_SIZE,
    ];
    $contentType =  "application/json;odata=minimalmetadata";
    $apiVersion = "2021-06-01.14.0";
    $ocpDate = Carbon::now('UTC')->toString("R");

    $signature = $this->createRidiculouslyOverComplicatedSignature(
        "POST",
        $contentType,
        $apiVersion,
        $ocpDate,
        $body
    );

    $response = $client->post(BATCH_ENDPOINT . "/pools?api-version={$apiVersion}", [
        'json' => $body,
        'headers' => [
            "ocp-date" => $ocpDate,
            "Authorization" => "SharedKey " . BATCH_ACCOUNT_NAME . ":{$signature}"
        ]
    ]);
    $contents = json_decode($response->getBody());

    dd($contents);
}

private function createRidiculouslyOverComplicatedSignature($verb, $contentType, $apiVersion, $ocpDate, $body)
{

    $contentLength = mb_strlen(json_encode($body, JSON_NUMERIC_CHECK), '8bit');
    $canonicalizedHeaders = "ocp-date:{$ocpDate}";
    $canonicalizedResource = "/" . BATCH_ACCOUNT_NAME . "/pools\napi-version:{$apiVersion}";
    $stringToSign = "{$verb}\n\n\n{$contentLength}\n\n{$contentType}\n\n\n\n\n\n\n{$canonicalizedHeaders}\n{$canonicalizedResource}";
 
    echo utf8_encode($stringToSign);
    return  base64_encode(hash_hmac('sha256', utf8_encode($stringToSign), BATCH_ACCOUNT_KEY));
}

但是,我总是收到 403 错误:

"Server failed to authenticate the request. Make sure the value of Authorization header is formed correctly including the signature"

由于复杂的设置和模糊的错误消息,我真的很难弄明白,where/why它失败了。尝试调整我能想到的每个选项,但没有。我在这里错过了什么?

更新: 我设法将批处理授权库从官方 python sdk 转换为 php。这是我想出的:

    private function createPoolIfNotExist()
{
    echo "-- creating batch pool --\n\n";

    $credentials = new BatchSharedKeyCredentials(
        BATCH_ACCOUNT_NAME,
        BATCH_ACCOUNT_KEY,
        BATCH_ENDPOINT,
    );
    $body = [
        "id" => POOL_ID,
        "vmSize" => POOL_VM_SIZE,
        "targetDedicatedNodes" => 0,
        "targetLowPriorityNodes" => 1,
    ];

    $stack = HandlerStack::create(new CurlHandler());
    $stack->push(new BatchAuthentication($credentials));
    $client = new Client([
        'handler' => $stack,
    ]);

    $client->post(BATCH_ENDPOINT . "/pools?api-version=2021-06-01.14.0", [
        'json' => $body
    ]);

    dd("end");
}
class BatchAuthentication
{


    public BatchSharedKeyCredentials $credentials;
    public function __construct(BatchSharedKeyCredentials $credentials)
    {
        $this->credentials = $credentials;
    }

    public function __invoke(callable $handler)
    {
        return function (RequestInterface $request, array $options) use ($handler) {
            $newRequest = $this->signHeader($request);
            return $handler(
                $newRequest,
                $options
            );
        };
    }






    private function  sign(string $stringToSign)
    {
        $key = $this->credentials->keyValue;
        $stringToSign = utf8_encode($stringToSign);
        $key = base64_decode($key);
        $sign =  hash_hmac(
            'sha256',
            $stringToSign,
            $key,
            true
        );

        $signature = utf8_decode(base64_encode($sign));
        echo ($signature);
        return $signature;
    }



    private function signHeader(RequestInterface $request)
    {

        // Set Headers
        if ($request->getHeader("ocp-date") == null) {
            $dateTime = Carbon::now('UTC')->toRfc7231String();
            $request = $request->withAddedHeader("ocp-date", $dateTime);
        }
        echo ("\n ocp date: " . $request->getHeader("ocp-date")[0] . "\n");

        $signature = $request->getMethod() . "\n";
        $signature .= $this->headerValue($request, "Content-Encoding") . "\n";
        $signature .= $this->headerValue($request, "Content-Language") . "\n";

        // Special handle content length
        $length = -1;
        if ($request->getBody() != null) {
            $length = $request->getBody()->getSize();
        }
        $signature .=  ($length  >= 0 ? $length  : "") . "\n";


        $signature .= $this->headerValue($request, "Content-MD5") . "\n";

        // Special handle content type header
        $contentType = "";
        if ($request->getBody() != null && $request->getBody()->getSize() != 0) {
            //here it differs. But official docs say like this:
            $contentType = "application/json; odata=minimalmetadata; charset=utf-8";
        }

        $signature .= $contentType . "\n";


        $signature .= $this->headerValue($request, "Date") . "\n";
        $signature .= $this->headerValue($request, "If-Modified-Since") . "\n";
        $signature .= $this->headerValue($request, "If-Match") . "\n";
        $signature .= $this->headerValue($request, "If-None-Match") . "\n";
        $signature .= $this->headerValue($request, "If-Unmodified-Since") . "\n";
        $signature .= $this->headerValue($request, "Range") . "\n";



        $customHeaders =  array();
        foreach ($request->getHeaders() as $key => $value) {
            if (str_starts_with(strtolower($key), "ocp-")) {
                array_push($customHeaders, strtolower($key));
            }
        }
        sort($customHeaders);

        foreach ($customHeaders as  $canonicalHeader) {
            $value = $request->getHeader($canonicalHeader)[0];

            $value = str_replace('\n', ' ', $value);
            $value = str_replace('\r', ' ', $value);
            $value = preg_replace("/^[ ]+/", "", $value);
            $signature .=  $canonicalHeader . ":" . $value . "\n";
        }


        $signature .= "/" . strtolower($this->credentials->accountName) . "/"
            .  str_replace("/", "", $request->getUri()->getPath());

        $query = $request->getUri()->getQuery();

        if ($query != null) {
            $queryComponents = array();
            $pairs = explode("&", $query);
            foreach ($pairs as $pair) {
                $idx = strpos($pair, "=");
                $key = strtolower(urldecode(mb_strcut($pair, 0, $idx, "UTF-8")));
                $queryComponents[$key] = $key . ":" . urldecode(mb_strcut($pair, $idx + 1, strlen($pair), "UTF-8"));
            }
            foreach ($queryComponents as $key => $value) {
                $signature .= "\n" . $value;
            }
        }

        echo ("\nsignature:\n" . $signature . "\n");

        $signedSignature = $this->sign($signature);

        $authorization = "SharedKey " . $this->credentials->accountName . ":" . $signedSignature;
        $request = $request->withAddedHeader("Authorization", $authorization);

        echo "\n";
        foreach ($request->getHeaders() as $key => $value) {
            echo ($key . " : " . $value[0] . "\n");
        }

        return $request;
    }

    private function headerValue(RequestInterface $request, String $headerName): String
    {

        $headerValue = $request->getHeader($headerName);
        if ($headerValue == null) {
            return "";
        }

        return $headerValue[0];
    }
}

class BatchSharedKeyCredentials
{

    public string $accountName;

    public string $keyValue;

    public string $baseUrl;

    public function __construct(string $accountName, string $keyValue, string $baseUrl)
    {
        $this->accountName = $accountName;
        $this->keyValue = $keyValue;
        $this->baseUrl = $baseUrl;
    }
}

我 运行 进行了一些测试,用于在(有效的)python 示例和我的 php 脚本中使用“test-string”进行签名过程。签名是一样的,所以我的签名功能现在可以正常使用了!

我还比较了headers和要签名的字符串。他们是一样的!

然而在 php 它抛出 403 错误,告诉我

The MAC signature found in the HTTP request 'mySignatureCode' is not the same as any computed signature.

我花了一个星期才弄明白,如果你不指定,guzzle 会自动设置 content-type header。

我 post 我的整个脚本,以防其他人想做同样的事情 - 不需要太痛苦 - 现在应该可以正常工作了:

<?php

namespace App\Http\Middleware;

use GuzzleHttp\Client;
use GuzzleHttp\Handler\CurlHandler;
use GuzzleHttp\HandlerStack;
use Psr\Http\Message\RequestInterface;
use Illuminate\Support\Carbon;


class AzureBatchClient extends Client
{
    public function __construct(array $config = [])
    {
        $stack = HandlerStack::create(new CurlHandler());
        $stack->push(new BatchAuthentication(new BatchSharedKeyCredentials(
            env("AZURE_BATCH_ACCOUNT"),env("AZURE_BATCH_KEY")
        )));
        $config['handler'] = $stack;
        parent::__construct($config);
    }
}



class BatchAuthentication
{
    public BatchSharedKeyCredentials $credentials;
    public function __construct(BatchSharedKeyCredentials $credentials)
    {
        $this->credentials = $credentials;
    }

    public function __invoke(callable $handler)
    {
        return function (RequestInterface $request, array $options) use ($handler) {
            $newRequest = $this->signHeader($request);
            return $handler(
                $newRequest,
                $options
            );
        };
    }

    private function sign(string $stringToSign)
    {
        $key = $this->credentials->keyValue;
        $stringToSign = utf8_encode($stringToSign);
        $key = base64_decode($key);
        $sign =  hash_hmac(
            'sha256',
            $stringToSign,
            $key,
            true
        );

        $signature = utf8_decode(base64_encode($sign));
        //echo ($signature);
        return $signature;
    }

    private function signHeader(RequestInterface $request)
    {
        // Set Headers
        if ($request->getHeader("ocp-date") == null) {
            $dateTime = Carbon::now('UTC')->toRfc7231String();
            $request = $request->withAddedHeader("ocp-date", $dateTime);
        }
        //echo ("\n ocp date: " . $request->getHeader("ocp-date")[0] . "\n");

        $signature = $request->getMethod() . "\n";
        $signature .= $this->headerValue($request, "Content-Encoding") . "\n";
        $signature .= $this->headerValue($request, "Content-Language") . "\n";

        // Special handle content length
        $length = -1;
        if ($request->getBody() != null) {
            $length = $request->getBody()->getSize();
        }
        $signature .=  ($length  > 0 ? $length  : "") . "\n";


        $signature .= $this->headerValue($request, "Content-MD5") . "\n";

        // Special handle content type header
        $contentType = "";
        if ($request->getBody() != null && $request->getBody()->getSize() != 0) {
            //here it differs. But official docs say like this:
            $contentType = "application/json; odata=minimalmetadata; charset=utf-8";
        }

        $signature .= $contentType . "\n";


        $signature .= $this->headerValue($request, "Date") . "\n";
        $signature .= $this->headerValue($request, "If-Modified-Since") . "\n";
        $signature .= $this->headerValue($request, "If-Match") . "\n";
        $signature .= $this->headerValue($request, "If-None-Match") . "\n";
        $signature .= $this->headerValue($request, "If-Unmodified-Since") . "\n";
        $signature .= $this->headerValue($request, "Range") . "\n";

        $customHeaders =  array();
        foreach ($request->getHeaders() as $key => $value) {
            if (str_starts_with(strtolower($key), "ocp-")) {
                array_push($customHeaders, strtolower($key));
            }
        }
        sort($customHeaders);

        foreach ($customHeaders as  $canonicalHeader) {
            $value = $request->getHeader($canonicalHeader)[0];

            $value = str_replace('\n', ' ', $value);
            $value = str_replace('\r', ' ', $value);
            $value = preg_replace("/^[ ]+/", "", $value);
            $signature .=  $canonicalHeader . ":" . $value . "\n";
        }

        $path = substr_replace($request->getUri()->getPath(), "", 0, strlen("/"));
        //  echo  $path;
        $signature .= "/" . strtolower($this->credentials->accountName) . "/" . $path;

        $query = $request->getUri()->getQuery();

        if ($query != null) {
            $queryComponents = array();
            $pairs = explode("&", $query);
            foreach ($pairs as $pair) {
                $idx = strpos($pair, "=");
                $key = strtolower(urldecode(mb_strcut($pair, 0, $idx, "UTF-8")));
                $queryComponents[$key] = $key . ":" . urldecode(mb_strcut($pair, $idx + 1, strlen($pair), "UTF-8"));
            }
            foreach ($queryComponents as $key => $value) {
                $signature .= "\n" . $value;
            }
        }

        //echo ("\n\n" . str_replace("\n", "\n", $signature) . "\n\n");

        $signedSignature = $this->sign($signature);
        $authorization = "SharedKey " . $this->credentials->accountName . ":" . $signedSignature;
        $request = $request->withAddedHeader("Authorization", $authorization);

        /*
        foreach ($request->getHeaders() as $key => $value) {
            echo ($key . " : " . $value[0] . "\n");
        }
        */

        return $request;
    }

    private function headerValue(RequestInterface $request, String $headerName): String
    {
        $headerValue = $request->getHeader($headerName);
        if ($headerValue == null) {
            return "";
        }
        return $headerValue[0];
    }
}

class BatchSharedKeyCredentials
{
    public string $accountName;
    public string $keyValue;

    public function __construct(string $accountName, string $keyValue)
    {
        $this->accountName = $accountName;
        $this->keyValue = $keyValue;
    }
}

然后你像这样使用它 POST:

$client = new AzureBatchClient();
$client->post(BATCH_ENDPOINT . "/pools?api-version=" . API_VERISON, [
    'json' => $body,
    'headers' => [
        "Content-Type" => "application/json; odata=minimalmetadata; charset=utf-8"
    ]
]);

像这样获取 GET 等:

$client = new AzureBatchClient();
$client->get(BATCH_ENDPOINT . "/jobs/{$jobId}?api-version=" . API_VERISON);

请确保您的 header 中的 content-type 与您的签名字符串匹配。