如何使用IdentityServer 4对我的SignalR客户端进行身份验证

3
在我的客户端中,我使用这段代码来获取令牌,"http://localhost:5006" 是我的 IdentityServer(授权服务器)。
var httpClient = new HttpClient();
var discoveryDocument = httpClient.GetDiscoveryDocumentAsync("http://localhost:5006").Result;
var tokenResponse = httpClient.RequestClientCredentialsTokenAsync(
    new ClientCredentialsTokenRequest
    {
        Address = discoveryDocument.TokenEndpoint,
        ClientId = "client",
        ClientSecret = "Prevo100",
        Scope = "prevo100-api"
    }).Result;

我的SignalR Hub是http://localhost:5119/Prevo100,当我运行StartAsync时,我遇到了以下异常:
System.AggregateException: '发生了一个或多个错误。(响应状态代码未指示成功:403(禁止)。)'
我也可能遇到401错误!
var url = "http://localhost:5119/Prevo100";

HubConnection connection = new HubConnectionBuilder()
    .WithUrl(url, options =>
    {
        options.AccessTokenProvider = () => Task.FromResult(tokenResponse.AccessToken);
    })
    .WithAutomaticReconnect()
    .Build();

var client = new Prevo100WebClient(connection);

connection.StartAsync().Wait(); // <== Exception here

在SignalR服务器(hub)中,我使用以下代码:
public void ConfigureServices(IServiceCollection services)
{
    services.AddAuthentication("Bearer")
        .AddJwtBearer("Bearer", options =>
        {
            options.Authority = "http://localhost:5006";
            options.RequireHttpsMetadata = false;

            options.Audience = "prevo100-api";

            options.TokenValidationParameters =
            new Microsoft.IdentityModel.Tokens.TokenValidationParameters
            {
                ValidateAudience = false
            };

            // We have to hook the OnMessageReceived event in order to
            // allow the JWT authentication handler to read the access
            // token from the query string when a WebSocket or 
            // Server-Sent Events request comes in.

            // Sending the access token in the query string is required when using WebSockets or ServerSentEvents
            // due to a limitation in Browser APIs. We restrict it to only calls to the
            // SignalR hub in this code.
            // See https://docs.microsoft.com/aspnet/core/signalr/security#access-token-logging
            // for more information about security considerations when using
            // the query string to transmit the access token.
            options.Events = 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("/Prevo100"))
                    {
                        // Read the token out of the query string
                        context.Token = accessToken;
                    }
                    return Task.CompletedTask;
                }
            };
        });

    services.AddSignalR(hubOptions =>
    {
        //hubOptions.ClientTimeoutInterval // 30 secondes par défaut
    });
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseHttpsRedirection();
    app.UseStaticFiles();
    app.UseFileServer();
    app.UseRouting();

    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapHub<Prevo100Hub>("/Prevo100");
    });
}

我给hub类添加了一个属性。
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public class Prevo100Hub : Hub<IPrevo100Client>, IHubContract
{
//....
}

我在这里有一篇关于解决JwtBearer问题的博客文章,链接是https://nestenius.se/2023/02/21/troubleshooting-jwtbearer-authentication-problems-in-asp-net-core/。 - undefined
@Aminos 看起来你的项目存在一些配置问题。你能否创建一个最小化的示例来复现这个问题给我?我愿意进行检查,请隐藏敏感信息,以确保你的数据安全。 - undefined
@JasonPan 我在这里创建了一个仓库:https://github.com/embeddedmz/AspNetSignalRAuthenticationExamples。首先启动SignalR/Identity Server项目,然后启动客户端,你会遇到403错误。 - undefined
嗨Aminos,我已经检查了代码库,并将答案发布在下面,你可以按照我的步骤来检查一下。 - undefined
我刚刚帮你找到了认证问题,看起来你的index.html文件还有一些错误,你可以在浏览器的开发者工具中的控制台窗口中找到它。 - undefined
显示剩余4条评论
1个回答

0
从仓库中,我们可以知道您在JwtSample项目中有一个广播中心(URL为/broadcast)。 而且您还有一个广播中心(URL为/Prevo100)。
所有客户端都应该从SignalRIdentityServerServer中的API(/generatetoken)获取令牌。
我不知道为什么当我像下面这样设置启动时,SignalRIdentityServerClient不能正常工作。

