errorline1
errorline1

Reputation: 432

Azure Blob Storage Authorization

EDIT 2: The following is the output of the Authorization error:

<?xml version=\"1.0\" encoding=\"utf-8\"?>
<Error>
  <Code>AuthenticationFailed</Code>
  <Message>Server failed to authenticate the request. Make sure the value of Authorization header is formed correctly including the signature.\nRequestId:34d738a5-101e-000d-5a14-ed5956000000\nTime:2021-01-17T21:07:38.6231913Z
  </Message>
  <AuthenticationErrorDetail>Signature did not match. String to sign used was cw\n2021-01-17T19:06:42Z\n2021-01-18T21:06:42Z\n/blob/example-account/example-container/example-blob.json\n\n\nhttps\n2019-02-02\nb\n\n\n\n\n\n
  </AuthenticationErrorDetail>
</Error>

I don't really understand... I updated the C# code below to print out the string_to_sign with the \n characters, and it's exactly the same as the string_to_sign from the output above.

NOTE: The SAS token generated from Azure Storage that does work is an Account SAS, while the one I'm generating is a Service SAS. Could Service SAS be restricted in Azure Storage?

EDIT: I tried generating a SAS token directly from Azure Storage, and this did seem to work. It appears to be an account SAS, NOT the service SAS I'm trying to use below.

?sv=2019-12-12&ss=b&srt=o&sp=wac&se=2021-01-18T01:15:13Z&st=2021-01-17T17:15:13Z&spr=https&sig=<signature>

I'm looking to be able to send upload a file to Azure Storage using it's REST API. However, I'm having some trouble getting it to Authorize. I find the documentation a bit conflicting, in some places it says I can include a SAS token in the URI, in others it's in the Authorize header. For context, I'm trying to do it directly from APIM, so in the sample code below it's written with its limited API. This is just a general concept I'm using to generate the authorization string, but I keep getting a 403 when I use it (I'm not sure if I need to do something from the Azure Storage side).

/**
 Based on https://learn.microsoft.com/en-us/rest/api/storageservices/create-service-sas
*/
using System;
using System.Collections.Generic;

namespace sas_token
{
  class Program
  {
    static void Main(string[] args)
    {
      string key = args[0];
      Console.WriteLine(generate_blob_sas_token(key));
    }

    public static string generate_blob_sas_token(string key)
    {
      const string canonicalizedResource = "canonicalizedResource";

      // NOTE: this only works for Blob type files, Tables have a different
      // structure
      // NOTE: use a List instead of Dictionary since the order of keys in
      // Dictionaries is undefined and the signature string requires a very
      // specific order
      List<KeyValuePair<string, string>> sas_token_properties = new List<KeyValuePair<string, string>>(){


    // signedPermissions, select 1..* from [racwdxltmeop], MUST be in that order
    new KeyValuePair<string, string>("sp", "cw"),

    // signedStart time, date from when the token is valid
    // NOTE: because of clock skew between services, even setting the time to
    // now may not create an immediately usable token
    new KeyValuePair<string, string>("st", DateTime.UtcNow.AddMinutes(-120).ToString("yyyy-MM-ddTHH:mm:ssZ")),

    // signedExpiry time, date until the token is valid
    new KeyValuePair<string, string>("se", DateTime.UtcNow.AddDays(1).ToString("yyyy-MM-ddTHH:mm:ssZ")),

    // canonicalizedResource, must be prefixed with /blob in recent versions
    // NOTE: this is NOT included as a query parameter, but is in the signature
    // URL = https://myaccount.blob.core.windows.net/music/intro.mp3
    // canonicalizedResource = "/blob/myaccount/music/intro.mp3"
    new KeyValuePair<string, string>(canonicalizedResource, "/blob/example-account/example-container"),

    // signedIdentifier, can be used to identify a Stored Access Policy
    new KeyValuePair<string, string>("si", ""),

    // signedIP, single or range of allowed IP addresses
    new KeyValuePair<string, string>("sip", ""),

    // signedProtocol
    // [http, https]
    new KeyValuePair<string, string>("spr", "https"),

    // signedVersion, the version of SAS used (defines which keys are
    // required/available)
    new KeyValuePair<string, string>("sv", "2019-02-02"),

    // signedResource, the type of resource the token is allowed to access
    // [b = blob, d = directory, c = container, bv, bs]
    new KeyValuePair<string, string>("sr", "b"),

    // signedSnapshotTime
    new KeyValuePair<string, string>("sst", ""),


    // the following specify how the response should be formatted

    // Cache-Control
    new KeyValuePair<string, string>("rscc", ""),

    // Content-Disposition
    new KeyValuePair<string, string>("rscd", ""),

    // Content-Encoding
    new KeyValuePair<string, string>("rsce", ""),

    // Content-Language
    new KeyValuePair<string, string>("rscl", ""),

     // Content-Type
    new KeyValuePair<string, string>("rsct", "")
};

      // the format is a very specific text string, where values are delimited by new
      // lines, and the order of the properties in the string is important!
      List<string> values = new List<string>();
      foreach (KeyValuePair<string, string> entry in sas_token_properties)
      {
        values.Add(entry.Value);
      }
      string string_to_sign = string.Join("\n", new List<string>(values));
      Console.WriteLine(string_to_sign.Replace("\n", "\\n"));
      System.Security.Cryptography.HMACSHA256 hmac = new System.Security.Cryptography.HMACSHA256(System.Text.Encoding.UTF8.GetBytes(key));
      var signature = System.Convert.ToBase64String(hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(string_to_sign)));

