ASP.NET 高级应用
ASP.NET Core Identity
ASP.NET Core Identity:
- 一个 API,它支持用户界面 (UI) 登录功能。
- 管理用户、密码、配置文件数据、角色、声明、令牌、电子邮件确认等等。
- 支持第三方登录
- 使用 EFCore 支持所有数据库
认证 (authentication) 与授权 (authorization)
Authentication 认证用户 (User), authorization 管理角色 (Role).
Identity 使用基于角色的访问控制来对用户进行管理.
搭建框架
依赖: Microsoft.AspNetCore.Identity.EntityFrameworkCore
要使用Identity框架, 需要:
- 用户类继承于
IdentityUser<T>
- 角色类继承于
IdentityRole<T>
- 用户管理相关数据库上下文继承于
IdentityContext<User, Role, T>
- 以上 T 代表数据键类型, 默认是 GUID 类型
public class User : IdentityUser<long>
{
}
public class Role : IdentityRole<long>
{
}
public class IdentityContext : IdentityDbContext<User, Role, long>
{
public IdentityContext(DbContextOptions<IdentityContext> options)
: base(options)
{
}
}
要在 Program.cs 中配置 Identity, 除了添加相关中间件外, 还需对额外严格Identity规则进行重新设定.
builder.Services.AddDataProtection();
// 对用户进行配置
builder.Services.AddIdentityCore<User>(options =>
{
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireDigit = false;
options.Password.RequiredUniqueChars = 26;
options.Password.RequireLowercase = false;
options.Password.RequireUppercase = false;
options.Password.RequiredLength = 6;
// 发送邮箱验证码时使用更为简单的验证码格式
options.Tokens.PasswordResetTokenProvider
= TokenOptions.DefaultEmailProvider;
options.Tokens.EmailConfirmationTokenProvider
= TokenOptions.DefaultEmailProvider;
});
var identityBuilder
= new IdentityBuilder(typeof(User), typeof(Role), builder.Services);
// 设定 Identity 与实体数据库的联系. 并添加相关Role/User配置
identityBuilder.AddEntityFrameworkStores<IdentityContext>()
.AddDefaultTokenProviders()
// RoleManager, UserManager 提供了相关管理功能
.AddRoleManager<RoleManager<Role>>()
.AddUserManager<UserManager<User>>();
用户登录的验证
UserManager
提供了相关接口以访问用户相关操作而且可以避免直接操作数据库. 同时也解决了并发等问题, 在 Program.cs 可以设定开启锁定功能, 密码将会以hash保存确保安全. 如果开启了锁定功能要切记在登陆失败是设定失败次数, 登陆成功后重置失败次数.
builder.Services.AddIdentityCore<User>(options =>
{
// 是否开启锁定功能: 默认为是
options.Lockout.AllowedForNewUsers = true;
// 最多3次尝试后将会锁定用户
options.Lockout.MaxFailedAccessAttempts = 3;
// 用户在30s后才可再次登录
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromSeconds(30);
});
[HttpPost]
public async Task<ActionResult> Login(LoginRequest request)
{
var user = await _userManager.FindByNameAsync(request.Name);
if (user == null)
{
return BadRequest("用户名或密码错误!");
}
if (_userManager.SupportsUserLockout
&& await _userManager.IsLockedOutAsync(user))
{
var retryTime =
await
_userManager.GetLockoutEndDateAsync(user) - DateTimeOffset.UtcNow;
return BadRequest($"账户已被锁定, 在 {retryTime.Value.Seconds} 秒后重试!");
}
if (!await _userManager.CheckPasswordAsync(user, request.Password))
{
await _userManager.AccessFailedAsync(user);
return BadRequest("用户名或密码错误!");
}
await _userManager.ResetAccessFailedCountAsync(user);
return Ok(await _userManager.GetRolesAsync(user));
}
设计登录接口时可能的安全隐患
如果在用户输入用户名错误后, 提示"用户名错误", 可能会被恶意利用进行攻击.
这种设计会让攻击者可以通过暴力破解的方式,对已知的用户名进行密码尝试,从而获取用户的登录凭证, 为了避免这种风险,一般的做法是,无论是用户名错误还是密码错误,系统都只提示“用户名或密码错误”,而不区分具体是哪个错误
注册用户/角色分配
RoleManager 提供一系列接口以供管理角色分配. 一个用户可以有多个角色.
[HttpPost]
public async Task<ActionResult> Register(RegisterInput input)
{
var user = new User { UserName = input.UserName };
var result = await _userManager.CreateAsync(user, input.Password);
if (result.Succeeded)
{
await _userManager.AddToRoleAsync(user, Role.StandardUser);
return Ok(input.UserName);
}
return BadRequest(result.ToString());
}
[HttpPost]
public async Task<ActionResult<Role>> AddRole(AddRoleRequest request)
{
var role = await _roleManager.FindByNameAsync(request.newRole);
if (role == null)
{
role = new Role
{
Name = request.newRole,
NormalizedName = request.newRole
};
await _roleManager.CreateAsync(role);
}
var user = await _userManager.FindByNameAsync(request.UserName);
if (user == null)
{
return BadRequest();
}
if (!await _userManager.IsInRoleAsync(user, role.Name))
{
await _userManager.AddToRoleAsync(user, role.Name);
}
return role;
}
向用户发送验证码以重置密码
通过邮箱发送
构建邮箱服务
我们构造了可使用依赖注入的邮箱服务:
// IMailSenderService.cs
namespace Identity.Services;
public interface IMailSenderService
{
public bool Send(string mailTo, string subject, string body, bool useHtml);
}
// MailSenderOptions.cs
namespace Identity.Services;
public class MailSenderOptions
{
public string StmpServer { get; set; } = null!;
public string SenderMail { get; set; } = null!;
public string Password { get; set; } = null!;
public int Port { get; set; }
}
// MailSenderService.cs
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using System.Net;
using System.Net.Mail;
using System.Text;
namespace Identity.Services;
public class MailSenderService : IMailSenderService
{
private readonly MailSenderOptions _options;
private readonly ILogger<IMailSenderService> _logger;
public MailSenderService(IOptions<MailSenderOptions> options, ILogger<MailSenderService> logger)
{
_options = options.Value;
_logger = logger;
}
public bool Send(string mailTo, string subject, string body, bool useHtml)
{
SmtpClient client = new(_options.StmpServer, _options.Port)
{
DeliveryMethod = SmtpDeliveryMethod.Network,
EnableSsl = true,
UseDefaultCredentials = false,
Credentials = new NetworkCredential(_options.SenderMail, _options.Password)
};
MailMessage mailMessage = new(_options.SenderMail, mailTo)
{
Subject = subject,
Body = body,
BodyEncoding = Encoding.UTF8,
IsBodyHtml = useHtml,
Priority = MailPriority.Normal
};
try
{
client.Send(mailMessage);
return true;
}
catch (SmtpException ex)
{
_logger.LogError("Error at MailSenderService: {ex}", ex);
return false;
}
}
}
public static class MailSenderServiceExtensions
{
public static IServiceCollection
AddMailSenderService(this IServiceCollection services, Action<MailSenderOptions> options)
{
services.PostConfigure(options);
services.TryAddScoped<IMailSenderService, MailSenderService>();
return services;
}
}
配置邮箱发送服务
需要在邮件服务提供商配置 SMTP 服务:
// MailSenderService 依赖于 Logging 与 Options 服务
builder.Services.AddLogging(loggingBuilder =>
{
loggingBuilder.AddConsole();
});
builder.Services.AddOptions();
builder.Services.AddMailSenderService();
builder.Services.Configure<MailSenderOptions>(builder.Configuration.GetSection("MailService"));
在机密文件配置这些设置即可。
"MailService": {
"SenderMail": "xxxxxx@foxmail.com",
"StmpServer": "smtp.qq.com",
"Password": "xxxxxxxxx",
"Port": "587"
}
使用 UserManger 提供的 Token 重置密码
[HttpPost("{userName}")]
public async Task<IActionResult> SendVerifyCode(string userName)
{
var user = await _userManager.FindByNameAsync(userName);
if (user?.Email == null || !user.EmailConfirmed)
{
return BadRequest();
}
// !!!
string token = await _userManager.GeneratePasswordResetTokenAsync(user);
var result = _mailSenderService.Send(user.Email, "Himu 客服酱",
MailVerifyCodeTemplate.GetTemplate(token), true);
if (!result) return BadRequest();
return Ok();
}
[HttpPut]
public async Task<ActionResult<bool>>
ResetPassword([FromBody] ResetPasswordRequest request)
{
var user = await _userManager.FindByNameAsync(request.UserName);
Debug.Assert(user != null, nameof(user) + " != null");
// !!!
var result =
await _userManager.ResetPasswordAsync(user, request.Code, request.Password);
if (result.Succeeded) return Ok(true);
return BadRequest(false);
}
ASP.NET JWT 令牌
JWT 与 Session
JWT(JSON Web Token)是一种基于JSON格式的开放标准,用于在各方之间安全地传输信息。JWT通常由三部分组成:头部(header)、有效载荷(payload)和签名(signature)。头部包含了令牌的类型和加密算法;有效载荷包含了一些声明,如用户的身份、角色等;签名是用头部和有效载荷加上一个密钥生成的,用于验证令牌的完整性和可信性。
跟传统的 Session 相比:
- JWT 可以减轻服务器端的内存压力,因为用户状态分散到了客户端中,不需要在服务器端维护会话。
- JWT 可以实现无状态的请求,不依赖于特定的服务器或域名,更适合分布式或微服务架构。
- JWT 可以防止篡改客户端声明,因为签名是由服务器端生成的,客户端无法伪造或修改令牌。
- JWT 可以在移动设备上更好地工作,不受浏览器对Cookie的限制。
分配/使用/检验 JWT 令牌
设置 Jwt 的 机密密钥与过期时间
public class JwtOptions
{
public string SecretToken { get; set; } = null!;
// Default set to 1 day
public int ExpireSeconds { get; set; } = 86400;
}
"JwtOptions": {
"SecretToken": "fadtyppuio([222#tyrehimu12314!&&",
"ExpireSeconds": 300000,
// "Issuer": "https://localhost:7082"
}
为应用程式添加 JwtBearer 服务
var jwtOptionsSection = builder.Configuration.GetSection("JwtOptions");
// 这里依赖注入是为了其它类 (控制器)更方便的访问到密钥
builder.Services.Configure<JwtOptions>(jwtOptionsSection);
// 为应用程式添加服务
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(jb =>
{
var jwtOptions = jwtOptionsSection.Get<JwtOptions>();
byte[] keyBytes = Encoding.UTF8.GetBytes(jwtOptions.SecretToken);
var secretKey = new SymmetricSecurityKey(keyBytes);
jb.TokenValidationParameters = new()
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = secretKey
};
});
// ...
// 切记在UseAuthorization前UseAuthentication
app.UseAuthentication();
为用户分配 Jwt 令牌
public static class JwtTokenGenerator
{
public static string
GenerateToken(IEnumerable<Claim> claims, JwtOptions options)
{
byte[] keyBytes = Encoding.UTF8.GetBytes(options.SecretToken);
var secretKey = new SymmetricSecurityKey(keyBytes);
var expire = DateTime.Now.AddSeconds(options.ExpireSeconds);
SigningCredentials credentials
= new(secretKey, SecurityAlgorithms.HmacSha256Signature);
JwtSecurityToken token
= new(claims: claims, expires: expire, signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
这样,对于 [Authorize]
标记的控制器类或Action方法,都必须带上该用户的 Jwt 令牌。否则一概都会返回 401.
要在用户传来的 Jwt 令牌解析出具体信息,使用 User.FindXXX
方法
[Authorize]
[HttpPost]
public async Task<ActionResult> AddRole(AddRoleRequest request)
{
var role = await _roleManager.FindByNameAsync(request.NewRole);
if (role == null)
{
role = new Role
{
Name = request.NewRole,
NormalizedName = request.NewRole
};
await _roleManager.CreateAsync(role);
}
string userName = User.FindFirstValue(ClaimTypes.Name);
var user = await _userManager.FindByNameAsync(userName);
if (user == null)
{
return BadRequest();
}
if (!await _userManager.IsInRoleAsync(user, role.Name))
{
await _userManager.AddToRoleAsync(user, role.Name);
}
return Ok(new {userName, role});
}
为 Swagger 添加 JWT 认证以方便调试
builder.Services.AddSwaggerGen(c =>
{
var scheme = new OpenApiSecurityScheme
{
Description = "Input JWT Token: Bearer xxxxxxxx",
Reference
= new OpenApiReference
{ Type = ReferenceType.SecurityScheme, Id = "Authorization" },
Scheme = "oauth2",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey
};
c.AddSecurityDefinition("Authorization", scheme);
var requirement = new OpenApiSecurityRequirement
{
[scheme] = new List<string>()
};
c.AddSecurityRequirement(requirement);
});
示例:结合 Redis 使用双 Token 认证
双Token认证是一种常用的Web API 认证方式,它使用两个Token来实现用户的登录和访问控制。这两个Token分别是:
- 访问令牌(Access Token):用于验证用户的身份和权限,通常是一个短期有效的JWT(JSON Web Token),包含了用户的基本信息和签名,每次请求时都要携带在HTTP头部中。
- 刷新令牌(Refresh Token):用于获取新的访问令牌,通常是一个长期有效的随机字符串,只在用户登录或访问令牌过期时使用,不需要每次请求时都携带。
时间过短的 JWT 令牌可能在调试中没有效果
过期时间间隔过短的 JWT 令牌可能不会生效,请设定ClockSkew = TimeSpan.Zero
使得令牌过期立刻生效
配置服务
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost";
options.InstanceName = "Identity_Demo";
});
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(jb =>
{
var jwtOptions = jwtOptionsSection.Get<JwtOptions>();
byte[] keyBytes = Encoding.UTF8.GetBytes(jwtOptions.SecretToken);
var secretKey = new SymmetricSecurityKey(keyBytes);
jb.TokenValidationParameters = new()
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = secretKey,
ClockSkew = TimeSpan.Zero
};
});
// ...
app.UseAuthentication();
app.UseAuthorization();
namespace Identity;
public class JwtOptions
{
public string SecretToken { get; set; } = null!;
public int AccessExpireSeconds { get; set; }
public int RefreshExpireSeconds { get; set; }
}
"JwtOptions": {
"SecretToken": "fadtyppuio([222#tyrehimu12314!&&",
"AccessExpireSeconds": 30,
"RefreshExpireSeconds": 604800,
"Issuer": "https://localhost:7082"
}
JWT 令牌生成的简单包装
namespace Identity;
public static class JwtTokenGenerator
{
public static string GenerateAccessToken(IEnumerable<Claim> claims, JwtOptions options)
{
byte[] keyBytes = Encoding.UTF8.GetBytes(options.SecretToken);
var secretKey = new SymmetricSecurityKey(keyBytes);
var expire = DateTime.UtcNow.AddSeconds(options.AccessExpireSeconds);
SigningCredentials credentials
= new(secretKey, SecurityAlgorithms.HmacSha256Signature);
JwtSecurityToken token = new(
claims: claims, expires: expire,
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public static string GenerateRefreshToken()
{
var refresh = new byte[32];
using var randomNumberGenerator = RandomNumberGenerator.Create();
randomNumberGenerator.GetBytes(refresh);
return Convert.ToBase64String(refresh);
}
}
public static class JwtTokenGeneratorExtensions
{
public static async Task<ResponseToken>
CreateResponseTokenAsync<TUser, TKey>(this IDistributedCache cache, TUser user, JwtOptions options)
where TKey : IEquatable<TKey>
where TUser : IdentityUser<TKey>
{
List<Claim> claims = new()
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()!),
new Claim(ClaimTypes.Name, user.UserName)
};
string refreshToken
= JwtTokenGenerator.GenerateRefreshToken();
string accessToken
= JwtTokenGenerator.GenerateAccessToken(claims, options);
await cache.SetStringAsync($"refresh_token_cache_{user.UserName}",
refreshToken, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow
= TimeSpan.FromSeconds(options.RefreshExpireSeconds)
});
return new ResponseToken(accessToken, refreshToken);
}
public static async Task<string?>
RefreshAccessTokenAsync
(this IDistributedCache cache, string name,
ResponseToken responseToken, JwtOptions options)
{
var tokenHandler = new JwtSecurityTokenHandler();
var signingKey
= new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.SecretToken));
var principal
= tokenHandler.ValidateToken
(responseToken.AccessToken, new TokenValidationParameters()
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = signingKey,
ValidateLifetime = false,
ValidateIssuer = false,
ValidateAudience = false
}, out var securityToken);
if (securityToken is not JwtSecurityToken jwtToken
|| !jwtToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256Signature,
StringComparison.InvariantCultureIgnoreCase))
{
throw new SecurityTokenException("Illegal Token!");
}
string? expectedName
= jwtToken.Claims.Where(c => c.Type == ClaimTypes.Name)
.Select(c => c.Value)
.FirstOrDefault();
if (expectedName == null || expectedName != name)
return null;
string? expectedRefreshToken
= await cache.GetStringAsync($"refresh_token_cache_{expectedName}");
Debug.Assert(expectedRefreshToken != null);
if (expectedRefreshToken != responseToken.RefreshToken)
return null;
string newAccessToken
= JwtTokenGenerator.GenerateAccessToken(principal.Claims, options);
return newAccessToken;
}
public static async Task
RemoveRefreshTokenAsync(this IDistributedCache cache, string userName)
{
await cache.RemoveAsync($"refresh_token_cache_{userName}");
}
}
API 说明
CreateResponseTokenAsync
:用于为用户创建一个响应令牌,它包含一个访问令牌和一个刷新令牌,并将刷新令牌缓存到分布式缓存中。这个方法接受三个泛型参数,分别是用户类型、用户标识类型和缓存类型,以及两个普通参数,分别是用户对象和JWT选项对象。这个方法返回一个ResponseToken对象,它包含访问令牌和刷新令牌的字符串。RefreshAccessTokenAsync
:用于根据刷新令牌刷新访问令牌,它先验证访问令牌的合法性,然后从分布式缓存中获取刷新令牌,并与客户端传来的刷新令牌进行比对,如果一致,则生成一个新的访问令牌并返回给客户端,如果不一致,则返回null。这个方法接受四个参数,分别是用户名、响应令牌对象、JWT选项对象和缓存对象。这个方法返回一个可空的字符串,表示新的访问令牌或者null。RemoveRefreshTokenAsync
:用于移除分布式缓存中的刷新令牌,它根据用户名从缓存中删除对应的刷新令牌。这个方法接受两个参数,分别是用户名和缓存对象。
托管服务(后台)
基本使用
BackgroundService 是用于实现长时间运行的 IHostedService (抽象后台服务接口) 的基类。
关于如何使用 IHostedService 参见:在 ASP.NET Core 中使用托管服务实现后台任务 | Microsoft Learn
示例:在每隔规定时间后保存数据库内的数据
- 使用
IServiceProvider
使用 Scoped 范围的服务 - 传递
CancellationToken
- 使用
PeriodicTimer
完成计时,而不是Sleep/Wait
- (.NET 6 之后) 任何未处理的异常将导致服务器宕机
public class SaveLockedUserHostService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<SaveLockedUserHostService> _logger;
public SaveLockedUserHostService(ILogger<SaveLockedUserHostService> logger, IServiceProvider serviceProvider)
{
_logger = logger;
_serviceProvider = serviceProvider;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation($"{nameof(ExecuteAsync)} is running.");
using var scope = _serviceProvider.CreateScope();
var databaseContext = scope.ServiceProvider.GetRequiredService<IdentityContext>();
using PeriodicTimer timer = new(TimeSpan.FromHours(1));
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
string recordFileName = $"LockedUsers_{DateTime.Now.TimeOfDay.TotalSeconds}.txt";
_logger.LogInformation(
"[SaveLockedUserHostService] ({DateTime.Now}) Saving {recordFileName}...",
DateTime.Now, recordFileName);
await using StreamWriter writer = new(recordFileName);
var lockedUsers = databaseContext.Users
.Where(u => u.LockoutEnd != null && u.LockoutEnd > DateTimeOffset.Now)
.Select(u => new { u.Id, u.UserName, u.LockoutEnd });
await foreach (var lockedUser
in lockedUsers.AsAsyncEnumerable().WithCancellation(stoppingToken))
{
await writer.WriteLineAsync($"{lockedUser.Id} {lockedUser.UserName} {lockedUser.LockoutEnd}");
}
}
}
catch (OperationCanceledException e)
{
_logger.LogError("OperationCanceledException: {e}", e.Message);
}
}
}
数据的校验:FluentValidation
内置 DataAnnotations 进行约束
public sealed class RegisterInput
{
public RegisterInput(string userName, string password, string password2)
{
UserName = userName;
Password = password;
Password2 = password2;
}
[Required]
[MinLength(6)]
public string UserName { get; init; }
[Required]
public string Password { get; init; }
[Required]
[Compare(nameof(Password))]
public string Password2 { get; init; }
}
不推荐这种用法。
FluentValidation 进行约束
依赖第三方库:FluentValidation.AspNetCore
添加服务
builder.Services.AddFluentValidationAutoValidation()
.AddFluentValidationClientsideAdapters()
.AddValidatorsFromAssembly(Assembly.GetEntryAssembly());
在当前程序集的 Validator 都会加入 DI 中。
使用
所有 Validator 必须继承于 AbstractValidator<>
, 在构造函数描述规则。
Validator 可以使用绝大多数 DI 服务
public class RegisterInputValidator : AbstractValidator<RegisterInput>
{
public RegisterInputValidator()
{
RuleFor(r => r.UserName)
.NotEmpty()
.Length(6, 20);
RuleFor(r => r.Password)
.Equal(r => r.Password2)
.WithMessage("前后密码不一致!");
}
}
注意: 在 Validator 自动验证中无法调用异步方法
在 Validator 自动验证中调用异步方法可能导致死锁!最新版 FluentValidation 已经禁用了这项功能。
//!! 以下代码引发异常
public class RegisterInputValidator : AbstractValidator<RegisterInput>
{
public RegisterInputValidator(UserManager<User> userManager)
{
RuleFor(r => r.UserName)
.NotEmpty()
.Length(6, 20)
.MustAsync(async (name, _)
=> await userManager.FindByNameAsync(name) == null)
.WithMessage("用户名已经存在!");
RuleFor(r => r.Password)
.Equal(r => r.Password2).WithMessage("前后密码不一致!");
}
}
即时通讯的封装:SignalR
SignalR 基本概念
WebSocket 是一种协议,它支持通过 TCP 连接建立持久的双向信道。它适用于需要快速实时通信的应用,如聊天、仪表板和游戏应用
ASP.NET SignalR 是即时通讯功能的封装,可以简化向应用程序添加实时 Web 功能的过程。
ASP.NET SignalR 会尽可能地使用 WebSocket 作为传输方式,因为它具有最高的效率和最低的延迟。但是,如果客户端或服务器不支持 WebSocket,ASP.NET SignalR 会自动回退到其他传输方式(按正常回退的顺序):
- WebSockets
- Server-Sent Events
- 长轮询
SignalR 自动选择服务器和客户端能力范围内的最佳传输方法。
SignalR 使用 Hub 在客户端和服务器之间进行通信。Hub 是一种高级管道,允许客户端和服务器相互调用方法。
基本使用
后端
- 添加 CORS(含 AllowCredentials)和 SignalR 服务
- 编写 Hub 并映射 Hub 到 API。
// Program.cs
// add CORS supports
string[] urls =
{
// 前端地址
"http://localhost:5173"
};
builder.Services.AddCors(options => options.AddDefaultPolicy(
corsPolicyBuilder => corsPolicyBuilder.WithOrigins(urls)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials()
)
);
builder.Services.AddSignalR();
// ...
// 注意顺序
app.UseCors();
app.UseHttpsRedirection();
app.MapHub<ChatRoomHub>("/chatroom");
app.MapControllers();
public class ChatRoomHub : Hub
{
public async Task SendPublicMessage(string userName, string message)
{
await Clients.All.SendAsync(
"ReceiveMessage", userName, message,
$"{DateTime.Now.ToShortDateString()} {DateTime.Now.ToShortTimeString()}");
}
}
前端
- 依赖
import * as SignalR from '@microsoft/signalr'
- 在挂载页面时,初始化连接。
- 使用
await connection.invoke
向后端对应方法发送请求,使用connection.on
监听服务器发送对应端口数据。
<script setup>
import * as SignalR from '@microsoft/signalr'
import { reactive, onMounted } from 'vue'
// import { NInput, NSpace, NButton } from 'naive-ui';
const state = reactive({
userName: '',
userMessage: '',
messageList: []
})
let connection;
onMounted(async () => {
connection = new SignalR.HubConnectionBuilder()
.withUrl('https://localhost:7035/chatroom')
.withAutomaticReconnect()
.build();
await connection.start();
connection.on('ReceiveMessage', (userName, message, sentTime) => {
console.log(userName, message);
state.messageList.push({ userName, userMessage: message, sentTime });
});
});
const sendMessage = async () => {
console.log(state.userName, state.userMessage);
await connection.invoke('SendPublicMessage', state.userName, state.userMessage);
state.userMessage = '';
}
</script>
服务协商问题
SignalR 协议协商问题是指在多台服务器组成的集群中,客户端和服务器之间的协商请求和 WebSocket 请求可能由不同的服务器处理,导致连接失败或不稳定的问题。
协商请求是指客户端向服务器询问支持什么传输协议的请求。
解决该问题有两个方法:
- 粘性会话是指对负载均衡服务器进行配置,以便把来自同一个客户端的请求都转发给同一台服务器。这样就避免了协商请求和 WebSocket 请求由不同服务器处理的问题。(缺乏可扩容性)
- (推荐)禁用协商 是指客户端不和服务器进行传输协议的协商,而直接向服务器发出 WebSocket 请求。这样就避免了两次请求状态保持的问题,而且 WebSocket 连接一旦建立后,后续的通信都由同一台服务器来处理。(在老式Web浏览器不支持)
禁用协商
要配置禁用协商只需要在前端在建立连接时进行配置即可。
connection = new SignalR.HubConnectionBuilder()
.withUrl('https://localhost:7035/chatroom', {
skipNegotiation: true,
transport: SignalR.HttpTransportType.WebSockets
})
.withAutomaticReconnect()
.build();
SignalR 身份授权
后端配置
以JWT认证为例,除了 [Authorize]
标记Hub对应方法外,需要额外添加事件提示 WSS 连接时的令牌,因为 WSS 连接不支持报文头。
var jwtOptionsSection = builder.Configuration.GetSection("JwtOptions");
builder.Services.Configure<JwtOptions>(jwtOptionsSection);
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(jb =>
{
var jwtOptions = jwtOptionsSection.Get<JwtOptions>();
byte[] keyBytes = Encoding.UTF8.GetBytes(jwtOptions.SecretToken);
var secretKey = new SymmetricSecurityKey(keyBytes);
jb.TokenValidationParameters = new()
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = secretKey,
ClockSkew = TimeSpan.Zero
};
jb.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.Request.Path;
if (!string.IsNullOrEmpty(accessToken)
&& path.StartsWithSegments("/chatroom"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
前端
在用户登陆时记录对应令牌(通常保存到 Cookie)。
let connectionOptions = {
skipNegotiation: true,
transport: SignalR.HttpTransportType.WebSockets
};
connectionOptions.accessTokenFactory = () => accessToken;
try {
userInfo = (await axios.get('https://localhost:8000/api/User/UserInfo')).data;
connection = new SignalR.HubConnectionBuilder()
.withUrl('https://localhost:8000/chatroom', connectionOptions)
.withAutomaticReconnect()
.build();
await connection.start();
connection.on('ReceiveMessage', (userName, message, sentTime) => {
// console.log(userName, message);
state.messageList.push({
userName, userMessage: message,
sentTime
});
})
// console.log(userInfo);
} catch (error) {
// error!
}
外部向 Hub 发送公共信息
只需要使用 DI 注入即可直接使用。
[HttpPost]
public async Task<ActionResult> Register(RegisterInput input)
{
var user = new User { UserName = input.UserName };
var result = await _userManager.CreateAsync(user, input.Password);
if (!result.Succeeded) return BadRequest(result.ToString());
await _userManager.AddToRoleAsync(user, Role.StandardUser);
await _chatRoomHubContext.Clients.All.SendAsync(
"ReceivePublicMessage", "Himu 客服酱",
$"** 欢迎用户 {user.UserName} 加入!**",
$"{DateTime.Now.ToShortDateString()} {DateTime.Now.ToShortTimeString()}");
return Ok(await _redisCache.CreateResponseTokenAsync<User, long>(user, _jwtOptions.Value));
}
示例:为大量数据的上传提供进度支持
描述 将一个 CSV 文件中的数据导入到数据库中的一个表中,并使用 SignalR 向客户端发送导入进度的消息。
过程概述
前端上传文件(向控制器发送上传请求) \(\rightarrow\) 保存文件 \(\rightarrow\) 前端向 Hub 发送导入请求 \(\rightarrow\) 服务器根据先前上传的文件完成导入,并使用 SignalR 通知进度。
首先将前端上传的文件保存到服务器本地。
对于 Navie-UI 封装的 NUpload 控件,上传的文件会以 multpart/form-data 传递到请求中,对应
Request.Form.Files
. 关于如何将 token 传递到NUpload封装的上传方法参见前端代码部分。
public class ImportDictionaryController : ControllerBase
{
[HttpPost]
public async Task<ActionResult> UploadDictionaryFile()
{
const string saveRoot = "AppData";
long totalSize = 0;
foreach (var file in Request.Form.Files)
{
if (file.Length <= 0) continue;
totalSize += file.Length;
string path = Path.Combine(saveRoot, file.FileName);
await using var stream = System.IO.File.Create(path);
await file.CopyToAsync(stream);
}
return Ok(new { Request.Form.Files.Count, totalSize });
}
}
ImportDictionaryExecutor
封装了导入词典的具体细节:
public class ImportDictionaryExecutor
{
private readonly IHubContext<ChatRoomHub> _hubContext;
public ImportDictionaryExecutor(IHubContext<ChatRoomHub> hubContext)
{
_hubContext = hubContext;
}
public async Task
ImportDictionaryToDatabase(string fileName, string connectionString, string connectionId)
{
using StreamReader reader = new(Path.Combine("AppData", fileName), Encoding.UTF8);
using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
csv.Context.RegisterClassMap<EnglishWordCsvMap>();
var words = await csv.GetRecordsAsync<EnglishWord>().ToListAsync();
int totalCount = words.Count, count = 0;
using SqlBulkCopy bulkCopy = new(connectionString);
bulkCopy.DestinationTableName = "T_EnglishWords";
bulkCopy.ColumnMappings.Add("Word", "Word");
bulkCopy.ColumnMappings.Add("Phonetic", "Phonetic");
bulkCopy.ColumnMappings.Add("Definition", "Definition");
bulkCopy.ColumnMappings.Add("Translation", "Translation");
using DataTable dataTable = new();
dataTable.Columns.Add("Word");
dataTable.Columns.Add("Phonetic");
dataTable.Columns.Add("Definition");
dataTable.Columns.Add("Translation");
foreach (var englishWord in words)
{
DataRow row = dataTable.NewRow();
row["Word"] = englishWord.Word;
row["Phonetic"] = englishWord.Phonetic;
row["Definition"] = englishWord.Definition;
row["Translation"] = englishWord.Translation;
dataTable.Rows.Add(row);
++count;
if (dataTable.Rows.Count != 1000) continue;
await bulkCopy.WriteToServerAsync(dataTable);
await _hubContext.Clients
.Client(connectionId)
.SendAsync("ReceiveImportProgress", count, totalCount);
dataTable.Clear();
}
await bulkCopy.WriteToServerAsync(dataTable);
await _hubContext.Clients
.Client(connectionId)
.SendAsync("ReceiveImportProgress", count, totalCount);
GC.Collect();
}
}
使用 StreamReader
和 CsvReader
类来读取 CSV 文件中的数据,并使用 GetRecordsAsync
方法将数据转换为 EnglishWord
类型的对象,并存储在一个 List<T>
中。RegisterClassMap
则是描述了Csv每列数据到对象的映射。
public sealed class EnglishWordCsvMap : CsvHelper.Configuration.ClassMap<EnglishWord>
{
public EnglishWordCsvMap()
{
Map(m => m.EnglishWordId).Ignore();
Map(m => m.Word).Name("word");
Map(m => m.Phonetic).Name("phonetic");
Map(m => m.Definition).Name("definition");
Map(m => m.Translation).Name("translation");
}
}
注意,应该使用 IHubContext
注入 Hub 连接。但是 ImportDictionaryExecutor
本身并不知道目前是哪一条客户端连接,所以其 connectionId
是由客户端发起导入请求对应的Hub方法传递的。(见下)
[Authorize]
public Task ImportDictionary(string fileName)
{
// 不等待,让服务器后台运行,具体进度会通过 SignalR 通知客户端
_ = _importExecutor.ImportDictionaryToDatabase(fileName,
_dictionaryContext.Database.GetConnectionString()!, Context.ConnectionId);
return Task.CompletedTask;
}
要使用以上 API, 前端只需要:
- 在页面挂载之时,建立 SignalR 连接并监听 ReceiveImportProgress 接口.
- 在上传完文件后,向 Hub 的 ImportDictionary 发送请求
<template>
<n-progress type="line"
:percentage="state.percentage"
status="success">
</n-progress>
<n-upload
action="https://localhost:8000/api/ImportDictionary/UploadDictionaryFile"
:custom-request="customUpload">
<n-button> 上传词典数据 </n-button>
</n-upload>
<n-button type="primary" @click="importDictionary"> 导入 </n-button>
</template>
<script>
const state = reactive({
userNameTo: '',
userName: Cookies.get('userName'),
userMessage: '',
messageList: [],
percentage: 0
});
onMounted(async () => {
let connectionOptions = {
skipNegotiation: true,
transport: SignalR.HttpTransportType.WebSockets
};
connectionOptions.accessTokenFactory = () => accessToken;
try {
userInfo = (await axios.get('https://localhost:8000/api/User/UserInfo')).data;
connection = new SignalR.HubConnectionBuilder()
.withUrl('https://localhost:8000/chatroom', connectionOptions)
.withAutomaticReconnect()
.build();
await connection.start();
connection.on('ReceiveImportProgress', (current, total) => {
state.percentage = Math.ceil(current / total * 100);
});
} catch (error) {
console.log(error);
loadingBar.error();
message.error(`无法建立连接: ${error}`);
}
});
// 上传文件
const customUpload = async ({
file,
data,
headers,
withCredentials,
action,
onFinish,
onError,
onProgress
}) => {
const formData = new FormData();
if (data) {
Object.keys(data).forEach((key) => {
formData.append(
key,
data[key]
);
});
}
formData.append(file.name, file.file);
axios.post(action, formData, {
withCredentials,
onUploadProgress: ({ total, loaded }) => {
onProgress({
percent: Math.ceil(loaded / total * 100)
});
}
}).then(() => {
onFinish();
}).catch((error) => {
onError();
});
fileName = file.name;
}
const importDictionary = async() => {
console.log('importDictionary start');
await connection.invoke('ImportDictionary', fileName);
}
</script>