ASP.NET Core SignalR 身份认证集成指南(Identity + JWT)

文章目录


前言

本文将详细介绍如何在 ASP.NET Core SignalR 应用中结合 Identity 框架和 JWT 实现安全的身份认证与授权。

一、完整解决方案架构

二、实现步骤

1.配置 Identity 和 JWT 认证

Identity、JWT请参照【ASP.NET Core 中JWT的基本使用】、【ASP.NET Core Identity框架使用指南】

  1. Program.cs

    csharp 复制代码
    using Microsoft.AspNetCore.Identity;
    using Microsoft.EntityFrameworkCore;
    using Microsoft.Extensions.Options;
    using Microsoft.OpenApi.Models;
    using SignalRDemo.Data;
    using SignalRDemo.Entity;
    using SignalRDemo.Extensions;
    using SignalRDemo.HubService;
    using SignalRDemo.Interfaces;
    using SignalRDemo.Repositories;
    
    var builder = WebApplication.CreateBuilder(args);
    
    // Add services to the container.
    
    builder.Services.AddControllers();
    // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
    builder.Services.AddEndpointsApiExplorer();
    builder.Services.AddSwaggerGen(); 
    //数据库上下文
    var connectionString = uilder.Configuration.GetConnectionString("DefaultConnection");
    builder.Services.AddDbContext<MyDbContext>(opt => {
        opt.UseSqlServer(connectionString);
    });
    //配置Identity
    builder.Services.AddIdentityCore<User>(opt => {
        opt.Lockout.MaxFailedAccessAttempts = 5;//登录失败多少次账号被锁定
        opt.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(1);//锁定多长时间
        opt.Password.RequireDigit = false;//密码是否需要数字    
        opt.Password.RequireLowercase = false;//密码是否需要小写字符
        opt.Password.RequireUppercase = false;//密码是否需要大写字符
        opt.Password.RequireNonAlphanumeric = false;//密码是否需要非字母数字的字符
        opt.Password.RequiredLength = 6;//密码长度
    
        opt.Tokens.PasswordResetTokenProvider = TokenOptions.DefaultEmailProvider;//密码重置令牌,使用默认的邮箱令牌提供程序来生成和验证令牌。此提供程序通常与用户邮箱关联,生成的令牌会通过邮件发送给用户,保证用户通过邮件接收密码重置链接。
        opt.Tokens.EmailConfirmationTokenProvider = TokenOptions.DefaultEmailProvider;//配置邮箱确认令牌(Email Confirmation Token)的生成和验证所使用的提供程序(Provider)
    });
    var idBuilder = new IdentityBuilder(typeof(User), typeof(Role), builder.Services);
    
    idBuilder.AddEntityFrameworkStores<MyDbContext>()
    .AddDefaultTokenProviders().AddUserManager<UserManager<User>>()
    .AddRoleManager<RoleManager<Role>>();
    
    builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("JwtSettings"));
    
    // 5. 注册应用服务
    builder.Services.AddScoped<IUserRepository, UserRepository>();
    builder.Services.AddScoped<IAuthService, AuthService>();
    
    
    // 添加 SignalR 服务
    string redisServerAddress = "";
    if (!string.IsNullOrWhiteSpace(redisServerAddress))
    {
        builder.Services.AddSignalR().AddStackExchangeRedis(redisServerAddress, opt =>
        {
            opt.Configuration.ChannelPrefix = "MyAppSignalR"; // 通道前缀
        });
    }
    else
    {
        builder.Services.AddSignalR();
    }
    
    //跨域
    string[] urls = new[] { "http://localhost:5173" };
    builder.Services.AddCors(opt => 
            opt.AddDefaultPolicy(builder => builder.WithOrigins(urls)
            .AllowAnyMethod().AllowAnyHeader().AllowCredentials())
            );
    // 添加JWT认证
    // 认证服务配置(来自ServiceExtensions)
    builder.Services.ConfigureJwtAuthentication(builder.Configuration);  // 扩展方法	ServiceExtensions.cs
    //  授权策略配置(来自ServiceExtensions)
    builder.Services.ConfigureAuthorizationPolicies();  // 扩展方法ServiceExtensions.cs
    //配置Swagger中带JWT报文头
    builder.Services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" });
        var securityScheme = new OpenApiSecurityScheme
        {
            Name = "Authorization",
            Description = "JWT Authorization header using the Bearer scheme.\r\nExample:'Bearer fadffdfadfds'",
            In = ParameterLocation.Header,
            Type = SecuritySchemeType.ApiKey,
            Scheme = "bearer",
            BearerFormat = "JWT",
            Reference = new OpenApiReference
            {
                Type = ReferenceType.SecurityScheme,
                Id = "Authorization"
            }
        };
    
        c.AddSecurityDefinition("Authorization", securityScheme);
    
        var securityRequirement = new OpenApiSecurityRequirement
        {
            { securityScheme, new[] { "Bearer" } }
        };
    
        c.AddSecurityRequirement(securityRequirement);
    });
    
    var app = builder.Build();
    
    // Configure the HTTP request pipeline.
    if (app.Environment.IsDevelopment())
    {
        app.UseSwagger();
        app.UseSwaggerUI();
    }
    
    app.UseHttpsRedirection();
    app.UseAuthentication();
    app.UseAuthorization();
    app.UseCors();
    // 配置路由
    app.MapHub<MyHubService>("/Hubs/MyHubService");// SignalR 终结点
    app.MapControllers();
    
    app.Run();