enter image description here

测试结果

enter image description here

但是当我像下面这样设置启动时,SignalRIdentityServerClient就能正常工作。

enter image description here

测试结果

enter image description here

我通常喜欢在身份服务器应用程序中使用能够生成令牌的方法。
这是示例代码,我已经进行了更改,它运行良好。
SignalRIdentityServerServer - Startup.cs
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HttpsPolicy;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;

namespace SignalRIdentityServerServer
{
    public class Startup
    {
        //private readonly SymmetricSecurityKey SecurityKey = new SymmetricSecurityKey(RandomNumberGenerator.GetBytes(32));
        private readonly JwtSecurityTokenHandler JwtTokenHandler = new JwtSecurityTokenHandler();

        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddSignalR();

            services.AddIdentityServer()
                .AddInMemoryApiResources(Config.Apis)
                .AddInMemoryClients(Config.Clients)
                .AddInMemoryApiScopes(Config.Scopes)
                .AddDeveloperSigningCredential();

            services.AddAuthorization(options =>
            {
                options.AddPolicy(JwtBearerDefaults.AuthenticationScheme, policy =>
                {
                    policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme);
                    policy.RequireClaim(ClaimTypes.NameIdentifier);
                });
            });
            // Add services to the container.
            services.AddCors(options => options.AddPolicy("CorsPolicy", builder =>
            {
                builder.AllowAnyMethod()
                    .SetIsOriginAllowed(_ => true)
                    .AllowAnyHeader()
                    .AllowCredentials();
            }));

            services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
                .AddJwtBearer(options => {
                    var secretByte = Encoding.UTF8.GetBytes("Authentication:SecretKey");
                    options.TokenValidationParameters = new TokenValidationParameters
                    {
                        ValidateIssuer = true,
                        ValidIssuer = "SignalRTestServer",
                        ValidateAudience = true,
                        ValidAudience = "SignalRTests",
                        ValidateLifetime = true,
                        IssuerSigningKey = new SymmetricSecurityKey(secretByte),
                        ClockSkew = TimeSpan.Zero
                    };

                    // We have to hook the OnMessageReceived event in order to
                    // allow the JWT authentication handler to read the access
                    // token from the query string when a WebSocket or 
                    // Server-Sent Events request comes in.

                    // Sending the access token in the query string is required when using WebSockets or ServerSentEvents
                    // due to a limitation in Browser APIs. We restrict it to only calls to the
                    // SignalR hub in this code.
                    // See https://docs.microsoft.com/aspnet/core/signalr/security#access-token-logging
                    // for more information about security considerations when using
                    // the query string to transmit the access token.
                    options.Events = 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) &&
                                (context.HttpContext.WebSockets.IsWebSocketRequest ||
                                context.Request.Headers["Accept"] == "text/event-stream")
                                //&& path.StartsWithSegments("/Prevo100")
                                )
                            {
                                // Read the token out of the query string
                                context.Token = accessToken;
                            }

                            return Task.CompletedTask;*/

                            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("/Prevo100")*/)
                            {
                                // Read the token out of the query string
                                context.Token = accessToken;
                            }
                            return Task.CompletedTask;
                        }
                    };
                });
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseStaticFiles();

            app.UseCors("CorsPolicy");

            app.UseFileServer();
            app.UseRouting();

            
            app.UseAuthentication();

            app.UseAuthorization();

            app.UseIdentityServer();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapHub<Broadcaster>("/Prevo100");
                endpoints.MapGet("/generatetoken", context =>
                {
                    return context.Response.WriteAsync(GenerateToken(context));
                });
            });
        }

        private string GenerateToken(HttpContext httpContext)
        {
            var singningAlgorithm = SecurityAlgorithms.HmacSha256;

            var claims = new[] { new Claim(ClaimTypes.NameIdentifier, httpContext.Request.Query["user"])
            };
            var secretByte = Encoding.UTF8.GetBytes("Authentication:SecretKey");
            var signingkey = new SymmetricSecurityKey(secretByte);
            var signingCredentials = new SigningCredentials(signingkey, singningAlgorithm);
            var token = new JwtSecurityToken(
                issuer: "SignalRTestServer",
                audience: "SignalRTests",
                claims,
                notBefore: DateTime.Now,
                expires: DateTime.Now.AddMinutes(3),
                signingCredentials
                );
            //var claims = new[] { new Claim(ClaimTypes.NameIdentifier, httpContext.Request.Query["user"]) };
            //var credentials = new SigningCredentials(SecurityKey, SecurityAlgorithms.HmacSha256);
            //var token = new JwtSecurityToken("SignalRTestServer", "SignalRTests", claims, expires: DateTime.UtcNow.AddSeconds(30), signingCredentials: credentials);
            var tokenStr = new JwtSecurityTokenHandler().WriteToken(token);
            return tokenStr;
        }
    }
}

