Authentication与Authorization

1.Authentication对访问者的用户身份进行验证,“用户是否登陆成功”。
2.Authorization验证访问者用户身份是否又对资源访问的访问权限,“用户是否有权限访问这个地址”。

标识(Identity)框架

1.标识(Identity)框架:采用基于角色的访问控制(Role-Based Access Control,简称RBAC)策略,内置了对用户、角色等表的管理以及相关的接口,支持外部登录、2FA等。
2.标识框架使用EF Core对数据进行操作,因此标识框架支持几乎所有数据库。

Identity框架使用

1.IdentityUser<TKey>、IdentityRole<TKey>,TKey代表主键的类型。我们一般编写继承自IdentityUser<TKey>、IdentityRole<TKey>等的自定义类,可以增加自定义属性。
2.NuGet安装
Microsoft.AspNetCore.Identity.EntityFrameworkCore
3.创建继承自IdentityDbContext的类。
4.可以通过IdDbContext类来操作数据库,不过框架中提供了RoleManager、UserManager等类来简化对数据库的操作。
5.部分方法的返回值为Task<IdentityResult>类型,查看、讲解IdentityResult类型定义。

  1. 创建用户实体类User和角色实体类Role. 分别继承自IdentityUser<long>、IdentityRole<long>的User类和Role类。
1
2
3
4
5
public class User:IdentityUser<long>
{
public DateTime CreationTime { get; set; }
public string? NickName { get; set; }
}
1
2
3
public class Role:IdentityRole<long>
{
}

IdnetityUser中定义类了很多属性。

除了IdentityUser和IdentityRole之外,表示框架中还有很多其他实体类。

  1. 创建继承自IdentityDbContext的类,这是一个EF Core中的上下文类,我们可以通过这个类操作数据库。IdentityDbContext是一个泛型类,有3个泛型参数,分别代表用户类型、角色类型和主键类型。
1
2
3
4
5
6
7
8
9
10
11
12
13
public class IdDbContext:IdentityDbContext<User,Role,long>
{
public IdDbContext(DbContextOptions<IdDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
new UserConfig().Configure(builder.Entity<User>());
new RoleConfig().Configure(builder.Entity<Role>());
}
}

标识框架中的方法有执行失败的可能,比如重置密码可能由于密码太简单而失败,因此标识框架中部分方法的返回值为Task类型。IdentityResult类型中有bool类型的Succeeded属性表示操作是否成功;如果操作失败,我们可以从Errors属性中获取错误的详细信息,由于有可能有多条错误信息,因此Errors是一个IEnumerable类型的属性。IdentityError类包含Code(错误码)和Description(错误的详细信息)这两个属性。

  1. 向依赖注入容器中注册与标识框架相关的服务。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
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();

IServiceCollection services = builder.Services;
//配置数据库
services.AddDbContext<IdDbContext>(opt =>
{
string? connStr = builder.Configuration.GetConnectionString("Default");
opt.UseSqlServer(connStr);
});
services.AddDataProtection(); //对数据进行保护

//调用AddIdentityCore添加标识框架的一些重要的基础服务
services.AddIdentityCore<User>(options =>
{
//对密码进行设置
options.Password.RequireDigit = false; //必须是数字
options.Password.RequireLowercase = false; //小写
options.Password.RequireNonAlphanumeric = false; //非字母数字
options.Password.RequireUppercase = false; //大写字母
options.Password.RequiredLength = 6;//长度6
options.Tokens.PasswordResetTokenProvider = TokenOptions.DefaultEmailProvider;
options.Tokens.EmailConfirmationTokenProvider = TokenOptions.DefaultEmailProvider;
});
//在使用标识框架的时候也需要注入和初始化非常多的服务
var idBuilder = new IdentityBuilder(typeof(User), typeof(Role), services);
idBuilder.AddEntityFrameworkStores<IdDbContext>()
.AddDefaultTokenProviders()
.AddRoleManager<RoleManager<Role>>()
.AddUserManager<UserManager<User>>();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

