AspNet.Core,IdentityServer 4:在使用 JWT 承载令牌与 SignalR 1.0 进行 websocket 握手期间未经授权 (401)

AspNet.Core, IdentityServer 4: Unauthorized (401) during websocket handshake with SignalR 1.0 using JWT bearer token

我有两个 aspnet.core 服务。一个用于 IdentityServer 4,一个用于 Angular4+ 客户端使用的 API。 SignalR 集线器在 API 上运行。整个解决方案在 docker 上运行,但这无关紧要(见下文)。

我使用了完美无缺的隐式身份验证流程。 NG 应用程序重定向到用户登录的 IdentityServer 登录页面。之后,浏览器使用访问令牌重定向回 NG 应用程序。然后使用令牌调用 API 并建立与 SignalR 的通信。我想我已经阅读了所有可用的内容(请参阅下面的资源)。

由于 SignalR 使用的 websockets 不支持 headers,令牌应该在查询字符串中发送。然后在 API 端提取令牌并为请求设置,就像在 header 中一样。然后验证令牌并授权用户。

API 的工作没有任何问题,用户获得授权并且可以在 API 端检索声明。所以 IdentityServer 应该没有问题,因为 SignalR 不需要任何特殊配置。我说得对吗?

当我不在 SignalR 集线器上使用 [授权] 属性时,握手 成功 。这就是为什么我认为我使用的 docker 基础架构和反向代理没有任何问题(代理设置为启用 websockets)。

所以,SignalR 在未经授权的情况下工作。经授权,NG 客户端在握手期间获得以下响应:

Failed to load resource: the server responded with a status of 401
Error: Failed to complete negotiation with the server: Error
Error: Failed to start the connection: Error

请求是

Request URL: https://publicapi.localhost/context/negotiate?signalr_token=eyJhbGciOiJSUz... (token is truncated for simplicity)
Request Method: POST
Status Code: 401 
Remote Address: 127.0.0.1:443
Referrer Policy: no-referrer-when-downgrade

我得到的回复:

access-control-allow-credentials: true
access-control-allow-origin: http://localhost:4200
content-length: 0
date: Fri, 01 Jun 2018 09:00:41 GMT
server: nginx/1.13.10
status: 401
vary: Origin
www-authenticate: Bearer

根据日志,令牌验证成功。我可以包括完整的日志,但我怀疑问题出在哪里。所以我将在此处包括该部分:

[09:00:41:0561 Debug] Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler AuthenticationScheme: Identity.Application was not authenticated.
[09:00:41:0564 Debug] Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler AuthenticationScheme: Identity.Application was not authenticated.

我在日志文件中得到了这些,但我不确定这意味着什么。我将代码部分包含在 API 中,我在其中获取并提取令牌以及身份验证配置。

services.AddAuthentication(options =>
    {
        options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
        options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        options.DefaultForbidScheme = JwtBearerDefaults.AuthenticationScheme;
        options.DefaultSignInScheme = JwtBearerDefaults.AuthenticationScheme;
        options.DefaultSignOutScheme = JwtBearerDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    }).AddIdentityServerAuthentication(options =>
        {
            options.Authority = "http://identitysrv";
            options.RequireHttpsMetadata = false;
            options.ApiName = "publicAPI";
            options.JwtBearerEvents.OnMessageReceived = context =>
            {
                if (context.Request.Query.TryGetValue("signalr_token", out StringValues token))
                {
                    context.Options.Authority = "http://identitysrv";
                    context.Options.Audience = "publicAPI";
                    context.Token = token;
                    context.Options.Validate();
                }

                return Task.CompletedTask;
            };
        });

系统无其他错误、异常。我可以调试应用程序,一切似乎都很好。

包含的日志行是什么意思? 如何调试授权过程中发生的事情?

编辑: 我差点忘了提到,我认为问题出在身份验证方案上,所以我将每个方案都设置为我认为需要的方案。然而遗憾的是它没有帮助。

我在这里有点无能,所以我很感激任何建议。谢谢。

信息来源:

Pass auth token to SignalR

Securing SignalR with IdentityServer

Microsoft docs on SignalR authorization

Another GitHub question

Authenticate against SignalR

Identity.Application was not authenticated

我必须回答我自己的问题,因为我有截止日期,令人惊讶的是我设法解决了这个问题。所以我把它写下来希望它能在将来帮助到某人。

首先我需要了解发生了什么,所以我将整个授权机制替换为我自己的。我可以通过这段代码来做到这一点。解决方案不需要它,但是如果有人需要它,这就是解决方法。

services.Configure<AuthenticationOptions>(options =>
{
    var scheme = options.Schemes.SingleOrDefault(s => s.Name == JwtBearerDefaults.AuthenticationScheme);
    scheme.HandlerType = typeof(CustomAuthenticationHandler);
});

