21

I have an api that is protected by JWT and Authorize attribute and at the client I use jquery ajax call to deal with it.

This works fine, however I now need to be able to secure downloading of files so I can't set a header Bearer value, can it be done in the URI as an url parameter?

=-=-=-=-

UPDATE: This is what I ended up doing for my scenario which is an in-house project and very low volume but security is important and it might need to scale in future:

When user logs in I generate a random download key and put it in their user record in the db along with the expiry date of their JWT and return the download key to the client. The download route is protected to only allow a download if there is a query parameter that has the download key and that key exists in the user records and that expiry date has not passed. This way the dl key is unique per user, valid as long as the user's auth session is valid and can be revoked easily.

JohnC
  • 3,938
  • 7
  • 41
  • 48

5 Answers5

27

This is a common problem.

Whenever you want to reference images or other files directly from an API in a single page application's HTML, there isn't a way to inject the Authorization request header between the <img> or <a> element and the request to the API. You can sidestep this by using some fairly new browser features as described here, but you may need to support browsers that lack this functionality.

Fortunately, RFC 6750 specifies a way to do exactly what you're asking via the "URI Query Parameter" authentication approach. If you follow its convention, you would accept JWTs using the following format:

https://server.example.com/resource?access_token=mF_9.B5f-4.1JqM&p=q

As stated in another answer and in RFC 6750 itself, you should be doing this only when necessary. From the RFC:

Because of the security weaknesses associated with the URI method (see Section 5), including the high likelihood that the URL containing the access token will be logged, it SHOULD NOT be used unless it is impossible to transport the access token in the "Authorization" request header field or the HTTP request entity-body.

If you still decide to implement "URI Query Parameter" authentication, you can use the Invio.Extensions.Authentication.JwtBearer library and call AddQueryStringAuthentication() extension method on JwtBearerOptions. Or, if you want to do it manually, you can certainly do that as well. Here's a code sample that shows both ways as extensions of the Microsoft.AspNetCore.Authentication.JwtBearer library.

public void ConfigureServices(IServiceCollection services) {
    services
        .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(
            options => {
                var authentication = this.configuration.GetSection("Authentication");

                options.TokenValidationParameters = new TokenValidationParameters {
                    ValidIssuers = authentication["Issuer"],
                    ValidAudience = authentication["ClientId"],
                    IssuerSigningKey = new SymmetricSecurityKey(
                        Encoding.UTF8.GetBytes(authentication["ClientSecret"])
                    )
                };

                // OPTION 1: use `Invio.Extensions.Authentication.JwtBearer`

                options.AddQueryStringAuthentication();

                // OPTION 2: do it manually

                options.Events = new JwtBearerEvents {
                    OnMessageReceived = (context) => {
                        StringValues values;

                        if (!context.Request.Query.TryGetValue("access_token", out values)) {
                            return Task.CompletedTask;
                        }

                        if (values.Count > 1) {
                            context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
                            context.Fail(
                                "Only one 'access_token' query string parameter can be defined. " +
                                $"However, {values.Count:N0} were included in the request."
                            );

                            return Task.CompletedTask;
                        }

                        var token = values.Single();

                        if (String.IsNullOrWhiteSpace(token)) {
                            context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
                            context.Fail(
                                "The 'access_token' query string parameter was defined, " +
                                "but a value to represent the token was not included."
                            );

                            return Task.CompletedTask;
                        }

                        context.Token = token;

                        return Task.CompletedTask;
                    }
                };
            }
        );
}
Community
  • 1
  • 1
Technetium
  • 5,902
  • 2
  • 43
  • 54
  • Despite all the finger waging, there are valid reasons to do this. For example, it's actually the recommended way to protect endpoints for Google Cloud PubSub's "Push" implementation. https://cloud.google.com/pubsub/docs/faq#security – Technetium Nov 14 '18 at 07:31
  • Great! Don't forget to add UseAuthentication in the Configure method (like I did at first). You can also add custom claims with the event OnTokenValidated, see https://joonasw.net/view/adding-custom-claims-aspnet-core-2 for an example with Open Id Connect. – Johan Maes May 09 '19 at 11:16
  • Can this work alongside token in the headers when not specified on the URL ? In my case I am rejected before reaching the OnMessageReceive event –  Aug 21 '19 at 09:36
  • Great solution, thank you. Code example is missing `app.UseJwtBearerQueryString()` statement. – Karel Kral Feb 12 '20 at 10:59
14

You can use a middleware to set the authorization header from the query param:

        public class SecureDownloadUrlsMiddleware
        {
            private readonly RequestDelegate next;

            public SecureDownloadUrlsMiddleware(RequestDelegate next)
            {
                this.next = next;
            }

            public async Task Invoke(HttpContext context /* other dependencies */)
            {
                // get the token from query param
                var token = context.Request.Query["t"];
                // set the authorization header only if it is empty
                if (string.IsNullOrEmpty(context.Request.Headers["Authorization"]) &&
                    !string.IsNullOrEmpty(token))
                {
                    context.Request.Headers["Authorization"] = $"Bearer {token}";
                }
                await next(context);
            }
        }

and then in Startup.cs use the middleware before the authentication middleware:

app.UseMiddleware(typeof(SecureDownloadUrlsMiddleware));
app.UseAuthentication();
8

Although it is technically possible to include a JWT in the URL, it is strongly discouraged. See the quote from here, which explains why it's a bad idea:

Don't pass bearer tokens in page URLs: Bearer tokens SHOULD NOT be passed in page URLs (for example, as query string parameters). Instead, bearer tokens SHOULD be passed in HTTP message headers or message bodies for which confidentiality measures are taken. Browsers, web servers, and other software may not adequately secure URLs in the browser history, web server logs, and other data structures. If bearer tokens are passed in page URLs, attackers might be able to steal them from the history data, logs, or other unsecured locations.