2. SignalR JWT配置

  1. ServiceExtensions.cs

    csharp 复制代码
    using Microsoft.AspNetCore.Authentication.JwtBearer;
    using Microsoft.IdentityModel.Tokens;
    using SignalRDemo.Entity;
    using System.Security.Claims;
    using System.Text;
    
    namespace SignalRDemo.Extensions
    {
        public static class ServiceExtensions
        {
            // JWT认证配置
            public static void ConfigureJwtAuthentication(this IServiceCollection services, IConfiguration configuration)
            {
                var jwtSettings = configuration.GetSection("JwtSettings").Get<JwtSettings>();
    
                services.AddAuthentication(options =>
                {
                    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
                })
                .AddJwtBearer(options =>
                {
                    options.TokenValidationParameters = new TokenValidationParameters
                    {
                        ValidateIssuer = false,
                        ValidIssuer = jwtSettings.Issuer,
                        ValidateAudience = false,
                        ValidAudience = jwtSettings.Audience,
                        ValidateLifetime = false,
                        ValidateIssuerSigningKey = false,
                        IssuerSigningKey = new SymmetricSecurityKey(
                                    Encoding.UTF8.GetBytes(jwtSettings.SecretKey!)),
                        //ClockSkew = TimeSpan.Zero,
                        RoleClaimType = ClaimTypes.Role
                    };
    
                    options.Events = new JwtBearerEvents
                    {
                        OnAuthenticationFailed = context =>
                        {
                            if (context.Exception.GetType() == typeof(SecurityTokenExpiredException))
                            {
                                context.Response.Headers.Append("Token-Expired", "true");
                            }
                            return Task.CompletedTask;
                        },
                        //SignalR JWT配置
                        OnMessageReceived = context =>
                        {
                            //websocket不支持自定义报文头
                            //所以需要把JWT通过URL中的Querystring传递
                            //然后在服务器端的OnMessageReceived中,把Querystring中的JWT读取出来
                            var accessToken = context.Request.Query["access_token"];
                            var path = context.HttpContext.Request.Path;
                            if (!string.IsNullOrEmpty(accessToken) &&
                                path.StartsWithSegments("/Hubs/MyHubService"))
                            {
                                context.Token = accessToken;
                            }
                            return Task.CompletedTask;
    
                        }
    
                    };
                });
            }
    
            // 授权策略配置
            public static void ConfigureAuthorizationPolicies(this IServiceCollection services)
            {
                services.AddAuthorization(options =>
                {
                    // 基于角色的策略
                    options.AddPolicy("AdminOnly", policy =>
                        policy.RequireRole("admin"));
    
                    options.AddPolicy("ManagerOnly", policy =>
                        policy.RequireRole("admin"));
    
                    // 基于自定义权限的策略
                    options.AddPolicy("ContentEditor", policy =>
                        policy.RequireClaim("permission", "content.edit"));
    
                    options.AddPolicy("UserManagement", policy =>
                        policy.RequireClaim("permission", "user.manage"));
                });
            }
        }
    }

