Create Single Use URIs to Protect Your Resources!

August 27, 2013 — 3 Comments

Crime-Control-Security-Services-23Finding the problem, of controlling access to specific resources for a certain amount of time and for a certain amount of downloads, to be quite interesting. So I decided to design a solution that would take advantage of the services made available by the Windows Azure platform.

The Challenge

Create single use URIs that invalidate after 15 minutes.

Why?

Companies that sell digital products often try to limit the number of downloads per purchase. Consequently, discouraging customers from sharing their download links becomes a priority. By creating public URIs constrained by a time to live and by a limited number downloads, can help accomplish this goal.

The Concept

Building on the Valet Key Pattern explored in “Keep Your Privates Private!” I used Shared Access Signatures to create a time to live for all public URIs. Then to control access to the actual resources I created a REST service using Web API, which tracks individual access to each URI using the Windows Azure Table Storage Service.

Each public URI is generated and tracked by the REST service. The URI has a maximum number of downloads. Access to the actual resource is limited by the Shared Access Signature.

When a consumer tries to access a resource that is no longer available, they will receive a 403 forbidden HTTP Status Code.

creating a URI is as simple as calling the REST Service

http://localhost:3027/api/create/image.png

The resulting URI will look like this. It contains a GUID that identifies the private Shared Access Signature and the resource name. This allows the REST Service to find the appropriate Shared Access Signature and validate that the resource can still be accessed.

http://localhost:3027/api/d421b98b-519a-4c6a-b255-e9dc2e16de7c/image.png

When the client requests a resource from the REST Service using a public URI, the service first validates that the URI is valid. A URI is considered valid if it is found in the table and if the maximum number of downloads has not been exceeded. Then the service will attempt to download the resource from the Windows Azure Blob Storage Service using the Shared Access Signature and if the resource is accessible, it will be returned to the client.

8-28-2013 2-53-51 AM

Tracking the URIs is done by keeping track of downloads by storing the generated URIs in Windows Azure Table Storage Service. The tracking table structure is quite simple. It tracks each URI as a unique entry, they have a maximum number of downloads and the number of time the resource was downloaded. It also contains the Shared Access Signature required to access the resource.

I chose the resource name as the Partition key so that its GUIDs would be grouped together. If you want to know more about working with the Windows Azure Table Storage Service, I recommend reading about the Windows Azure Storage Best Practices.

8-28-2013 2-26-21 AM

 

Web API Controller

The controller is quite straightforward. It creates a DownloadGate  instance by specifying the Windows Azure Blob Container name and the AppSettings key for the Windows Azure Storage Account Connection String.

The GetUri will creates a public URI for the client and GetResource is responsible for controlling access to the private resource.

public class DownloadController
    : ApiController
{
    private readonly DownloadGate gate;

    public DownloadController()
    {
        gate = new DownloadGate("files", "StorageConnectionString");
    }

    public string GetUri([FromUri]string resource)
    {
        var maxDownloads = 1;
        var expiresInMinutes = 15;

        var key = gate.CreateAccessKey(resource,
                                       maxDownloads,
                                       expiresInMinutes);

        return Url.Link("DownloadResource", new {resource, key });
    }

    public async Task<HttpResponseMessage> GetResource([FromUri]string resource,
                                                       [FromUri]string key)
    {
        try
        {
            return await gate.GetContentHttpResponse(resource, key);
        }
        catch
        {
            return new HttpResponseMessage(HttpStatusCode.Forbidden);
        }
    }
}

 

Download Gate

The Download Gate is where all the magic happens. It creates and maintains the tracking system. It is responsible for generating public URIs. It is also responsible for validating access to resources and streaming resources back to the client.

public class DownloadGate
{
    private const string MAX_DOWNLOADS_KEY = "MaxDownloads";
    private const string SAS_KEY = "SAS";
    private const string DOWNLOADS_KEY = "Downloads";

    private readonly string containerName;
    private readonly CloudBlobContainer container;
    private readonly CloudTable table;

    public DownloadGate(string containerName, string connectionString)
    {
        this.containerName = containerName;

        var cs = CloudConfigurationManager.GetSetting(connectionString);
        var account = CloudStorageAccount.Parse(cs);

        var client = account.CreateCloudBlobClient();
        var tableClient = account.CreateCloudTableClient();

        table = tableClient.GetTableReference(containerName + "DownloadGate");
        table.CreateIfNotExists();

        container = client.GetContainerReference(containerName);
        container.CreateIfNotExists();
    }

    public string ContainerName
    {
        get { return containerName; }
    }

