Loading

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 是一种高级管道,允许客户端和服务器相互调用方法。

基本使用

后端

  1. 添加 CORS(含 AllowCredentials)和 SignalR 服务
  2. 编写 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()}");
    }
}

前端

  1. 依赖 import * as SignalR from '@microsoft/signalr'
  2. 在挂载页面时,初始化连接。
  3. 使用 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 请求可能由不同的服务器处理,导致连接失败或不稳定的问题。

协商请求是指客户端向服务器询问支持什么传输协议的请求。

解决该问题有两个方法:

  1. 粘性会话是指对负载均衡服务器进行配置,以便把来自同一个客户端的请求都转发给同一台服务器。这样就避免了协商请求和 WebSocket 请求由不同服务器处理的问题。(缺乏可扩容性)
  2. (推荐)禁用协商 是指客户端不和服务器进行传输协议的协商,而直接向服务器发出 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();
    }
}

使用 StreamReaderCsvReader 类来读取 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>
posted @ 2025-03-14 21:28  Himu  阅读(21)  评论(0)    收藏  举报