JwtSample - Startup.cs

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

namespace JwtSample;

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddSignalR(options =>
        {
            options.EnableDetailedErrors = true;
        });
        services.AddControllers();
        services.AddAuthorization(options =>
        {
            options.AddPolicy(JwtBearerDefaults.AuthenticationScheme, policy =>
            {
                policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme);
                policy.RequireClaim(ClaimTypes.NameIdentifier);
            });
        });

        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "http://localhost:5155";
        options.RequireHttpsMetadata = false;

        options.Audience = "prevo100-api";

        options.TokenValidationParameters = new TokenValidationParameters
        {
            //ValidateAudience = true,
            //ValidateIssuer = true
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidIssuer = "SignalRTestServer",
            ValidAudience = "SignalRTests",
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes("Authentication:SecretKey"))
        };

        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                var accessToken = context.Request.Query["access_token"];
                if (!string.IsNullOrEmpty(accessToken) &&
                    (context.HttpContext.WebSockets.IsWebSocketRequest || context.Request.Headers["Accept"] == "text/event-stream"))
                {
                    context.Token = accessToken;
                }
                return Task.CompletedTask;
            }
        };
    });

    }

    public void Configure(IApplicationBuilder app)
    {
        app.UseFileServer();

        app.UseRouting();
        app.UseAuthentication();
        app.UseAuthorization();
        app.UseWebSockets();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapHub<Broadcaster>("/broadcast");
            endpoints.MapControllers();
        });
    }


}

JwtSample - index.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>SignalR JWT Sample</title>
</head>
<body>
    <div id="log">

    </div>
</body>
</html>
<script type="text/javascript" src="lib/signalr-client/signalr.js"></script>
<script>

    function createLog(clientId) {
        var log = document.getElementById('log');
        var ul =  document.createElement('ul');
        ul.id = 'log' + clientId;
        log.appendChild(ul);
    }

    function appendLog(clientId, entry) {
        var listId = document.getElementById('log' + clientId);
        if (listId.children.length > 11) {
            listId.removeChild(listId.children[1]);
        }
        var child = document.createElement('li');
        child.innerText = entry;
        listId.appendChild(child);
    }

    function get(url) {
        return new Promise((resolve, reject) => {
            var xhr = new XMLHttpRequest();
            xhr.open('GET', url, true);
            xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
            xhr.send();
            xhr.onload = () => {
                if (xhr.status >= 200 && xhr.status < 300) {
                    resolve(xhr.response || xhr.responseText);
                }
                else {
                    reject(new Error(xhr.statusText));
                }
            };

            xhr.onerror = () => {
                reject(new Error(xhr.statusText));
            }
        });
    }

    var tokens = {};

    function refreshToken(clientId) {

        console.log("clientId: " + clientId);
        //var tokenUrl = 'http://' + document.location.host + '/generatetoken?user=' + clientId;
        var tokenUrl = 'http://localhost:5155/generatetoken?user=' + clientId;
        return get(tokenUrl)
            .then(function (token) {
                tokens[clientId] = token;
            });
    }

    function runConnection(clientId, transportType) {
        var connection;
        console.log("clientId: " + clientId);
        refreshToken(clientId)
            .then(function () {
                var options = {
                    transport: transportType,
                    accessTokenFactory: function () { return tokens[clientId]; }
                };

                console.log(transportType)
                console.log("tokens: " + tokens[clientId]);
                connection = new signalR.HubConnectionBuilder()
                    .withUrl("/broadcast", { accessTokenFactory: () => tokens[clientId] })
                    .configureLogging(signalR.LogLevel.Debug)
                    .build();

                connection.on('Message', function (from, message) {
                    appendLog(clientId, from + ': ' + message);
                });
                return connection.start();
            })
            .then(function () {
                appendLog(clientId, 'user ' + clientId + ' connected');
                setInterval(function () {
                    appendLog(clientId, 'Refreshing token');
                    refreshToken(clientId);
                }, 20000);
                setTimeout(function sendMessage() {
                    connection.send('broadcast', clientId, 'Hello at ' + new Date().toLocaleString());
                    var timeout = 2000 + Math.random() * 4000;
                    setTimeout(sendMessage, timeout);
                })
            })
            .catch(function (e) {
                appendLog(clientId, 'Could not start connection');
            });
    }

    [signalR.HttpTransportType.WebSockets, signalR.HttpTransportType.ServerSentEvents, signalR.HttpTransportType.LongPolling].forEach(function(transportType) {
        var clientId = 'browser ' + signalR.HttpTransportType[transportType];
        console.log("transportType: " + signalR.HttpTransportType[transportType]);
        createLog(clientId);
        appendLog(clientId, 'Log for user: ' + clientId);
        runConnection(clientId, transportType);
    });