然后执行数据库迁移Add-Migration createIdentity、Update-database等命令执行EF Core的数据库迁移,然后程序就会在数据库中生成多张数据库表。这些数据库表都由标识框架负责管理,开发人员一般不需要直接访问这些表。

  1. 编写控制器的代码。我们在控制器中需要对角色、用户进行操作,也需要输出日志,因此通过控制器的构造方法注入相关的服务。编写创建角色和用户的方法CreateUserRole。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
public class TestController : ControllerBase
{
private readonly ILogger<TestController> _logger;
private readonly UserManager<User> _userManager;
private readonly RoleManager<Role> _roleManager;

public TestController
(
ILogger<TestController> logger,
UserManager<User> userManager,
RoleManager<Role> roleManager
)
{
_logger = logger;
_userManager = userManager;
_roleManager = roleManager;
}
[HttpPost]
public async Task<IActionResult> CreateUserRole()
{
//检查系统中是否已存在名为 "admin" 的角色
bool roleExists = await _roleManager.RoleExistsAsync("admin");
//若不存在,则创建一个名为 "Admin" 的角色实例
//(注意:角色名称为 "Admin",与检查时的 "admin" 大小写不一致,可能导致后续问题)
if (!roleExists)
{
Role role = new Role { Name = "Admin" };
var r = await _roleManager.CreateAsync(role);
if (!r.Succeeded)
{
return BadRequest(r.Errors);
}
}
//检查名为 "if" 的用户是否存在
User user = await _userManager.FindByNameAsync("if");
//若不存在,则创建新用户并设置用户名、邮箱,同时确认邮箱已验证
//(EmailConfirmed = true)
if (user == null)
{
user = new User
{
UserName = "YOUXIANYU",
Email = "123456@QQ.COM",
EmailConfirmed = true,
};
//使用密码 "123456" 创建用户(注意:生产环境中应使用更安全的密码策略)
var r = await _userManager.CreateAsync(user, "123456");
if (!r.Succeeded)
{
return BadRequest(r.Errors);
}
//将用户添加到 "admin" 角色
//(注意:此处角色名称为 "admin",与创建时的 "Admin" 大小写不一致,可能导致角色分配失败)
r = await _userManager.AddToRoleAsync(user, "admin");
if (!r.Succeeded)
{
return BadRequest(r.Errors);
}
}
return Ok("注册成功");
}
}

6.编写处理登录请求的操作方法Login。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
[HttpPost]
public async Task<IActionResult> Login(LoginRequest loginRequest)
{
string userName = loginRequest.UserName;
string password = loginRequest.Password;
try
{
//通过_userManager查找用户,若不存在则返回 HTTP 404。
var user = await _userManager.FindByNameAsync(userName);
if (user == null)
{
return NotFound($"用户名或密码错误");
}
//检查用户是否因多次登录失败被锁定(基于ASP.NET Identity 的锁定机制)
var islocked = await _userManager.IsLockedOutAsync(user);
if (islocked)
{
var lockoutEnd = await _userManager.GetLockoutEndDateAsync(user);
return BadRequest($"用户已锁定,解锁时间:{lockoutEnd.Value.LocalDateTime}");
}
//使用CheckPasswordAsync验证密码,成功则返回 HTTP 200
var success = await _userManager.CheckPasswordAsync(user, password);
if (success)
{
return Ok("登录成功");
}
//调用AccessFailedAsync记录登录失败次数,触发锁定逻辑(若达到失败次数阈值)
//若记录失败则返回特定错误,否则返回通用失败信息
else
{
var r = await _userManager.AccessFailedAsync(user);
if (!r.Succeeded)
{
return BadRequest("访问失败信息写入错误!");
}
else
{
return BadRequest("失败!");
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "登录过程发生异常");
return StatusCode(500, "登录失败,请稍后重试");
}
}
  1. 检查登录用户信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[HttpPost]
public async Task<ActionResult<string>> CheckPwd(CheckPwdRequest req) //检查密码
{
string userName = req.userName; //用户名
string password = req.password; //密码
var user = await userConfig.FindByNameAsync(userName); //查找用户
if(user == null) // 用户不存在
return NotFound($"用户{userName}不存在"); // 返回404
if (await userConfig.IsLockedOutAsync(user)) //判断用户是否锁定
return BadRequest("用户已锁定");
var success = await userConfig.CheckPasswordAsync(user, password); //检查密码
if (success) //密码正确
{
await userConfig.ResetAccessFailedCountAsync(user); //重置失败次数
return Ok("密码正确"); //返回200
}
else
{
await userConfig.AccessFailedAsync(user); //增加失败次数
var count = await userConfig.GetAccessFailedCountAsync(user); //获取失败次数
if (count >= 5) //失败次数超过5次
{
await userConfig.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddMinutes(5)); //锁定用户
return BadRequest("用户已锁定");
}
return BadRequest($"密码错误,还有{5 - count}次机会");
}
}

