ASP.NET Core中的JWT在URI查询参数中使用?

21

我有一个API,由JWT和授权属性进行保护,在客户端上,我使用jQuery AJAX调用来处理它。

这很好用,但是现在我需要能够安全地下载文件,因此我不能设置标题Bearer值,它可以作为URL参数在URI中完成吗?

=-=-=-=-

更新:这就是我最终为我的场景所做的事情,这是一个内部项目,交易量非常低,但安全性很重要,未来可能需要扩展:

当用户登录时,我生成一个随机的下载密钥,并将其放入他们的用户记录中,包括其JWT的到期日期,并将下载密钥返回给客户端。下载路由受保护,只允许下载如果有一个查询参数具有下载密钥,并且该密钥存在于用户记录中并且到期日期尚未过去。这样dl密钥对于每个用户是唯一的,在用户的身份验证会话有效期内有效,并且可以轻松撤销。

5个回答

27

这是一个常见的问题。

每当您想要在单页应用程序的HTML中直接引用API中的图像或其他文件时,没有办法在<img><a>元素和对API的请求之间注入Authorization请求头。您可以通过使用一些相当新的浏览器功能来绕过此问题,如此处所述,但您可能需要支持缺乏此功能的浏览器。

幸运的是,RFC 6750指定了一种通过"URI查询参数"身份验证方法实现您所要求的方式。如果遵循其约定,您将接受以下格式的JWT:

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

如另一个答案和RFC 6750本身所述,您应该仅在必要时才这样做。来自RFC的原话:

由于与URI方法相关的安全漏洞(请参见第5节),包括记录包含访问令牌的URL的可能性很高,因此除非无法在“Authorization”请求头字段或HTTP请求实体正文中传输访问令牌,否则不应使用它。

如果您仍然决定实现"URI查询参数"身份验证,您可以使用Invio.Extensions.Authentication.JwtBearer库,并在JwtBearerOptions上调用AddQueryStringAuthentication()扩展方法。或者,如果您想手动实现,也可以这样做。下面是一个代码示例,展示了作为Microsoft.AspNetCore.Authentication.JwtBearer库的扩展的两种方法。

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;
                    }
                };
            }
        );
}

尽管有一些批评声音,但这样做确实有其合理的原因。例如,在Google Cloud PubSub的“Push”实现中,这实际上是保护终端节点的推荐方式。https://cloud.google.com/pubsub/docs/faq#security - Technetium
太好了!不要忘记在Configure方法中添加UseAuthentication(就像我一开始做的那样)。您还可以使用OnTokenValidated事件添加自定义声明,请参见https://joonasw.net/view/adding-custom-claims-aspnet-core-2,其中包含Open Id Connect的示例。 - Johan Maes
这个能在没有在URL上指定时与标头中的令牌一起工作吗?在我的情况下,我在到达OnMessageReceive事件之前就被拒绝了。 - anon
非常好的解决方案,谢谢。代码示例缺少 app.UseJwtBearerQueryString() 语句。 - Karel Kral

15
您可以使用中间件从查询参数设置授权头:
        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);
            }
        }

接着在 Startup.cs 文件中,在身份验证中间件之前使用中间件:

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

8
虽然在URL中包含JWT理论上是可行的,但强烈不建议这样做。请参考此处的引述,解释为什么这是一个坏主意:

不要在页面URL中传递Bearer tokens:Bearer tokens 不应该在页面URL中传递(例如,作为查询字符串参数)。相反,应采取保密措施,将Bearer tokens传递到HTTP消息头或消息体中。浏览器、Web服务器和其他软件可能无法充分保护浏览器历史记录、Web服务器日志和其他数据结构中的URL。如果Bearer tokens在页面URL中传递,攻击者可能能够从历史数据、日志或其他不安全的位置窃取它们。

但是,如果你别无选择或者不关心安全实践,请参考Technetium的答案


嗯...邏輯上說得通,但我發誓在標準中讀到他們明確提到使用URI參數來傳遞令牌;如果我不做任何事情,我仍然會面臨一個不安全的下載路線。也許可以通過某種API調用來獲取某種唯一密鑰,然後將其用於下載路線。 - JohnC
糟糕,这不是标准中的内容,而是在jwt.io介绍页面上:https://jwt.io/introduction/ 他们说:“紧凑型:由于它们较小的尺寸,JWT可以通过URL、POST参数或HTTP头部发送。此外,较小的尺寸意味着传输速度快。”但你说的也有道理。 - JohnC
1
保护下载的常见方法类似于Azure的共享访问签名。请查看链接以了解其工作原理,并查看是否能为您的用例提供灵感。 - Kirk Larkin

1
如果您仍需要它,您需要在localStorage上设置jwt token。之后,您需要使用以下代码创建一个新的头部:
'functionName'():Headers{
        let header =new Headers();
        let token = localStorage.getItem('token')
        header.append('Authorization',`Bearer ${token}`);

        return header;
    }

在HTTP请求中添加头部信息。
return this.http.get('url',new RequestOptions({headers:this.'serviceName'.'functionName'()}))

-1

虽然这有点超出常规,但我建议您做同样的事情,因为在.NET环境下开发时,这是最好的可扩展解决方案。

使用Azure存储!或任何其他类似的在线云存储解决方案。

  1. 它确保您的Web应用程序与文件分离,因此您不必担心将应用程序移动到不同的Web环境中。
  2. Web存储通常比Azure存储更昂贵(1GB大约需要3000个操作(读/写/列出),总共约为$0.03)。
  3. 当您扩展应用程序且停机时间更为关键时,使用交换/暂存技术时也适用于第1点。
  4. Azure存储负责过期所谓的Shared Access Tokens (SAS)

为了简化您的工作,我将在此处包含我的代码,以便您无需搜索其余部分

因此,在我的情况下,我保存所有文件作为数据库中的附件(当然不是实际文件)。

当有人请求附件时,我会快速检查过期日期是否已过,如果是,则应生成新的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 是一个内部枚举,用于轻松跟踪存储某些文件的容器,但这些当然可以是字符串

而我的 AzureStorage 类:

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;
}

因此,它将生成以下URL: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

当然,在检索附件时,您必须应用自己的身份验证逻辑,以确定用户是否被允许查看文件。但是,这一切都可以通过JWT令牌在控制器或存储库中完成。如果有人能够得到URL,我不会担心URL是公共URL,如果他们能够在一个小时内得到它...那么就缩短过期日期:D


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接