      // create the query parameters of any set values + the signature
      // NOTE: all properties that contribute to the signature must be added
      // as query params EXCEPT canonicalizedResource
      List<string> parameters = new List<string>();

      foreach (KeyValuePair<string, string> entry in sas_token_properties)
      {
        if (!string.IsNullOrEmpty(entry.Value) && entry.Key != canonicalizedResource)
        {
          parameters.Add(entry.Key + "=" + System.Net.WebUtility.UrlEncode(entry.Value));
        }
      }

      parameters.Add("sig=" + System.Net.WebUtility.UrlEncode(signature));

      string sas_token_querystring = string.Join("&", parameters);

      return sas_token_querystring;
    }
  }
}

I use the output in the following (simplified) APIM policy (I set "sas_token" variable to the output of the function to test the process):

<set-variable name="x-request-body" value="@(context.Request.Body.As<string>())" />
<send-request mode="new" response-variable-name="tokenstate" timeout="20" ignore-error="true">
   <set-url>@("https://example-account.blob.core.windows.net/example-container/test.json")</set-url>
   <set-method>PUT</set-method>
   <set-header name="x-ms-date" exists-action="override">
     <value>@(DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"))</value>
   </set-header>
   <set-header name="x-ms-version" exists-action="override">
     <value>2019-02-02</value>
   </set-header>
   <set-header name="x-ms-blob-type" exists-action="override">
     <value>BlockBlob</value>
   </set-header>
   <set-header name="Authorization" exists-action="override">
     <value>@("SharedAccessSignature " + (string)context.Variables["sas_token"])</value>
   </set-header>
   <set-body>@((string)context.Variables["x-request-body"])</set-body>
</send-request>

For completeness, here's the result from APIM when I trace a test request using {"hello": "then"}:

{
    "message": "Request is being forwarded to the backend service. Timeout set to 20 seconds",
    "request": {
        "method": "PUT",
        "url": "https://example-account.blob.core.windows.net/example-container/test.json",
        "headers": [
            {
                "name": "Host",
                "value": "example-account.blob.core.windows.net"
            },
            {
                "name": "Content-Length",
                "value": 17
            },
            {
                "name": "x-ms-date",
                "value": "2021-01-17T16:53:28Z"
            },
            {
                "name": "x-ms-version",
                "value": "2019-02-02"
            },
            {
                "name": "x-ms-blob-type",
                "value": "BlockBlob"
            },
            {
                "name": "Authorization",
                "value": "SharedAccessSignature sp=cw&st=2021-01-17T13%3A42%3A02Z&se=2021-01-18T15%3A42%3A02Z&spr=https&sv=2019-02-02&sr=b&sig=<signature>"
            },
            {
                "name": "X-Forwarded-For",
                "value": "205.193.94.40"
            }
        ]
    }
}
send-request (92.315 ms)
{
    "response": {
        "status": {
            "code": 403,
            "reason": "Server failed to authenticate the request. Make sure the value of Authorization header is formed correctly including the signature."
        },
        "headers": [
            {
                "name": "x-ms-request-id",
                "value": "185d86f5-601e-0038-5cf1-ec3542000000"
            },
            {
                "name": "Content-Length",
                "value": "321"
            },
            {
                "name": "Content-Type",
                "value": "application/xml"
            },
            {
                "name": "Date",
                "value": "Sun, 17 Jan 2021 16:53:28 GMT"
            },
            {
                "name": "Server",
                "value": "Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0"
            }
        ]
    }
}

Also, still newish to C#, so if something can be done better please let me know.

Upvotes: 2

Views: 8362

Answers (3)

errorline1
errorline1

Reputation: 432

Thanks to David's help to confirm that it was my error, I was incorrectly converting the key to generate the HMAC. Below is the correct code, notice the Base64 decode, whereas originally I was just getting the byte array:

string string_to_sign = string.Join("\n", new List<string>(values));
Console.WriteLine(string_to_sign.Replace("\n", "\\n"));
System.Security.Cryptography.HMACSHA256 hmac = new System.Security.Cryptography.HMACSHA256(System.Convert.FromBase64String(key));
var signature = System.Convert.ToBase64String(hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(string_to_sign)));