    public async Task<HttpResponseMessage> GetContentHttpResponse(string resource,
                                                                  string key)
    {
        var blockBlobReference = container.GetBlockBlobReference(resource);
        blockBlobReference.FetchAttributes();

        var sas = GetSharedAccessSignature(resource, key);

        var downloadUri = new Uri(blockBlobReference.Uri.AbsoluteUri + sas);

        return await Task.Factory.StartNew(() =>
        {
            var webClient = new WebClient();

            var bytes = webClient.DownloadData(downloadUri);

            var httpResponseMessage = new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new PushStreamContent((stream, content, arg3) =>
                {
                    stream.Write(bytes, 0, bytes.Length);
                    stream.Close();
                }, new MediaTypeHeaderValue(blockBlobReference.Properties.ContentType))
            };
            return httpResponseMessage;
        });
    }

    public string CreateAccessKey(string resource,
                                  int maxDownloads,
                                  int expiresInMinutes)
    {
        var minutes = TimeSpan.FromMinutes(expiresInMinutes);
       
        var expiryTime = DateTime.UtcNow.Add(minutes);

        var sharedAccessBlobPolicy = new SharedAccessBlobPolicy
        {
            Permissions = SharedAccessBlobPermissions.Read,
            SharedAccessStartTime = null,
            SharedAccessExpiryTime = expiryTime,
        };

        var blockBlobReference = container.GetBlockBlobReference(resource);
        var sas = blockBlobReference.GetSharedAccessSignature(sharedAccessBlobPolicy);

        var key = Guid.NewGuid().ToString();

        CreatetrakingEntry(resource, sas,maxDownloads, key);

        return key;
    }

    public string GetSharedAccessSignature(string resource, string key)
    {
        var partitionFilter = TableQuery.GenerateFilterCondition("PartitionKey",
                                        QueryComparisons.Equal,
                                        resource);

        var primaryKeyFilter = TableQuery.GenerateFilterCondition("RowKey",
                                        QueryComparisons.Equal,
                                        key);
        var query = new TableQuery
        {
            FilterString = TableQuery.CombineFilters(partitionFilter,
                                                     TableOperators.And,
                                                      primaryKeyFilter)
        };

        var entity = table.ExecuteQuery(query).FirstOrDefault();

        if (entity == null)
            return string.Empty;

        var downloads = entity.Properties[DOWNLOADS_KEY].Int32Value;
        var maxDownloads = entity.Properties[MAX_DOWNLOADS_KEY].Int32Value;
        var sas = entity.Properties[SAS_KEY].StringValue;

        var allowDownload = downloads.HasValue &&
                            maxDownloads.HasValue &&
                            !string.IsNullOrEmpty(sas) &&
                            downloads.Value < maxDownloads;
       
        if (!allowDownload)
            return string.Empty;
       
        entity.Properties[DOWNLOADS_KEY] = new EntityProperty((downloads ?? 0) + 1);

        var updateOperation = TableOperation.InsertOrReplace(entity);
        table.Execute(updateOperation);

        return sas;
    }

    private void CreatetrakingEntry(string resource, string sas, int maxDownloads, string key)
    {
        var entry = new DynamicTableEntity(resource, key);

        entry.Properties.Add(DOWNLOADS_KEY,new EntityProperty(0));
        entry.Properties.Add(MAX_DOWNLOADS_KEY, new EntityProperty(maxDownloads));
        entry.Properties.Add(SAS_KEY, new EntityProperty(sas));

        var operation = TableOperation.Insert(entry);
        table.Execute(operation);
    }
}

 

Web API Route Configuration

 

config.Routes.MapHttpRoute(
    name: "DownloadCreate",
    routeTemplate: "api/create/{resource}",
    defaults: new { controller = "Download", action = "GetUri" }
);
           
config.Routes.MapHttpRoute(
    name: "DownloadResource",
    routeTemplate: "api/{key}/{resource}",
    defaults: new { controller = "Download", action = "GetResource" }
);

 

Next Steps

This code does not come with any guaranties. Bugs are possible and I will be happy to fix them. Please keep in mind that work on this code is done on my personal time.

Now that formalities are a side. Please consider the following if you wish to use this code in your solution.

  • Solidify the code by adding validations
  • Add more explicit exceptions that will allow your users and developer to better understand when something goes wrong
  • Think about adding an expiration date to the tracking date to help clear out invalid URIs
  • Implement a service that will regularly purge invalid URIs after a certain amount of time. Be sure to keep URIs around for a short while, this will greatly help you in your debugging efforts.

3 responses to Create Single Use URIs to Protect Your Resources!

  1. 

    Again a great post.. I have been through one of the pluralsight course for Windows Azure which was having initial briefing about shared access rights.. and I must say your blog is really more than add-on to that… I would wish that you could be part of Pluralsight team for some addition to Windows Azure..

    Like

Trackbacks and Pingbacks:

  1. Reading Notes 2013-09-01 | Matricis - September 3, 2013

    […] Create Single Use URIs to Protect Your Resources! – Great tutorial. I like the idea of "real" scenario. […]

    Like

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.