</script>

JwtClientSample - Program.cs
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Concurrent;
using System.Net.Http;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.SignalR.Client;

namespace JwtClientSample;

class Program
{
    static async Task Main(string[] args)
    {
        var app = new Program();
        await Task.WhenAll(
            app.RunConnection(HttpTransportType.WebSockets)/*,
            app.RunConnection(HttpTransportType.ServerSentEvents),
            app.RunConnection(HttpTransportType.LongPolling)*/);
    }

    private const string ServerUrl = "http://localhost:54543";
    private const string ID4ServerUrl = "http://localhost:5155";

    private readonly ConcurrentDictionary<string, Task<string>> _tokens = new ConcurrentDictionary<string, Task<string>>(StringComparer.Ordinal);

    private async Task RunConnection(HttpTransportType transportType)
    {
        var userId = "C#" + transportType;
        _tokens[userId] = GetJwtToken(userId);

        var hubConnection = new HubConnectionBuilder()
            .WithUrl(ServerUrl + "/broadcast", options =>
            {
                options.Transports = transportType;
                options.AccessTokenProvider = () => _tokens[userId];
            })
            .Build();

        var closedTcs = new TaskCompletionSource();
        hubConnection.Closed += e =>
        {
            closedTcs.SetResult();
            return Task.CompletedTask;
        };

        hubConnection.On<string, string>("Message", (sender, message) => Console.WriteLine($"[{userId}] {sender}: {message}"));
        await hubConnection.StartAsync();
        Console.WriteLine($"[{userId}] Connection Started");

        var ticks = 0;
        var nextMsgAt = 3;

        try
        {
            while (!closedTcs.Task.IsCompleted)
            {
                await Task.Delay(1000);
                ticks++;
                if (ticks % 15 == 0)
                {
                    // no need to refresh the token for websockets
                    if (transportType != HttpTransportType.WebSockets)
                    {
                        _tokens[userId] = GetJwtToken(userId);
                        Console.WriteLine($"[{userId}] Token refreshed");
                    }
                }

                if (ticks % nextMsgAt == 0)
                {
                    await hubConnection.SendAsync("Broadcast", userId, $"Hello at {DateTime.Now}");
                    nextMsgAt = Random.Shared.Next(2, 5);
                }
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"[{userId}] Connection terminated with error: {ex}");
        }
    }

    private static async Task<string> GetJwtToken(string userId)
    {
        var httpResponse = await new HttpClient().GetAsync(ID4ServerUrl + $"/generatetoken?user={userId}");
        httpResponse.EnsureSuccessStatusCode();
        return await httpResponse.Content.ReadAsStringAsync();
    }
}

SignalRIdentityServerClient - Program.cs
using IdentityModel.Client;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.SignalR.Client;
using SignalRIdentityServerShared;
using System.Collections.Concurrent;
using System.Data.Common;
using TypedSignalR.Client;

namespace SignalRIdentityServerClient
{
    public class WebClient : IClient, IHubConnectionObserver, IDisposable
    {
        private readonly IHubContract _hub;
        private readonly IDisposable _subscription;
        private readonly CancellationTokenSource _cancellationTokenSource = new();
        