实现密码的重置

1.生成重置Token
2.Token发给客户(邮件、短信等),形式:连接、验证码等。
3.根据Token完成密码的重置。

编写一个发送重置密码请求的操作方法SendResetPasswordToken.

1
2
3
4
5
6
7
8
9
10
[HttpPost]
public async Task<ActionResult> SendResetPasswordToken(string userName) //发送重置密码的token
{
var user = await userConfig.FindByNameAsync(userName); //查找用户
if(user == null) // 用户不存在
return NotFound($"用户{userName}不存在");
string token = await userConfig.GeneratePasswordResetTokenAsync(user); //生成重置密码的token
Console.WriteLine($"验证码是{token}"); //输出验证码
return Ok("ok");
}

调用GeneratePasswordResetTokenAsyncc方法来生成一个密码重置令牌,这个令牌会保存到数据库中,然后我们把这个令牌发送到用户邮箱。

编写重置密码的操作方法VerifyResetPasswordToken。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[HttpPut]
public async Task<ActionResult> ResetPassword(string userName,string token,string newPassword) //重置密码
{
var user = await userConfig.FindByNameAsync(userName); //查找用户
if (user == null) // 用户不存在
return NotFound($"用户{userName}不存在");
var r = await userConfig.ResetPasswordAsync(user, token, newPassword); //重置密码
if(r.Succeeded) // 重置密码成功
{
await userConfig.ResetAccessFailedCountAsync(user); // 重置失败次数
return Ok("重置密码成功");
}
else
{
await userConfig.AccessFailedAsync(user); //增加失败次数
return BadRequest("重置密码失败");
}
}

JWT(Json Web Token)

1.JWT把登录信息(也称作令牌)保存在客户端。
2.为了防止客户端的数据造假,保存在客户端的令牌经过了签名处理,而签名的密钥只有服务器端才知道,每次服务器段收到客户端提交过来的令牌的时候都要检查一下签名。
3.基于JWT如何实现“登录”。

JWT实现登陆的流程如下。

  1. 客户端向服务端发送用户名、密码等请求登录。
  2. 服务器端校验用户名、密码,如果校验成功,则从数据库中取出这个用户的ID、角色等用户相关信息。
  3. 服务器段采用只有服务器端才知道的密钥来对用户信息的JSON字符串进行签名,形成签名数据。
  4. 服务器端把用户信息的JSON字符串和签名拼接到一起形成JWT,然后发送给客户端。
  5. 客户端保存服务器返回的JWT,并且在客户端每次向服务器端发送请求的时候都带上这个JWT。
  6. 每次服务器端收到浏览器请求中携带的JWT后,服务器端用密钥对JWT的签名进行校验,如果校验成功,服务器端则从JWT中的JSON字符串中读取出用户的信息。这样服务器端就知道这个请求对应的用户了,也就实现了登录的功能。

JWT的基本使用