And then I can use it like so in the APIM Policy:

<set-variable name="x-request-body" value="@(context.Request.Body.As<string>())" />
<send-request mode="new" response-variable-name="tokenstate" timeout="20" ignore-error="true">
   <set-url>@(string.Format("https://example-account.blob.core.windows.net/example-container/test.json?{0}", context.Variables["sas_token"]))</set-url>
   <set-method>PUT</set-method>
   <set-header name="x-ms-date" exists-action="override">
     <value>@(DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"))</value>
   </set-header>
   <set-header name="x-ms-version" exists-action="override">
     <value>2019-02-02</value>
   </set-header>
   <set-header name="x-ms-blob-type" exists-action="override">
     <value>BlockBlob</value>
   </set-header>
   <set-body>@((string)context.Variables["x-request-body"])</set-body>
</send-request>

Upvotes: 1

suziki
suziki

Reputation: 14113

Azure Storage supports below authorizing method:

enter image description here

But SAS token can not be the Authorization header of REST API.

https://learn.microsoft.com/en-us/rest/api/storageservices/authorize-requests-to-azure-storage

I encapsulated several authentication methods:

using Azure.Storage;
using Azure.Storage.Sas;
using Microsoft.Azure.Services.AppAuthentication;
using System;
using System.IO;
using System.Net;
using System.Security.Cryptography;
using System.Text;

namespace ConsoleApp31
{
    class Program
    {
        static void Main(string[] args)
        {

            string storageKey = "xxxxxx";
            string storageAccount = "yourstorageaccountname";
            string containerName = "test";
            string blobName = "test.txt";
            string mimeType = "text/plain";

            string test = "This is a test of bowman.";
            byte[] byteArray = Encoding.UTF8.GetBytes(test);
            MemoryStream stream = new MemoryStream(byteArray);

            UseRestApiToUpload(storageKey,storageAccount,containerName,blobName,stream,mimeType);

            Console.WriteLine("*******");
            Console.ReadLine();
        }

        //Upload blob with REST API
        static void UseRestApiToUpload(string storageKey, string storageAccount, string containerName, string blobName, Stream stream, string mimeType)
        {
            string method = "PUT";
            long contentlength = stream.Length;

            string requestUri = $"https://{storageAccount}.blob.core.windows.net/{containerName}/{blobName}";

            HttpWebRequest request = (HttpWebRequest)WebRequest.Create(requestUri);

            string utcnow = DateTime.UtcNow.ToString("R");

            var memoryStream = new MemoryStream();
            stream.CopyTo(memoryStream);
            var content = memoryStream.ToArray();

            request.Method = method;

            request.Headers.Add("Content-Type", mimeType);
            request.Headers.Add("x-ms-version", "2019-12-12");
            request.Headers.Add("x-ms-date", utcnow);
            request.Headers.Add("x-ms-blob-type", "BlockBlob");
            request.Headers.Add("Content-Length", contentlength.ToString());

            //Use SharedKey to authorize.
            request.Headers.Add("Authorization", AuthorizationHeaderWithSharedKey(method, utcnow, request, storageAccount, storageKey, containerName, blobName));

            //Can not use SAS token in REST API header to authorize. 

            //Use Bearer token to authorize.
            //request.Headers.Add("Authorization",AuthorizationHeaderWithAzureActiveDirectory());



            using (Stream requestStream = request.GetRequestStream())
            {
                requestStream.Write(content, 0, (int)contentlength);
            }

            using (HttpWebResponse resp = (HttpWebResponse)request.GetResponse())
            {
            }

        }

        //Use shared key to authorize.
        public static string AuthorizationHeaderWithSharedKey(string method, string now, HttpWebRequest request, string storageAccount, string storageKey, string containerName, string blobName)
        {

            string headerResource = $"x-ms-blob-type:BlockBlob\nx-ms-date:{now}\nx-ms-version:2019-12-12";
            string urlResource = $"/{storageAccount}/{containerName}/{blobName}";
            string stringToSign = $"{method}\n\n\n{request.ContentLength}\n\n{request.ContentType}\n\n\n\n\n\n\n{headerResource}\n{urlResource}";

            HMACSHA256 hmac = new HMACSHA256(Convert.FromBase64String(storageKey));
            string signature = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(stringToSign)));

            String SharedKey = String.Format("{0} {1}:{2}", "SharedKey", storageAccount, signature);
            return SharedKey;
        }


        //Use Shared access signature(SAS) to authorize.
        public static string AuthorizationHeaderWithSharedAccessSignature(string storageAccount, string storageKey)
        {
            // Create a SAS token that's valid for one hour.
            AccountSasBuilder sasBuilder = new AccountSasBuilder()
            {
                Services = AccountSasServices.Blobs | AccountSasServices.Files,
                ResourceTypes = AccountSasResourceTypes.Service,
                ExpiresOn = DateTimeOffset.UtcNow.AddHours(1),
                Protocol = SasProtocol.Https
            };

            sasBuilder.SetPermissions(AccountSasPermissions.Read |
                AccountSasPermissions.Write);

            // Use the key to get the SAS token.
            StorageSharedKeyCredential key = new StorageSharedKeyCredential(storageAccount, storageKey);
            string sasToken = sasBuilder.ToSasQueryParameters(key).ToString();

            Console.WriteLine("SAS token for the storage account is: {0}", sasToken);
            Console.WriteLine();

            return sasToken;
        }


        //Use Azure Active Directory(Bearer token) to authorize.
        public static string AuthorizationHeaderWithAzureActiveDirectory()
        {
            AzureServiceTokenProvider azureServiceTokenProvider = new AzureServiceTokenProvider();
            string bearertoken = azureServiceTokenProvider.GetAccessTokenAsync("https://storage.azure.com/").Result;
            return "Bearer " + bearertoken;
        }
    }
}