3.SignalR Hub 集成认证和授权

  1. MyHubService.cs

    csharp 复制代码
    using Microsoft.AspNetCore.Authorization;
    using Microsoft.AspNetCore.SignalR;
    
    namespace SignalRDemo.HubService
    {
    	//[Authorize]
        public class MyHubService:Hub
        {
            [Authorize(Roles = "admin")]
            public Task SendMessageAsync(string user,string content)
            {
                var connectionId=this.Context.ConnectionId;
                string msg = $"{connectionId},{DateTime.Now.ToString()}:{user}";
                return Clients.All.SendAsync("ReceiveMsg", msg, content);
            }
            
        }
    }

4.控制器

  1. AuthController.cs

    csharp 复制代码
    using Microsoft.AspNetCore.Authorization;
    using Microsoft.AspNetCore.Http;
    using Microsoft.AspNetCore.Identity.Data;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.Extensions.Options;
    using Microsoft.IdentityModel.Tokens;
    using SignalRDemo.Entity;
    using SignalRDemo.Interfaces;
    using System.IdentityModel.Tokens.Jwt;
    using System.Runtime;
    using System.Security.Claims;
    using System.Text;
    
    namespace SignalRDemo.Controllers
    {
        [Route("api/[controller]/[action]")]
        [ApiController]
        public class AuthController : ControllerBase
        {
            private readonly IAuthService _authService;
            public AuthController(IConfiguration config, IOptionsSnapshot<JwtSettings> settings, IAuthService authService)
            {
                _config = config;
                _settings = settings;
                _authService = authService;
            }
            [HttpPost]
            [AllowAnonymous]
            public async Task<IActionResult> Login([FromBody] LoginModel request)
            {
                var result = await _authService.Authenticate(request.Username, request.Password);
                if (result == null) return Unauthorized();
                return Ok(result);
            }
        }
    }