先创建一个控制台程序用来创建JWT,在创建一个程序读取JWT。
.NET中进行JWT读取的NuGet包是:System.IdentityModel.Tokens.Jwt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//NameIdentifier 和 Name:用于标识用户身份
//两个Role声明:表示用户同时拥有 "User" 和 "Admin" 角色
//自定义声明jz:可用于传递业务相关数据(如账户级别)
var claims = new List<Claim>();
claims.Add(new Claim(ClaimTypes.NameIdentifier, "6")); // 用户ID
claims.Add(new Claim(ClaimTypes.Name, "路飞")); // 用户名
claims.Add(new Claim(ClaimTypes.Role, "User")); // 角色1
claims.Add(new Claim(ClaimTypes.Role, "Admin")); // 角色2
claims.Add(new Claim("jz", "112233")); // 自定义声明

string key = "hweorgnfhsoifhjafvbsfjawoighnbsdhfvklawhng"; // 密钥
DateTime expires = DateTime.Now.AddDays(1); //过期时间

//使用 HMAC-SHA256 算法对 JWT 进行签名
//密钥长度需足够(建议至少 128 位),且应安全存储(实际项目中不应硬编码)
byte[] secBytes = Encoding.UTF8.GetBytes(key);
var secKey = new SymmetricSecurityKey(secBytes);
var credentials = new SigningCredentials(secKey,SecurityAlgorithms.HmacSha256Signature);
//JWT 包含三部分:Header(头部)、Payload(负载)和 Signature(签名)
//生成的 JWT 可用于后续 API 请求的身份验证
var tokenDescriptor = new JwtSecurityToken(claims:claims,expires:expires,signingCredentials:credentials);
string jwt = new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);

Console.WriteLine(jwt);

运行程序

JWT被句点分隔成了3部分,分别是头部、负载和签名。JWT都是明文存储的,JWT中使用Base64URL算法对字符串进行编码,这个算法跟Base64算法基本相同,考虑到JWT可能会被放到URL中,而Base64有3个特殊字符+、/和=,它们在URL里面有特殊含义,因此我们需要从Base64中删除=,并且把+替换成-、把/替换成_。

把JWT字符串的头部和负载解码为可读的字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
string jwt = Console.ReadLine()!;
string[] segments = jwt.Split('.');
string head = JwtDecode(segments[0]); ;
string payload = JwtDecode(segments[1]);
Console.WriteLine("--------head--------");
Console.WriteLine(head);
Console.WriteLine("--------payload--------");
Console.WriteLine(payload);
string JwtDecode(string s)
{
s=s.Replace('-','+').Replace("/","-");
switch (s.Length % 4)
{
case 2:
s += "===";
break;
case 3:
s += "=";
break;
}
var bytes = Convert.FromBase64String(s);
return Encoding.UTF8.GetString(bytes);
}

JWT的编码和解码规则都是公开的,而且负部分的Claim信息也是明文的,因此恶意攻击者可以对负载部分中的用户ID等信息进行修改,从而冒充其他用户的身份来访问服务器上的资源。因此,服务器端需要对部分进行校验,从而检查JWT是否被篡改了。
我们可以调用JwtSecurityTokenHandler类对JWT进行解码,因为它会在对JWT解码前对签名进行校验。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
string jwt =Console.ReadLine()!;//从控制台读取用户输入的 JWT 字符串
//使用与生成 JWT 时相同的密钥
string secKey = "hweorgnfhsoifhjafvbsfjawoighnbsdhfvklawhng";
//创建 JWT 处理器和验证参数对象
//设置签名密钥为之前生成 JWT 时使用的密钥
//禁用发行人(Issuer)和受众(Audience)验证(实际项目中通常需要验证)
JwtSecurityTokenHandler tokenHandler = new();
TokenValidationParameters valParam = new();
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secKey));
valParam.IssuerSigningKey = securityKey;
valParam.ValidateIssuer = false;
valParam.ValidateAudience = false;
//ValidateToken 方法执行以下验证:
//签名是否有效(防止篡改)
//令牌是否过期(默认验证 exp 声明)
//其他自定义验证(根据 TokenValidationParameters 设置)
//验证通过后,从 ClaimsPrincipal 中提取所有声明并打印
ClaimsPrincipal claimsPrincipl = tokenHandler.ValidateToken(jwt, valParam, out SecurityToken sectToken);
foreach(var claim in claimsPrincipl.Claims)
{
Console.WriteLine($"{claim.Type}={claim.Value}");
}