IdentityServerAuthenticationHandler 的帮助下并覆盖所有可能的方法,我终于明白了 OnMessageRecieved 事件是在检查令牌后执行的。因此,如果在调用 HandleAuthenticateAsync 期间没有任何令牌,则响应将是 401。这帮助我弄清楚将自定义代码放在哪里。

我需要在令牌检索期间实施我自己的“协议”。所以我更换了那个机制。

services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;                
}).AddIdentityServerAuthentication(JwtBearerDefaults.AuthenticationScheme,
    options =>
    {
        options.Authority = "http://identitysrv";
        options.TokenRetriever = CustomTokenRetriever.FromHeaderAndQueryString;
        options.RequireHttpsMetadata = false;
        options.ApiName = "publicAPI";
    });

重要的部分是 TokenRetriever 属性 以及替代它的东西。

public class CustomTokenRetriever
{
    internal const string TokenItemsKey = "idsrv4:tokenvalidation:token";
    // custom token key change it to the one you use for sending the access_token to the server
    // during websocket handshake
    internal const string SignalRTokenKey = "signalr_token";

    static Func<HttpRequest, string> AuthHeaderTokenRetriever { get; set; }
    static Func<HttpRequest, string> QueryStringTokenRetriever { get; set; }

    static CustomTokenRetriever()
    {
        AuthHeaderTokenRetriever = TokenRetrieval.FromAuthorizationHeader();
        QueryStringTokenRetriever = TokenRetrieval.FromQueryString();
    }

    public static string FromHeaderAndQueryString(HttpRequest request)
    {
        var token = AuthHeaderTokenRetriever(request);

        if (string.IsNullOrEmpty(token))
        {
            token = QueryStringTokenRetriever(request);
        }

        if (string.IsNullOrEmpty(token))
        {
            token = request.HttpContext.Items[TokenItemsKey] as string;
        }

        if (string.IsNullOrEmpty(token) && request.Query.TryGetValue(SignalRTokenKey, out StringValues extract))
        {
            token = extract.ToString();
        }

        return token;
    }

这是我的自定义令牌检索算法,它首先尝试标准 header 和查询字符串以支持常见情况,例如 Web API 调用。但是如果令牌仍然是空的,它会尝试从客户端在 websocket 握手期间放置它的查询字符串中获取它。

编辑:我使用以下客户端 (TypeScript) 代码来为 SignalR 握手提供令牌

import { HubConnection, HubConnectionBuilder, HubConnectionState } from '@aspnet/signalr';
// ...
const url = `${apiUrl}/${hubPath}?signalr_token=${accessToken}`;
const hubConnection = new HubConnectionBuilder().withUrl(url).build();
await hubConnection.start();

其中apiUrl、hubPath和accessToken为连接所需的参数。

让我把我的两分钱花在这上面。我认为我们大多数人都将令牌存储在 cookie 中,并且在 WebSockets 握手期间它们也会发送到服务器,因此我建议使用从 cookie 检索令牌。

要做到这一点,请在最后一个 if 语句下面添加:

if (string.IsNullOrEmpty(token) && request.Cookies.TryGetValue(SignalRCookieTokenKey, out string cookieToken))
{
    token = cookieToken;
}

实际上我们完全可以从查询字符串中删除检索,因为根据 Microsoft docs 这不是真正安全的并且可以在某处记录。

我知道这是一个旧线程,但以防有人像我一样偶然发现它。我找到了替代解决方案。

TLDR:JwtBearerEvents.OnMessageReceived,如下图所示使用时将在检查之前捕获令牌:

public void ConfigureServices(IServiceCollection services)
{
    // Code removed for brevity
    services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
    .AddIdentityServerAuthentication(options =>
    {
        options.Authority = "https://myauthority.io";
        options.ApiName = "MyApi";
        options.JwtBearerEvents = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                var accessToken = context.Request.Query["access_token"];

                // If the request is for our hub...
                var path = context.HttpContext.Request.Path;
                if (!string.IsNullOrEmpty(accessToken) &&
                    (path.StartsWithSegments("/hubs/myhubname")))
                {
                    // Read the token out of the query string
                    context.Token = accessToken;
                }
                return Task.CompletedTask;
            }
        };
    });
}

这个微软文档给了我一个提示: https://docs.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz?view=aspnetcore-3.1。但是,在 Microsoft 示例中,调用了 options.Events,因为它不是使用 IdentityServerAuthentication 的示例。如果 options.JwtBearerEvents 的使用方式与 Microsoft 示例中的 options.Events 相同,那么 IdentityServer4 很高兴!