5.客户端集成 (JavaScript)

  1. 代码示例

    csharp 复制代码
    <template>
      <div style="padding: 20px; max-width: 800px; margin: 0 auto;">
        <h2 style="color: #2c3e50;">SignalR 聊天室</h2>
        
        <!-- 消息发送区域 - 始终显示但禁用状态 -->
        <div style="margin-bottom: 20px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center;">
          <div style="flex: 1 1 200px;">
            <label style="display: block; font-weight: bold; margin-bottom: 5px;">用户:</label>
            <input 
              type="text" 
              v-model="state.username" 
              placeholder="输入用户名"
              :disabled="state.isLoggingIn || state.isConnected"
              style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; width: 100%;"
            />
          </div>
          
          <div style="flex: 1 1 200px;">
            <label style="display: block; font-weight: bold; margin-bottom: 5px;">密码:</label>
            <input 
              type="password" 
              v-model="state.password" 
              placeholder="输入密码"
              :disabled="state.isLoggingIn || state.isConnected"
              style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; width: 100%;"
            />
          </div>
          
          <div style="flex: 1 1 200px;">
            <label style="display: block; font-weight: bold; margin-bottom: 5px;">消息内容:</label>
            <input 
              type="text" 
              v-model="state.contentMsg" 
              @keydown.enter="sendMessage"
              placeholder="输入消息后按回车发送"
              :disabled="!state.isConnected || state.isConnecting"
              style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; width: 100%;"
            />
          </div>
        </div>
        
        <!-- 登录控制区域 -->
        <div style="margin-bottom: 20px; background: #f8f9fa; padding: 15px; border-radius: 4px;">
          <div style="display: flex; margin-bottom: 10px;">
            <label style="margin-right: 10px; font-weight: bold; min-width: 80px;">服务器:</label>
            <input
              type="text"
              v-model="state.serverUrl"
              placeholder="输入 SignalR Hub URL"
              :disabled="state.isConnected"
              style="padding: 8px; border: 1px solid #ddd; border-radius: 4px; flex: 1;"
            />
          </div>
          <div style="display: flex; gap: 10px;">
            <button 
              @click="login"
              :disabled="state.isLoggingIn || state.isConnected"
              style="padding: 8px 15px; background: #3498db; color: white; border: none; border-radius: 4px; cursor: pointer; flex: 1;"
            >        
              {{ state.isLoggingIn ? '登录中...' : '登录' }}
            </button>
            
            <button 
              @click="reconnect"
              :disabled="!state.token"
              style="padding: 8px 15px; background: #2ecc71; color: white; border: none; border-radius: 4px; cursor: pointer; flex: 1;"
            >
              {{ state.isConnected ? '重新连接' : '连接' }}
            </button>
          </div>
        </div>
        
        <!-- 消息记录区域 -->
        <div style="border: 1px solid #e0e0e0; border-radius: 4px; overflow: hidden; margin-bottom: 20px;">
          <div style="background: #f0f0f0; padding: 10px; font-weight: bold;">消息记录</div>
          <div style="max-height: 300px; overflow-y: auto; padding: 10px; background: white;">
            <div v-for="(msg, index) in state.messages" :key="index" style="padding: 8px 0; border-bottom: 1px solid #f5f5f5;">
                {{ msg }}
            </div>
            <div v-if="state.messages.length === 0" style="text-align: center; color: #999; padding: 20px;">
              暂无消息
            </div>
          </div>
        </div>
        
        <!-- 状态显示区域 -->
        <div :style="{
          padding: '12px',
          borderRadius: '4px',
          marginBottom: '15px',
          backgroundColor: state.connectionStatus.includes('失败') ? '#ffebee' : 
                          state.connectionStatus.includes('连接') ? '#e8f5e9' : '#e3f2fd',
          color: state.connectionStatus.includes('失败') ? '#b71c1c' : 
                 state.connectionStatus.includes('连接') ? '#1b5e20' : '#0d47a1',
          border: state.connectionStatus.includes('失败') ? '1px solid #ffcdd2' : 'none'
        }">
          <div style="font-weight: bold; margin-bottom: 5px;">连接状态:</div>
          <div>{{ state.connectionStatus }}</div>
          <div v-if="state.errorDetails" style="margin-top: 10px; font-size: 0.9em; color: #b71c1c;">
            <div style="font-weight: bold;">错误详情:</div>
            <div style="word-break: break-all;">{{ state.errorDetails }}</div>
          </div>
        </div>
      </div>
    </template>
    
    <script>
    import { reactive, onUnmounted } from 'vue';
    import * as signalR from '@microsoft/signalr';
    
    export default {
      setup() {
        const state = reactive({
          username: "",
          password: "",
          contentMsg: "",
          messages: [],
          connectionStatus: "未连接",
          isConnected: false,
          isConnecting: false,
          isLoggingIn: false,
          serverUrl: "https://localhost:7183/Hubs/MyHubService",
          errorDetails: "",
          connection: null,
          retryCount: 0,
          token: null
        });
    
        const sendMessage = async () => {
          if (!state.contentMsg.trim()) return;
          
          if (!state.isConnected || !state.connection) {
            state.connectionStatus = "连接尚未建立,无法发送消息";
            return;
          }
          
          try {
            const possibleMethods = [
              // "SendMessage", 
              "SendMessageAsync"
              // "BroadcastMessage",
              // "SendToAll",
              // "PublishMessage"
            ];
            
            let lastError = null;
            
            for (const method of possibleMethods) {
              try {
                await state.connection.invoke(method, state.username, state.contentMsg);
                state.contentMsg = "";
                return;
              } catch (error) {
                lastError = error;
              }
            }
            
            state.connectionStatus = `发送失败: 未找到服务端方法`;
            state.errorDetails = `尝试的方法: ${possibleMethods.join(", ")}\n错误: ${lastError.message}`;
            
          } catch (error) {
            state.connectionStatus = `发送失败: ${error.message}`;
            state.errorDetails = error.toString();
          }
        };
        
        const initSignalRConnection = async (token) => {
          // token='12332131321';
          state.isConnecting = true;
          state.connectionStatus = "正在连接...";
          state.errorDetails = "";
          
          try {
            if (state.connection) {
              await state.connection.stop();
              state.connection = null;
            }
            
            state.connection = new signalR.HubConnectionBuilder()
              .withUrl(state.serverUrl, {
                accessTokenFactory: () => token,
                skipNegotiation: true,
                transport: signalR.HttpTransportType.WebSockets
              })
              .withAutomaticReconnect({
                nextRetryDelayInMilliseconds: retryContext => {
                  state.retryCount = retryContext.previousRetryCount + 1;
                  return Math.min(1000 * Math.pow(2, state.retryCount), 30000);
                }
              })
              .configureLogging(signalR.LogLevel.Debug)
              .build();
            
            state.connection.on('ReceiveMessage', rcvMsg => {
              state.messages.push(rcvMsg);
            });
            
            state.connection.on('ReceiveMsg', (rcvMsg, rcvContent) => {
              state.messages.push(`${rcvMsg}: ${rcvContent}`);
            });
            
            state.connection.onreconnecting(() => {
              state.isConnected = false;
              state.connectionStatus = "连接丢失,正在重连...";
            });
            
            state.connection.onreconnected(connectionId => {
              state.isConnected = true;
              state.isConnecting = false;
              state.retryCount = 0;
              state.connectionStatus = `已重新连接 (ID: ${connectionId})`;
            });
            
            state.connection.onclose(error => {
              state.isConnected = false;
              state.isConnecting = false;
              state.connectionStatus = error 
                ? `连接关闭: ${error.message}` 
                : "连接已关闭";
            });
            
            await state.connection.start();
            state.isConnected = true;
            state.isConnecting = false;
            state.retryCount = 0;
            state.connectionStatus = `已连接 (ID: ${state.connection.connectionId})`;
            
          } catch (error) {
            console.error("SignalR 连接失败:", error);
            state.isConnected = false;
            state.isConnecting = false;
            state.connectionStatus = `连接失败: ${error.message}`;
            state.errorDetails = error.toString();
          }
        };
    
        const reconnect = async () => {
          if (state.token) {
            await initSignalRConnection(state.token);
          } else {
            state.connectionStatus = "请先登录";
          }
        };
        
        const login = async () => {
          if (state.isLoggingIn || state.isConnected) return;
          state.isLoggingIn = true;
          state.connectionStatus = "正在登录...";
          
          try {
            const apiUrl = state.serverUrl.split('/Hubs/')[0] || 'https://localhost:7183';
            const response = await fetch(`${apiUrl}/api/auth/login`, {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({
                username: state.username,
                password: state.password
              })            
            });
            
            if (!response.ok) {
              throw new Error(`登录失败: ${response.status}`);
            }
            
            const result = await response.json();
            state.token = result.token;
            localStorage.setItem('jwtToken', result.token);
            // alert(result.token);
            // 登录成功后初始化SignalR连接
            await initSignalRConnection(result.token);
            
          } catch (error) {
            state.connectionStatus = `登录失败: ${error.message}`;
            state.errorDetails = error.toString();
          } finally {
            state.isLoggingIn = false;
          }
        };
    
        onUnmounted(() => {
          if (state.connection) {
            state.connection.stop();
          }
        });
    
        return { state, sendMessage, reconnect, login };
      }
    }
    </script>
    
    <style>
    body {
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
      background-color: #f5f7fa;
      margin: 0;
      padding: 20px;
      color: #333;
    }
    
    input, button {
      font-size: 1rem;
      transition: all 0.3s;
    }
    
    input:focus {
      outline: none;
      border-color: #3498db;
      box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
    }
    
    button {
      font-weight: 500;
    }
    
    button:hover:not(:disabled) {
      opacity: 0.9;
      transform: translateY(-1px);
    }
    
    button:disabled {
      opacity: 0.6;
      cursor: not-allowed;
    }
    
    label {
      display: block;
      margin-bottom: 5px;
    }
    </style>
  2. 界面展示:

6.配置 appsettings.json

  1. appsettings.json

    csharp 复制代码
    {
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft.AspNetCore": "Warning"
        }
      },
      "AllowedHosts": "*",
      "ConnectionStrings": {
        "DefaultConnection": "Server=XXX;Database=XXX;User Id=sa;Password=XXX;TrustServerCertificate=True;Trusted_Connection=True;MultipleActiveResultSets=True"
      },
      "JwtSettings": {
        "Issuer": "yourdomain.com",
        "Audience": "yourapp",
        "SecretKey": "YourSuperSecretKeyAtLeast32CharactersLong",
        "ExpirationMinutes": 60,
        "RefreshTokenExpirationDays": 7
      }
    }

三、认证流程详解

1.用户登录:

  • 客户端发送凭据到 /api/auth/login
  • 服务器验证凭据,使用 Identity 检查用户
  • 生成包含用户声明和角色的 JWT
  • 返回 JWT 给客户端

2.SignalR 连接:

  • 客户端使用 accessTokenFactory 提供 JWT
  • SignalR 通过 WebSocket 或长轮询连接时携带 JWT
  • 服务器在 OnMessageReceived 事件中提取 JWT

3.JWT 验证:

  • 认证中间件验证 JWT 签名、有效期等
  • 创建 ClaimsPrincipal 并附加到 HttpContext
  • SignalR 继承此安全上下文