JWT机制让我们可以把用户的信息保存到客户端,每次客户端向服务器发送请求的时候,客户端只要把JWT发送到服务器端,服务器端就可以得知当前请求用户的信息,而通过签名的机制则可以避免JWT内存被篡改。

ASP.NET Core对于JWT的封装

  1. 配置JWT节点,节点下创建SigningKey、ExpireSeconds两个配置项,分别代表JWT的密钥和过期时间(单位:秒)。在创建配置类JWTOptions,包含SigningKey、ExpireSeconds两个属性。
  2. NuGet:Microsoft.AspNetCore.Authentication.JwtBearer

第一步,在配置系统中配置一个名字为JWT的节点,并在节点下创建SigningKey、ExpireSeconds两个配置项,分别代表JWT的密钥和过期时间(单位为秒)。我们在创建一个对应JWT节点的配置类JWTOptions,类中包含SigningKey、ExpireSwconds这两个属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"JWT": {
"SigningKey": "jlkerujoigsnvjlkasjfwehiojkgnsgss",
"ExpireSeconds": "3600"
},
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=YourDatabaseName;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True;Encrypt=True;"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
1
2
3
4
5
public class JWTOptions
{
public string SigningKey { get; set; }
public int ExpirSeconds { get; set; }
}

第三步,编写代码对JWT进行配置,内容添加到Program.cs的builder.Build之前。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
//从配置文件(如appsettings.json)的 "JWT" 部分读取配置,并注册为JWTOptions类型的选项服务
//后续可通过依赖注入使用这些配置
services.Configure<JWTOptions>(builder.Configuration.GetSection("JWT"));
//设置应用的主要认证方案为 JWT Bearer
//添加 JWT 承载认证处理器
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(x =>
{
//再次读取 JWT 配置(存在重复读取问题)
/*var jwtOptions = builder.Configuration.GetSection("JWT").Get<JWTOptions>();

// 注册JWT选项到依赖注入容器
services.Configure<JWTOptions>(builder.Configuration.GetSection("JWT"));

// 生成安全密钥
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey));*/
//从配置中获取签名密钥并转换为安全密钥对象
var jwtOpt = builder.Configuration.GetSection("JWT").Get<JWTOptions>();
byte[] keyBytes = Encoding.UTF8.GetBytes(jwtOpt.SigningKey);
var secKey = new SymmetricSecurityKey(keyBytes);
x.TokenValidationParameters = new TokenValidationParameters()
{
ValidateIssuer = false, // 不验证令牌发行人
ValidateAudience = false, // 不验证令牌受众
ValidateLifetime = false, // 不验证令牌有效期
ValidateIssuerSigningKey = true, // 验证签名密钥
IssuerSigningKey = secKey // 设置签名密钥
};
});
/*// 从配置中读取JWT选项
var jwtOptions = builder.Configuration.GetSection("JWT").Get<JWTOptions>();

// 注册JWT选项到依赖注入容器
services.Configure<JWTOptions>(builder.Configuration.GetSection("JWT"));

// 生成安全密钥
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey));

// 配置JWT认证
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true, // 验证发行人
ValidateAudience = true, // 验证受众
ValidateLifetime = true, // 验证过期时间
ValidateIssuerSigningKey = true, // 验证签名密钥
ValidIssuer = jwtOptions.Issuer, // 设置有效发行人
ValidAudience = jwtOptions.Audience, // 设置有效受众
IssuerSigningKey = securityKey, // 设置签名密钥
ClockSkew = TimeSpan.Zero // 严格验证过期时间
};

// 可选:添加事件处理(如令牌验证失败时的自定义响应)
options.Events = new JwtBearerEvents
{
OnAuthenticationFailed = context =>
{
if (context.Exception is SecurityTokenExpiredException)
{
context.Response.Headers.Add("Token-Expired", "true");
}
return Task.CompletedTask;
}
};
});*/
var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}

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

app.MapControllers();

app.Run();

第4步,在Program.cs的app.UseAuthorization之前添加app.UseAuthentication。