However, if you have no choice or just don't care about security practices, see Technetium's answer.

Kirk Larkin
  • 84,915
  • 16
  • 214
  • 203
  • Hmmm...that makes logical sense, though I swear I read in the standard that they explicitly mention uri paramters to pass the token; I'm still left with an insecure download route if I don't do something. Maybe some kind of api call to get some kind of unique key that then can be used against the download route. – JohnC Aug 19 '17 at 02:41
  • Whups, it wasn't in the standard, it was on the jwt.io intro page: https://jwt.io/introduction/ Where they state: "Compact: Because of their smaller size, JWTs can be sent through a URL, POST parameter, or inside an HTTP header. Additionally, the smaller size means transmission is fast." But what you say makes sense. – JohnC Aug 19 '17 at 02:45
  • 1
    A common approach to securing the download is something like Azure's [Shared Access Signature](https://learn.microsoft.com/en-us/azure/storage/storage-dotnet-shared-access-signature-part-1). Have a look at the link to get the general idea of how that works and see if that inspires a solution for your use-case. – Kirk Larkin Aug 19 '17 at 07:59
1

If you still need it,you have to set jwt token on localStorage.After,you have to create a new header with the following code:

'functionName'():Headers{
        let header =new Headers();
        let token = localStorage.getItem('token')
        header.append('Authorization',`Bearer ${token}`);

        return header;
    }

Add Hader to http requests.

return this.http.get('url',new RequestOptions({headers:this.'serviceName'.'functionName'()}))
-1

Although this is a bit outside of the box, I would advice you to do the same as this is the best scalable solution when developing in the .NET environment.

Use Azure Storage! Or any other similar online cloud storage solution.

  1. It makes sure your web app is separate from your files, so you don't have to worry about moving an application to a different web environment.
  2. Web storage is mostly more expensive then azure storage (1GB with about 3000 operations (read/write/list) costs in total about $0.03.
  3. When you scale your application where downtime is more critical, point 1 also applies when you use a swapping/staging technique.
  4. Azure storage takes care of the expiry of so called Shared Access Tokens (SAS)

For the sake of simplicity for you, I will just include my code here so you don't have to google the rest

So what I do in my case, all my files are saved as Attachments within the database (not the actual file of course).

When someone requests an attachment, I do a quick check to see if the expire date has passed and if so we should generate a new url.

//where ever you want this to happen, in the controller before going to the client for example
private async Task CheckSasExpire(IEnumerable<AttachmentModel> attachments)
{
    foreach (AttachmentModel attachment in attachments)
    {
        await CheckSasExpire(attachment);
    }
}
private async Task CheckSasExpire(AttachmentModel attachment)
{
    if (attachment != null && attachment.LinkExpireDate < DateTimeOffset.UtcNow && !string.IsNullOrWhiteSpace(attachment.AzureContainer))
    {
        Enum.TryParse(attachment.AzureContainer, out AzureStorage.ContainerEnum container);
        string url = await _azureStorage.GetFileSasLocator(attachment.Filename, container);
        attachment.FileUrl = url;
        attachment.LinkExpireDate = DateTimeOffset.UtcNow.AddHours(1);
        await _attachmentRepository.UpdateAsync(attachment.AttachmentId, attachment);
    }
}

AzureStorage.ContainerEnum is just an internal enum to easily track the container certain files are stored in, but these can be strings of course

And my AzureStorage class:

using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Blob;
public async Task<string> GetFileSasLocator(string filename, ContainerEnum container, DateTimeOffset expire = default(DateTimeOffset))
{
    var cont = await GetContainer(container);
    CloudBlockBlob blockBlob = cont.GetBlockBlobReference(filename);
    DateTimeOffset expireDate = DateTimeOffset.UtcNow.AddHours(1);//default
    if (expire != default(DateTimeOffset) && expire > expireDate)
    {
        expireDate = expire.ToUniversalTime();
    }

    SharedAccessBlobPermissions permission = SharedAccessBlobPermissions.Read;
    var sasConstraints = new SharedAccessBlobPolicy
    {
        SharedAccessStartTime = DateTime.UtcNow.AddMinutes(-30),
        SharedAccessExpiryTime = expireDate,
        Permissions = permission
    };
    var sasToken = blockBlob.GetSharedAccessSignature(sasConstraints);
    return blockBlob.Uri + sasToken;
}

private async Task<CloudBlobContainer> GetContainer(ContainerEnum container)
{
    //CloudConfigurationManager.GetSetting("StorageConnectionString")
    CloudStorageAccount storageAccount = CloudStorageAccount.Parse(_config["StorageConnectionString"]);
    CloudBlobClient blobClient = storageAccount.CreateCloudBlobClient();
    string containerName = container.ToString().ToLower();
    CloudBlobContainer cloudContainer = blobClient.GetContainerReference(containerName);
    await cloudContainer.CreateIfNotExistsAsync();
    return cloudContainer;
}

So this will produce url's like so: http://127.0.0.1:10000/devstoreaccount1/invoices/NL3_2002%20-%202019-04-12.pdf?sv=2018-03-28&sr=b&sig=gSiohA%2BGwHj09S45j2Deh%2B1UYP1RW1Fx5VGeseNZmek%3D&st=2019-04-18T14%3A16%3A55Z&se=2019-04-18T15%3A46%3A55Z&sp=r

Of course you have to apply your own authentication logic when retrieving the attachments, if the user is allowed to view the file or not. But that can all be done with the JWT token and in the controller or the repository. I wouldn't worry about the URL being a public url, if one is so mighty to get that URL... within one hour... well then reduce the expire date :D

CularBytes
  • 9,924
  • 8
  • 76
  • 101