4.Hub 授权:

  • Authorize\] 属性检查用户是否认证

四、常见问题及解决方案:

问题 解决方案
401 Unauthorized 检查 JWT 是否过期,验证签名密钥
连接失败 确保 OnMessageReceived 正确提取令牌
角色授权失败 检查 JWT 是否包含正确的角色声明
WebSocket 问题 检查服务器和代理的 WebSocket 配置
CORS 问题 确保 CORS 策略包含 AllowCredentials()

总结

通过以上配置,您可以构建一个安全、可扩展的 ASP.NET Core SignalR 应用,充分利用 Identity 框架进行用户管理,并通过 JWT 实现无状态认证。这种架构特别适用于需要实时通信的现代 Web 应用和移动应用。

相关推荐
每天进步一点_JL10 小时前
Spring Boot 缓存体系
后端
百珏10 小时前
[灰度发布]:全链路透传组件:APM、自研方案与 Java Agent 的实现取舍
后端·设计模式·架构
正在走向自律10 小时前
DISTINCT 去重查询为什么这么慢?聊聊我能理解的几种优化思路
后端
OpsEye10 小时前
数据库连接池爆了,这3个命令能救你一次
运维·数据库·后端
绝知此事10 小时前
【产品更名】通义灵码升级为 Qoder CN:AI 编码助手新时代,附大模型收费与 Spring Boot 支持全对比
人工智能·spring boot·后端·idea·ai编程
~|Bernard|10 小时前
GO语言中哪些类型是可比较类型的(==和!=)
开发语言·后端·golang
用户67570498850211 小时前
Celery 太重了?这可能是你一直在找的 asyncio 任务队列
后端·python·消息队列
Cloud_Shy61811 小时前
Python 数据分析基础入门:《Excel Python:飞速搞定数据分析与处理》学习笔记系列(第十一章 Python 包跟踪器 下篇)
前端·后端·python·数据分析·excel
神奇小汤圆11 小时前
为什么Redis能称霸缓存界?揭秘其每秒10万+读写的核心技术
后端
楼田莉子11 小时前
C++17新特性:结构化绑定/inline变量/if相关的变化
c++·后端·学习