第五步,在TestController类中增加登录并且创建JWT的操作方法Login。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
[HttpPost]
//LoginRequest:包含用户名和密码的登录请求模型
//IOptions<JWTOptions>:从依赖注入容器获取 JWT 配置选项
public async Task<IActionResult> Login(LoginRequest loginRequest, [FromServices] IOptions<JWTOptions> jwtOptions)
{
string userName = loginRequest.UserName;
string password = loginRequest.Password;
//通过用户名查找用户
var user = await _userManager.FindByNameAsync(userName);
//验证密码是否正确
var success = await _userManager.CheckPasswordAsync(user,password);
if (!success)
{
//验证失败返回 HTTP 400 错误
return BadRequest("失败");
}
var claims = new List<Claim>();
//NameIdentifier:用户唯一标识
claims.Add(new Claim(ClaimTypes.NameIdentifier,user.Id.ToString()));
//Name:用户名
claims.Add(new Claim(ClaimTypes.Name,user.UserName));
var roles = await _userManager.GetRolesAsync(user);
foreach(var role in roles)
{
//Role:用户角色(可包含多个)
claims.Add(new Claim(ClaimTypes.Role, role));
}
//调用辅助方法生成 JWT 字符串
//返回 HTTP 200 响应,包含生成的令牌
string jwtToken = BuildToken(claims, jwtOptions.Value);
return Ok(jwtToken);
}
/// <summary>
///
/// </summary>
/// <param name="claims"></param>
/// <param name="options"></param>
/// <returns></returns>
/// 这是一个静态方法,根据用户声明和配置生成 JWT
private static string BuildToken(IEnumerable<Claim> claims,JWTOptions options)
{
///从配置中读取过期秒数,计算令牌过期时间
DateTime expires = DateTime.Now.AddSeconds(options.ExpirSeconds);
//使用配置中的签名密钥创建安全密钥对象
byte[] keyBytes = Encoding.UTF8.GetBytes(options.SigningKey);
//指定 HMAC SHA-256 算法进行签名
var secKey = new SymmetricSecurityKey(keyBytes);
var credentials = new SigningCredentials(secKey, SecurityAlgorithms.HmacSha256Signature);
//创建 JWT 令牌描述符,包含过期时间, 签名凭证, 用户声明
var tokenDescriptor = new JwtSecurityToken(expires:expires,signingCredentials:credentials,claims:claims);
//使用JwtSecurityTokenHandler将令牌描述符转换为 JWT 字符串
return new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);
}

第六步,在需要登录才能访问的控制器类或者Action方法上添加[Authorize]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[Authorize]
public class Demo2Controller : ControllerBase
{
[HttpGet]
public IActionResult Hello()
{
string id= this.User.FindFirst(ClaimTypes.NameIdentifier)!.Value; //用户ID
string userName = this.User.FindFirst(ClaimTypes.Name)!.Value; //用户名
IEnumerable<Claim> roleClaims = this.User.FindAll(ClaimTypes.Role); //角色
string roleNames = string.Join(",", roleClaims.Select(c => c.Value)); //角色

return Ok($"Hello {userName}, your ID is {id}, and your roles are {roleNames}.");
}
}

第七步,测试登录和访问。用PostMan自定义报文头:Authorization的值为“Bearer JWTToken”,Authorization的值中的“Bearer”和JWT令牌之间一定要通过空格分隔。前后不能多出来额外的空格、换行等。

[Authorize]的注意事项

  1. ASP.NET Core中身份验证和授权验证的功能由Authentication、Authorization中间件提供:app.UseAuthentication()、app.UseAuthorization()。
  2. 控制器类上标注[Authorize],则所有操作方法都会被进行身份验证和授权验证;对于标注了[Authorzie]的控制器中,如果其中某个操作方法不想被验证,可以在操作方法上添加[AllowAnonymous]。如果没有在控制器类上标注[Authorize],那么这个控制器中的所有操作方法都允许被自由地访问;对于没有标注[Authorzie]的控制器中,如果其中某个操作方法需要被验证,文买也可以在操作方法上添加[Authorize].
  3. ASP.NET Core会按照HTTP协议的规范,从Authorization取出来令牌,并且进行校验、解析,然后把解析结果填充到User属性中,这一切都是ASP.NET Core完成的,不需要开发人员自己编写代码。但是一旦出现401,没有详细的报错信息,很难排查。