Although the interaction between many software packages and azure is based on REST API, for operations like uploading blobs, I don't recommend you to use rest api to complete. Azure officially provides many packaged packages that you can use directly, such as:

https://learn.microsoft.com/en-us/dotnet/api/azure.storage.blobs?view=azure-dotnet

And example for .Net:

https://learn.microsoft.com/en-us/azure/storage/blobs/storage-quickstart-blobs-dotnet

In the above SDK, you can use sas token for authentication.

Upvotes: 2

David Browne - Microsoft
David Browne - Microsoft

Reputation: 89406

I don't think you can put a SAS token in an Authorization header. I can't find any relevant sample, so I used the Using the Azure.Storage.Blob C# client library from NuGet to do this

var data = System.Text.Encoding.UTF8.GetBytes("Hello Azure Storage");

var keyCred = new StorageSharedKeyCredential(account, key);
var sasBuilder = new AccountSasBuilder()
{
    Services = AccountSasServices.Blobs,
    ResourceTypes = AccountSasResourceTypes.Object,
    ExpiresOn = DateTimeOffset.UtcNow.AddHours(1),
    Protocol = SasProtocol.Https
};
sasBuilder.SetPermissions(AccountSasPermissions.All);
var sasToken = sasBuilder.ToSasQueryParameters(keyCred).ToString();

var blobClient = new BlobServiceClient(new Uri($"https://{account}.blob.core.windows.net/?{sasToken}"), null);

var containter = blobClient.GetBlobContainerClient("test");
containter.UploadBlob("test.txt", new MemoryStream(data));

Generates an HTTP request like this:

PUT https://xxxxxx.blob.core.windows.net/test/test.txt?sv=2020-04-08&ss=b&srt=o&spr=https&se=2021-01-17T18%3A13%3A55Z&sp=rwdxlacuptf&sig=RI9It3O6mcmw********S%2B1r91%2Bj5zGbk%3D HTTP/1.1
Host: xxxxxx.blob.core.windows.net
x-ms-blob-type: BlockBlob
x-ms-version: 2020-04-08
If-None-Match: *
x-ms-client-request-id: c6e93312-af95-4a04-a207-2e2062b1dd26
x-ms-return-client-request-id: true
User-Agent: azsdk-net-Storage.Blobs/12.8.0 (.NET Core 3.1.10; Microsoft Windows 10.0.19042)
Request-Id: |ffa2da23-45c79d128da40651.
Content-Length: 19

Hello Azure Storage

Then using the SAS token directly with WebClient,

var wc = new WebClient();
wc.Headers.Add("x-ms-blob-type: BlockBlob");
wc.UploadData($"https://{account}.blob.core.windows.net/test/test2.txt?{sasToken}", "PUT", data);

works too, which should be the minimal request:

PUT https://xxxxx.blob.core.windows.net/test/test2.txt?sv=2020-04-08&ss=b&srt=o&spr=https&se=2021-01-17T18%3A50%3A01Z&sp=rwdxlacuptf&sig=Fj4QVfwIfjXP10G%xxxxxxxx%2FF%2FcjikizKggY%3D HTTP/1.1
Host: xxxx.blob.core.windows.net
x-ms-blob-type: BlockBlob
Connection: Keep-Alive
Content-Length: 19

Hello Azure Storage

Removing the x-ms-blob-type header fails with:

The remote server returned an error: (400) An HTTP header that's mandatory for this request is not specified..

You are free to poke through the source code on GitHub for more details.

Upvotes: 1

Related Questions