        public WebClient(HubConnection connection)
        {
            _hub = connection.CreateHubProxy<IHubContract>(_cancellationTokenSource.Token);
            _subscription = connection.Register<IClient>(this);
        }

        Task IClient.ReceiveMessage(string sender, string message)
        {
            Console.WriteLine("[{0}] {1}", sender, message);
            return Task.CompletedTask;
        }

        public Task OnClosed(Exception e)
        {
            Console.WriteLine($"[On Closed!]");
            return Task.CompletedTask;
        }

        public Task OnReconnected(string connectionId)
        {
            Console.WriteLine($"[On Reconnected!]");
            return Task.CompletedTask;
        }

        public Task OnReconnecting(Exception exception)
        {
            Console.WriteLine($"[On Reconnecting!]");
            return Task.CompletedTask;
        }

        public Task RequestBroadcast()
        {
            return _hub.Broadcast("Client", "Hello Server !");
        }

        public void Dispose()
        {
            _subscription?.Dispose();
        }
    }

    public class Program
    {
        private static readonly ConcurrentDictionary<string, Task<string>> _tokens = new ConcurrentDictionary<string, Task<string>>(StringComparer.Ordinal);
        private const string ID4ServerUrl = "http://localhost:5155";
        static async Task Main(string[] args)
        {
            var httpClient = new HttpClient();
            //var discoveryDocument = httpClient.GetDiscoveryDocumentAsync("http://localhost:5155").Result;
            //var tokenResponse = httpClient.RequestClientCredentialsTokenAsync(
            //    new ClientCredentialsTokenRequest
            //    {
            //        Address = discoveryDocument.TokenEndpoint,
            //        ClientId = "client",
            //        ClientSecret = "Prevo100",
            //        Scope = "prevo100-api"
            //    }).Result;

            //Console.WriteLine($"Token : {tokenResponse.AccessToken}");

            var userId = "C#" + HttpTransportType.WebSockets;
            _tokens[userId] = GetJwtToken(userId);

            var url = "http://localhost:5155/Prevo100";

            HubConnection connection = new HubConnectionBuilder()
                .WithUrl(url, options =>
                {
                    options.Transports = HttpTransportType.WebSockets;
                    options.AccessTokenProvider = () => Task.FromResult(_tokens[userId].Result);
                })
                .WithAutomaticReconnect()
                .Build();

            var client = new WebClient(connection);

            connection.StartAsync().Wait();

            client.RequestBroadcast().Wait();

            //connection.StopAsync().Wait();
            client.Dispose();
        }

        private static async Task<string> GetJwtToken(string userId)
        {
            var httpResponse = await new HttpClient().GetAsync(ID4ServerUrl + $"/generatetoken?user={userId}");
            httpResponse.EnsureSuccessStatusCode();
            return await httpResponse.Content.ReadAsStringAsync();
        }
    }
}

实际上,我想在SignalRIdentityServerClient - Program.cs中使用ClientSecret = "Prevo100"(请查看Config.cs,其中包含身份验证服务器的配置)。我不想使用JwtSample技术。再次强调,JwtSample和JwtClientSample项目对我没有兴趣。 - undefined
嗨 @Aminos,我擅长解决SignalR的问题。最终我调查了你的问题,发现SignalRIdentityServerClient的RequestClientCredentialsTokenAsync方法返回的tokenResponse.AccessToken格式是RS256算法。 - undefined
嗨 @Aminos 在我之前分享的生成令牌的方式中,我修改了过期时间,比如设置为10分钟,然后在 options.AccessTokenProvider = () => Task.FromResult("ej..."); 中硬编码了它,一切都运行得很好。 - undefined
@Aminos,所以我强烈建议你可以创建一个新的帖子,并继续跟进这个问题。在这个帖子中,我的回答包括代码和截图。这样其他社区成员就可以更容易地参与进来。而且我们也找到了问题的原因,对吧? - undefined
我不明白你上次的评论。我的问题的目标是使用IdentityServer来对我的客户进行身份验证,客户必须使用存储在Identity Server中的“客户端密钥”,而不是使用随机安全密钥。我的错误是在我给你的解决方案中包含了与我的问题无关的其他项目。我更希望你能编辑你的答案,Jason。 - undefined
显示剩余3条评论

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