让Swagger中调试待验证的请求跟简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
builder.Services.AddSwaggerGen(c =>
{
var scheme = new OpenApiSecurityScheme()
{
/*Description:在 Swagger UI 中显示的描述信息,指导用户如何输入认证信息*/
Description = "Authorization header.\r\nExample:'Bearer 12345abcdef'",
/*Reference:引用类型为安全方案,ID 为 "Authorization"*/
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Authorization"
},
/*Scheme:指定为 "oauth2",尽管我们使用的是 JWT,但 Swagger UI 使用此值来显示授权按钮*/
Scheme = "oauth2",
/*Name:HTTP 请求头的名称,通常为 "Authorization"*/
Name = "Authorization",
/*In:指定在请求头中传递认证信息*/
In = ParameterLocation.Header,
/*Type:指定为 API 密钥类型*/
Type = SecuritySchemeType.ApiKey,
};
/*将上面定义的安全方案添加到 Swagger 生成器中,名称为 "Authorization"*/
c.AddSecurityDefinition("Authorization", scheme);
/*创建一个安全需求对象,要求所有 API 操作都需要上述定义的安全方案*/
var requirement = new OpenApiSecurityRequirement();
/*空的字符串列表表示不需要任何特定的作用域(scopes),适用于 JWT 认证*/
requirement[scheme] = new List<string>();
c.AddSecurityRequirement(requirement);
});

解决JWT无法提前撤回的难题

JWT的缺点
1.到期前,令牌无法撤销

遇到这种情况用Session更好解决这个问题。

解决方法
在用户表中增加一个整数类型的列JWTVersion,代表最后一次发放出去的令牌的版本号;每次登录、发放令牌的时候,都让JWTVersion的值自增,同时将JWTVersion的值也放到JWT令牌的负载中;当执行禁用用户、撤回用户的令牌等操作的时候,把这个用户对应的JWTVersion列的值自增;当服务器段收到客户端提交的JWT令牌后,先把JWT令牌中的JWTVersion值和数据库中JWTVersion的值做一下比较,如果JWT令牌中JWTVersion的值小于数据库中JWTVsersion的值,就说明这个JWT令牌过期了。

先配置所需程序:

配置CheckAsync扩展方法:如果用户、密码正确则空,失败则自增

1
2
3
4
5
6
7
8
9
10
11
public static class IdentityHelper
{
public static async Task CheckAsync(this Task<IdentityResult> task) //检查IdentityResult
{
var r = await task; //等待任务完成
if (!r.Succeeded) //如果操作失败
{
throw new Exception(JsonSerializer.Serialize(r.Errors)); //抛出异常并序列化错误信息
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class DemoController : ControllerBase
{

private readonly IOptionsSnapshot<JWTSettings> jwtSettings; //密钥
private readonly UserManager<MyUser> userManager; //用户管理器


public DemoController(IOptionsSnapshot<JWTSettings> jwtSettings, UserManager<MyUser> userManager)
{
this.jwtSettings = jwtSettings;
this.userManager = userManager;
}
[HttpPost]
public async Task<ActionResult<string>> Login(string userName,string password) //登录
{
var user = await userManager.FindByNameAsync(userName); //根据用户名查找用户
if(user == null) //用户不存在
{
return BadRequest("用户名或密码错误");
}
if(await userManager.CheckPasswordAsync(user,password)) //验证密码
{
await userManager.AccessFailedAsync(user).CheckAsync(); //重置登录失败次数
List<Claim> claims = new List<Claim>();
claims.Add(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())); //用户ID
claims.Add(new Claim(ClaimTypes.Name, user.UserName)); //用户名
var roles = await userManager.GetRolesAsync(user); //获取用户角色
foreach (var role in roles) //遍历角色
{
claims.Add(new Claim(ClaimTypes.Role, role)); //添加角色声明
}
string key = jwtSettings.Value.SecKey; //密钥
DateTime expires = DateTime.Now.AddSeconds(jwtSettings.Value.ExpirSeconds); //过期时间
byte[] secBytes = Encoding.UTF8.GetBytes(key); //将密钥转换为字节数组
var secKey = new SymmetricSecurityKey(secBytes); //创建对称密钥
var credentials = new SigningCredentials(secKey, SecurityAlgorithms.HmacSha256Signature); //创建签名凭据
var tokenDescriptor = new JwtSecurityToken(claims: claims, //声明
expires: expires, //过期时间
signingCredentials: credentials); //签名凭据
string jwt = new JwtSecurityTokenHandler().WriteToken(tokenDescriptor); //生成JWT
return jwt; //输出JWT
}
else
{
await userManager.AccessFailedAsync(user).CheckAsync(); //增加登录失败次数
return BadRequest("用户名或密码错误");
}
}
}
  1. 为用户实体User类增加一个long类型的属性JWTVersion.
1
2
3
4
5
public class MyUser:IdentityUser<long> 
{
public string? WeiXinAccount { get; set; }
public long JWTVersion { get; set; } // JWT版本号
}
  1. 修改登录并发放令牌的代码,把用户的JWTVersion属性的值自增,并且把JWTVersion的值写入JWT令牌。
1
2
3
4
5
6
7
await userManager.AccessFailedAsync(user).CheckAsync(); //重置登录失败次数
user.JWTVersion++; //增加JWT版本号
await userManager.UpdateAsync(user); //更新用户信息
List<Claim> claims = new List<Claim>();
claims.Add(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())); //用户ID
claims.Add(new Claim(ClaimTypes.Name, user.UserName)); //用户名
claims.Add(new Claim("JWTVersion", user.JWTVersion.ToString())); //JWT版本号
  1. 编写一个操作筛选器,统一实现对所有的控制器的操作方法中JWT令牌的检查操作。把JWTValidationFilter注册到Program.cs中MVC的全局筛选器中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class JWTValidationFilter : IAsyncActionFilter
{
private IMemoryCache memCache; //内存缓存
private UserManager<MyUser> userManager; //用户管理器

public JWTValidationFilter(IMemoryCache memCache, UserManager<MyUser> userManager)
{
//构造函数
//注入内存缓存和用户管理器
this.memCache = memCache;
this.userManager = userManager;
}

public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var claimUserId = context.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier); //获取用户ID
if(claimUserId == null)
{
await next(); //如果没有用户ID,继续执行
return;
}
long userId = long.Parse(claimUserId!.Value); //解析用户ID
string cacheKey = $"JWTValidationFilter.UserInfo.{userId}"; //缓存键
MyUser user = await memCache.GetOrCreateAsync(cacheKey, async e =>
{
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); //设置缓存过期时间
return await userManager.FindByIdAsync(userId.ToString()); //根据用户ID查找用户
});
if(user == null)
{
var result = new ObjectResult($"UserId({userId}) not found"); //用户不存在
result.StatusCode = (int)HttpStatusCode.Unauthorized; //未授权
context.Result = result; //设置结果
return;
}
var claimJWTVersion = context.HttpContext.User.FindFirst(ClaimTypes.Version); //获取JWT版本号
long jwtVersion = long.Parse(claimJWTVersion!.Value); //解析JWT版本号
if(jwtVersion != user.JWTVersion) //如果JWT版本号不匹配
{
next(); //继续执行
}
else
{
var result = new ObjectResult($"UserId({userId}) JWTVersion({jwtVersion}) not match"); //JWT版本号不匹配
result.StatusCode = (int)HttpStatusCode.Unauthorized; //未授权
context.Result = result; //设置结果
return; //返回
}
}
}
  1. 把JWTValidationFilter注册到Program.cs中MVC的全局筛选器中。
1
2
3
4
builder.Services.Configure<MvcOptions>(opt =>
{
opt.Filters.Add<JWTValidationFilter>(); // 添加JWT验证过滤器
});