在一个系统中,不是所有功能都能被自由地访问的,比如有的功能需要注册用户才能访问,有的功能需要VIP用户才能访问。
针对资源的访问限制有两个概念:Authentication、Authorization。
Authentication可以翻译为”鉴权”或者”身份验证”,它用来对访问者的用户身份进行验证;
Authorization可以翻译为”授权”,它用来验证访问者的用户身份是否有对资源进行访问的权限。
通俗来说,Authentication是用来验证”用户是否登录成功”的,Authorization是用来验证”用户是否有权限访问”的。
1. 标识框架
大部分系统中都需要通过数据库保存用户、角色等信息,并且需要注册、登录、密码重复、角色管理等功能。
ASP.NET Core提供了标识(identity)框架,它采用RBAC(role-based access control,基于角色的访问控制)策略,内置了对用户、角色等表的管理及相关的接口,从而简化了系统的开发。
标识框架还提供了对外部登录(比如qq登录、微信登录、微软账户登录等)的支持。
标识框架使用EF Core对数据库进行操作,由于EF Core屏蔽了底层数据库的差异,因此标识框架支持几乎所有数据库。
这里将使用控制台程序连接SQL Server来演示标识框架的使用。
标识框架中提供了IdentityUser<TKey>、IdentityRole<TKey>两个实体类型,其中的TKey代表主键的类型,因此IdentityUser<Guid>就代表使用Guid类型主键的用户实体类。
我们可以在开发的时候直接使用IdentityUser<Guid>等类型,不过使用起来比较麻烦,因此每次都要声明主键的泛型类型,而且我们一般还需要为实体类增加额外的属性。
因此我们一般编写继承自IdentityUser<TKey>、IdentityRole<TKey>等的自定义类。
第1步,创建Web API项目,并通过NuGet安装Microsoft.AspNetCore.Identity.EntityFrameworkCore。
第2步,创建用户实体类User和角色实体类Role。
在这个演示中,我们使用自增标识列类型的主键,因此我们编写分别继承自己IdentityUser<long>、IdentityRole<long>的User类和Role类,如以下代码:
public class User:IdentityUser<long>
{
public DateTime CreationTime { get; set; }
public string? NickName { get; set; }
}
public class Role:IdentityRole<long>
{
}
IdentityUser中定义了UserName、Email、PhoneNumber、PasswordHash等属性,我们在User中又添加了CreationTime、NickName两个属性。
除了IdentityUser和IdentityRole之外,标识框架中还有其他很多其他实体类,比如IdentityRoleClaim、IdentityUserLogin、IdentityUserToken等,一般情况下,我们不需要再编写这些实体类的子类。
这些实体类有默认的表名,如果需要修改默认的表名或者对实体类进行进一步的配置,我们可以用EF Core中提供的IEntityTypeConfiguration来对实体类进行配置。
第3步,创建继承自IdentityDbContext的类,这是一个EF Core中的上下文类,我们可以通过这个类操作数据库。
IdentityDbContext是一个泛型类,有3个泛型参数,分别代表用户类型、角色类型和主键类型。
IdDbContext类如以下代码所示:
public class IdDbContext:IdentityDbContext<User,Role,long>
{
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
}
}
我们可以直接通过IdDbContext类来操作数据库,不过标识框架中提供了RoleManager、UserManager等类来简化对数据库的操作,这些类封装了对IdentityDbContext的操作。
标识框架中的方法有执行失败的可能,比如重置密码可能由于密码太简单而失败,因此标识框架中部分方法的返回值为Task<IdentityResult>类型。
IdentityResult类型中有bool类型的Succeeded属性表示操作是否成功;
如果操作失败,我们可以从Errors属性中获取错误的详细信息,由于有可能有多条错误信息,因此Errors是一个IEnumerbale<IdentityError>类型的属性。
IdentityError类包含Code和Description这两个属性。
下面是RoleManager类中常用的方法:
![](https://ichiblog.cn/wp-content/uploads/2024/09/图片-17.png)
下面是UserManager类中常用的方法。
第4步,我们需要向依赖注入容器中注册与标识框架相关的服务,并且对相关的选项进行配置,如代码所示:
//注册标识框架相关服务
builder.Services.AddDbContext<IdDbContext>(options =>
{
options.UseSqlServer(builder.Configuration["ConnectionString"]);
});
//数据保护服务允许应用程序对敏感数据进行加密
builder.Services.AddDataProtection();
builder.Services.AddIdentityCore<User>(options =>
{
//不需要数字
options.Password.RequireDigit = false;
//不需要小写字母
options.Password.RequireLowercase = false;
//不需要非字母数字字符
options.Password.RequireNonAlphanumeric = false;
//不需要大写字母
options.Password.RequireUppercase = false;
//密码至少需要6个字符
options.Password.RequiredLength = 6;
//设置令牌提供者
// 使用默认的电子邮件提供者来发送密码重置令牌
options.Tokens.PasswordResetTokenProvider = TokenOptions.DefaultEmailProvider;
// 使用默认的电子邮件提供者来发送电子邮件确认令牌
options.Tokens.EmailConfirmationTokenProvider = TokenOptions.DefaultEmailProvider;
});
//集成EF Core 和 ASP.NET Core Identity
var idBuilder = new IdentityBuilder(typeof(User), typeof(Role), builder.Services);
idBuilder.AddEntityFrameworkStores<IdDbContext>()
//默认的令牌提供者
.AddDefaultTokenProviders()
//角色管理器
.AddRoleManager<RoleManager<Role>>()
//用户管理器
.AddUserManager<UserManager<User>>();
第5步,通过执行Add-Migration、Update-database等命令执行EF Core的数据库迁移,然后程序就会在数据库中生成多张数据库表,如下图所示:
![](https://ichiblog.cn/wp-content/uploads/2024/09/图片-20.png)
这些数据库表都由标识框架负责管理,开发人员一般不需要直接访问这些表。
第6步,编写控制器的代码。
我们在控制器中需要对角色、用户进行操作,也需要输出日志,因此通过控制器的构造方法注入相关的服务,如以下代码所示:
/// <summary>
/// 使用主构造函数注入服务
/// </summary>
/// <param name="_log"></param>
/// <param name="_role"></param>
/// <param name="_user"></param>
public class IdentityController(ILogger<IdentityController>_log,RoleManager<Role> _role,UserManager<User> _user)
{
}
由于RoleManager和UserManager两个类都是泛型类,因此我们需要为它们指定泛型类型。
第7步,编写创建角色和用户的方法CreateUserRole,其主干内容如以下代码:
[HttpGet]
public async Task<IActionResult> CreateUserRole()
{
//调用RoleExistsAsync方法判断名字为"admin"的角色是否存在
bool roleExists = await _role.RoleExistsAsync("admin");
//如果不存在的话,则调用RoleManager的CreateAsync方法创建角色。
if (!roleExists)
{
Role role = new() { Name = "admin" };
var result = await _role.CreateAsync(role);
if (!result.Succeeded)
{
return BadRequest(result.Errors);
}
}
//调用FindByNameAsync方法判断用户名为"ichi"的用户是否存在
User? user = await _user.FindByNameAsync("ichi");
//如果不存在,则创建这个用户,并且设置用户的密码为123456
if(user == null)
{
user = new() { UserName = "ichi", Email = "ichistudio@163.com", EmailConfirmed = true };
var result = await _user.CreateAsync(user,"123456");
if (!result.Succeeded) return BadRequest(result.Errors);
//调用AddToRoleAsync方法为"ichi"这个用户添加名字为admin的角色
result = await _user.AddToRoleAsync(user, "admin");
if(!result.Succeeded)return BadRequest(result.Errors);
}
return Ok();
}
执行上面的代码,并且访问CreateUserRole这个操作方法,如果方法执行成功,我们查看一下数据库表中的角色表AspNetRoles、用户表AspNetUsers、用户与角色关系表AspNetUserRoles
从上图可以看出,数据库中用户的密码不是明文保存的,而是以哈希值的形式保存在PasswordHash列中,这样就降低了明文保存密码的安全风险。
第8步,编写处理登录请求的操作方法Login,如以下代码所示:
[HttpGet]
public async Task<IActionResult> Login(User request)
{
string? userName = request.UserName;
string? password = request.PasswordHash;
var user = await _user.FindByNameAsync(userName);
if (user == null) return NotFound($"用户名不存在{userName}");
if (await _user.IsLockedOutAsync(user)) return BadRequest("LockedOut");
//调用CheckPasswordAsync方法来检查用户输入的密码是否正确
var success = await _user.CheckPasswordAsync(user,password);
if (success)
{
return Ok();
}
else //如果密码不正确
{
//调用AccessFailedAsync方法来记录一次"登录失败"
//当连续多次登录失败后,账户就会锁定一段时间
//以避免账户被暴力破解
await _user.AccessFailedAsync(user);
}
return Ok();
}
AspNetUser表中的LockoutEnd、LockoutEnabled、AccessFailedCount就是分别用来记录这个账户的锁定时间、是否锁定、登录失败次数的。
账户默认的锁定时间是5min,锁定用户之前的登录失败次数是5次。
需要注意的是,上面代码的演示并没有真正完成登录,而只是校验了用户名、密码,还需要校验成功后,把用户的身份信息以合适的方式保存到HTTP请求中才能完成完整的登录。
2. 实现密码的重置
首先,我们编写一个发送重置密码请求的操作方法SendResetPasswordToken,其方法体的主要部分代码如下所示:
[HttpGet]
public async Task<IActionResult> SendResetPasswordToken(string email)
{
var user = await _user.FindByEmailAsync(email);
//调用GeneratePasswordResetTokenAsync方法来生成一个密码重置令牌
//这个令牌会被保存到数据库中,然后我们把这个令牌发送到用户邮箱。
//GeneratePasswordResetTokenAsync方法默认生成的令牌很长且复杂,我们必须用超链接的形式进行传递。
string token = await _user.GeneratePasswordResetTokenAsync(user);
_log.LogInformation($"向邮箱{user.Email}发送Token={token}");
return Ok();
}
实际项目中,邮件发送一般都要调用邮件服务提供商的接口,这里没有真的发送到用户邮箱,只是把令牌输出到日志而已。
然后,编写重置密码的操作方法VerifyResetPasswordToken,其方法体的主干内容如以下代码:
[HttpGet]
public async Task<IdentityResult> VerifyResetPasswordToken(string email,string token,string newPassword)
{
var user = await _user.FindByEmailAsync(email);
//调用ResetPasswordAsync进行密码重置
return await _user.ResetPasswordAsync(user,token,newPassword);
}
运行项目,然后访问SendResetPasswordToken方法,如图所示:
![](https://ichiblog.cn/wp-content/uploads/2024/09/图片-21-1024x327.png)
我们在控制台中看到”向邮箱ichistudio@163.com发送Token=993735″这样的日志输出。
然后把我们收到的令牌及邮箱、新密码等设置到对VerifyResetPasswordToken的请求中,如下图所示,完成了密码的重置
![](https://ichiblog.cn/wp-content/uploads/2024/09/图片-22-1024x339.png)
3. 代替Session(会话)的JWT
我们知道,HTTP是无状态的,因此如果要实现”用户登录后才能访问某些资源”的功能,开发人员就要自己基于HTTP来模拟实现状态的保存。
实现用户登录功能的经典做法是用Session,也就是在用户登录验证成功后,服务器端生成唯一标识SessionId。
服务器端不仅会把SessionId返回给浏览器端,还会把SessionId和登录用户的信息的对应关系保存到服务器的内存中;
当浏览器端再次向服务器发送请求的时候,浏览器端就在HTTP请求中携带SessionId,服务器端就可以根据SessionId从服务器的内存中取到用户的信息,这样就实现了用户登录的功能。
我们一般把SessionId保存在Cookie中,而Session的数据默认是保存在服务器内存中的。
对于分布式集群环境,Session数据保存在服务器内存中就不合适了,应该保存到一个供所有集群实例访问的共用的状态服务器上。
ASP.NET Core同样支持Session机制,而且我们也可以采用Redis、Memcached、关系数据库等作为状态服务器,以便支持分布式集群环境。
Session是Web开发中在服务器端保存客户端相关状态的经典方案,但是在分布式环境下,特别是在”前后端分离、多客户端”时代,Session暴露出很多缺点。
这些缺点包括但不局限于如下几点:
- 如果Session数据保存到内存中,当登录用户量很大的时候,Session数据就会占用非常多的内存,而且无法支持分布式集群环境。
- 如果Session数据保存到Redis等状态服务器中,它可以支持分布式集群环境,但是每遇到一次客户端请求都要向状态服务器获取一次Session数据!
在现代的项目开发中,我们倾向于采用JWT代替Session实现登录。
JWT全称是JSON Web Token,从名字中可以看出,JWT是使用JSON格式来保存令牌信息的。
JWT机制不是把用户的登录信息保存在服务器端,而是把登录信息(令牌)保存在客户端。
为了防止客户端的数据造假,保存在客户端的令牌经过了签名处理。
而签名的密钥只有服务器端才知道,每次服务器端收到客户端提交的令牌的时候都要检查一下签名。
如果发现数据被篡改,则拒绝接收客户端提交的令牌。
JWT的结构如图:
![](https://ichiblog.cn/wp-content/uploads/2024/09/图片-33-1024x546.png)
JWT的头部(HEADER)中保存的是加密算法的说明,负载(PAYLOAD)中保存的是用户的ID、用户名、角色等信息,签名(SIGNATURE)是根据头部和负载算出来的值。
HEADER:所使用的签名算法、令牌的类型
PAYLOAD:用户信息,但不放敏感信息,如密码
SIGNATURE:HMACSHA256(base64UrlEncode(header) + “.” + base64UrlEncode(payload), secret);
JWT实现登录的流程如下:
- 客户端向服务器端发送用户名、密码等请求登录。
- 服务器端校验用户名、密码,如果校验成功,则从数据库中取出这个用户的ID、角色等用户相关信息。
- 服务器端采用只有服务器端才知道的密钥对用户信息的JSON字符串进行签名,形成签名数据。
- 服务器端把用户信息的JSON字符串和签名拼接到一起形成JWT,然后发送给客户端。
- 客户端保存服务器端返回的JWT,并且在客户端每次向服务器端发送请求的时候都带上这个JWT。
- 每次服务器端收到浏览器请求中携带的JWT后,服务器端用密钥对JWT的签名进行校验,如果校验成功,服务器端则从JWT中的JSON字符串中读取用户的信息。
这样服务器端就知道这个请求对应的用户了,也就实现了登录的功能。
由此可以看出,在JWT机制下,登录用户的信息保存在客户端,服务器端不需要保存数据,这样我们的程序就天然地适合分布式的集群环境,而且服务器端从客户端请求中就可以获取当前登录用户的信息,不需要再去状态服务器中获取,因此程序的运行效率更高。
虽然用户信息保存在客户端,但是由于有签名的存在,客户端无法篡改这些用户信息,因此可以保证客户端提交的JWT的可信度。
4. JWT的基本使用
下面通过代码演示JWT的使用,我们先创建一个程序用来创建JWT,再创建一个程序读取JWT。
.NET 中进行了JWT读写的NuGet包是System.IdentityModel.Tokens.Jwt,因此我们要在这两个程序中都安装了这个NuGet包。
首先,编写生成JWT的程序,如下面代码所示:
public string Init()
{
List<Claim> claims =
[
new Claim(ClaimTypes.NameIdentifier,"6"),
new Claim(ClaimTypes.Name,"ICHI"),
new Claim(ClaimTypes.Role,"USER"),
new Claim(ClaimTypes.Role,"ADMIN"),
new Claim("PassPort","E0000982"),
];
string Key = "aldhawljdaslhkdaslidhlk";
DateTime expires = DateTime.Now.AddDays(1);
byte[] secBytes = Encoding.UTF8.GetBytes(Key);
SymmetricSecurityKey secKey = new(secBytes);
SigningCredentials credentials = new(secKey,SecurityAlgorithms.HmacSha256Signature);
var tokenDescriptor = new JwtSecurityToken(claims:claims,expires:expires,signingCredentials:credentials);
return new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);
}
一个用户的信息可能会包含多项内容,比如身份证号、生日、邮箱、地址等,在.NET中Claim就代表一条用户信息。
Claim有两个主要的属性:Type和Value,它们都是string类型的,Type代表用户信息的类型,Value代表用户信息的值。
由于Type是string类型的,因此可以取任何值,但尽量使用ClaimTypes类中的预置值。
我们使用这些ClaimTypes类中的预置值而非自定义值的好处是可以更方便地与其他系统对接。
运行上面的程序,我们就能得到如图所示的JWT:
![](https://ichiblog.cn/wp-content/uploads/2024/09/图片-34-1024x161.png)
请仔细观察得出的JWT,我们会发现JWT被句点分隔成了3部分,分别是头部、负载和签名。
JWT看起来很乱,好像是加密过的,其实它们都是明文存储的,只不过进行了简单的编码而已。
JWT中使用Base64URL算法对字符串进行编码,这个算法跟Base64算法基本相同。
考虑到JWT可能会被放到URL中,而Base64有3个特殊字符+、/和=,它们在URL里面有特殊含义,因此我们需要从Base64删除=,并且把+替换成-、把/替换成_。
我们把JWT字符串的头部和负载解码为可读的字符串,如以下代码所示:
public string Decode(string jwt)
{
string[] segments = jwt.Split('.');
string head = JWTDecode(segments[0]);
string payload = JWTDecode(segments[1]);
_log.LogInformation("----------HEAD-----------");
_log.LogInformation(head);
_log.LogInformation("----------PAYLOAD-------------");
_log.LogInformation(payload);
return head;
}
private string JWTDecode(string jwt)
{
jwt = jwt.Replace('-', '+').Replace('_', '/');
switch (jwt.Length % 4)
{
case 2:
jwt += "==";
break;
case 3:
jwt += "=";
break;
}
var bytes = Convert.FromBase64String(jwt);
return Encoding.UTF8.GetString(bytes);
}
这段对JWT进行解码的代码中没有用到任何密钥,只是简单地解码,因为JWT的头部和负载部分都没有加密,实质上都是以明文的形式保存的。
运行上面的程序,得到的运行结果如下:
![](https://ichiblog.cn/wp-content/uploads/2024/09/图片-35.png)
从程序运行结果可以看出,JWT的头部中本质上以明文的形式记录了JWT的签名使用的哈希算法,负载中本质上也以明文的形式记录了我们设置的多条Claim信息。
由于JWT会被发送到客户端,而负载中的内容是以明文形式保存的,因此一定不要把不能被客户端知道的信息放到负载中。
服务器端可以从客户端提交的JWT中读取出用户ID、用户名、角色等信息,这样服务器端就可以知道这个客户端对应的登录用户信息了。
但是,我们知道,JWT的编码和解码规则都是公开的,而且负载部分的Claim信息也是明文的。
因此恶意攻击者可以对负载部分中的用户ID等信息进行修改,从而冒充其他用户的身份来访问服务器上的资源。
所以,服务器端需要对签名部分进行校验,从而检查JWT是否被篡改了。
我们可以调用JwtSecurityTokenHandler类对JWT进行解码,因为它会在对JWT解码前对签名进行校验,如下面代码所示:
public List<string> JWTHandler(string jwt)
{
List<string> result = [];
//演示Key
string secKey = "123456qwer";
JwtSecurityTokenHandler handler = new();
TokenValidationParameters validationParameters = new();
SymmetricSecurityKey securityKey = new(Encoding.UTF8.GetBytes(secKey));
validationParameters.IssuerSigningKey = securityKey;
validationParameters.ValidateAudience = false;
//调用ValidateToken方法对JWT进行解密
ClaimsPrincipal claimsPrincipal = handler.ValidateToken(jwt, validationParameters, out SecurityToken secToken);
foreach(var token in claimsPrincipal.Claims)
{
result.Add($"{token.Type}={token.Value}");
}
return result;
}
如果我们输入的是服务器端返回的JWT,上面的代码能够正常运行。
但是,如果我们自己编写程序,随便使用一个密钥来生成一个用户ID等经过篡改后的JWT。
然后用这个自己生成的JWT的负载部分替换服务器端返回的JWT,就可以得到一个用户ID、用户名等被篡改的JWT。
我们使用这个被篡改的JWT去运行只解码头部和负载的代码,这个JWT是可以被正常解码的。
但是,当我们使用这个被篡改的JWT去运行解码完整的JWT的代码,程序运行时就会抛出内容为”Signature validation failed.”的异常。
![](https://ichiblog.cn/wp-content/uploads/2024/09/图片-36-1024x92.png)
这证明ValidateToken方法确实会对JWT中的签名进行校验,从而保证JWT不被客户端篡改。
5. ASP.NET Core对于JWT的封装RBAC
ASP.NET 封装了对于JWT的操作,让我们在程序中使用JWT进行鉴权和授权更简单。
下面我们就来看看在ASP.NET Core中如何更简单地使用JWT。
第1步:
我们先在配置系统中配置一个名字为JWT的节点,并在节点下创建SigningKey、ExpireSeconds两个配置项,分别代码JWT的密钥和过期时间。
我们再创建一个对应JWT节点的配置类JWTOptions,类中包含SigningKey、ExpireSeconds这两个属性。
第2步:
通过NuGet为项目安装Microsoft.AspNetCore.Authentication.JwtBearer包,这个包封装了简化ASP.NET Core中使用JWT的操作。
第3步:
编写代码对JWT进行配置,把下面代码的内容添加到Program.cs的builder.Build之前。
//对JWT进行配置
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
{
string? jwtOptions = builder.Configuration["JWTSettings:SecKey"];
byte[] keyBtyes = Encoding.UTF8.GetBytes(jwtOptions);
SymmetricSecurityKey seckey = new(keyBtyes);
options.TokenValidationParameters = new()
{
ValidateIssuer = false, ValidateAudience = false, ValidateLifetime = false,
ValidateIssuerSigningKey = true,IssuerSigningKey = seckey
};
});
第4步:
在Program.cs的app.UseAuthorization之前添加app.UseAuthentication。
第5步:
在IdentityController类中增加登录并且创建JWT的操作方法Login2,方法体的主干部分如以下代码所示
[HttpGet]
public async Task<IActionResult> Login2(string userName,string password)
{
//检测客户端请求的用户名、密码
var user = await _user.FindByNameAsync(userName);
var success = await _user.CheckPasswordAsync(user,password);
if (!success) return BadRequest("Failed");
//如果信息输入正确,服务器端就会把用户ID、用户名、角色作为Claim加入JWT
List < Claim > claims = [];
claims.Add(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
claims.Add(new Claim(ClaimTypes.Name, user.UserName));
var roles = await _user.GetRolesAsync(user);
foreach (var role in roles)
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
//JWTSettings: SecKey
string jwtToken = BuildToken(claims, "absdadasd123456!@#!@#");
return Ok(jwtToken);
}
private string BuildToken(IEnumerable<Claim> claim,string jwtOptions)
{
//Signature
//取出私钥并以utf8编码字节输出
var secretByte = Encoding.UTF8.GetBytes(jwtOptions);
//使用非对称算法对私钥进行加密
var signingKey = new SymmetricSecurityKey(secretByte);
//使用HmacSha256来验证加密后的私钥生成数字签名
var signingCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
//生成Token
var Token = new JwtSecurityToken(
issuer: string.Empty, //发布者
audience: string.Empty, //接收者
claims: claim, //存放的用户信息
notBefore: DateTime.UtcNow, //发布时间
expires: DateTime.UtcNow.AddDays(1), //有效期设置为1天
signingCredentials //数字签名
);
//生成字符串token
var TokenStr = new JwtSecurityTokenHandler().WriteToken(Token);
return TokenStr;
}
第6步:
在需要登录才能访问的控制器类上添加[Authorize]这个ASP.NET Core内置的Attribute,如以下代码:
[Route("Security/[controller]/[action]")]
[ApiController]
[Authorize]
public class BaseController : ControllerBase
{
}
添加的[Authorize]表示这个控制器类下所有的操作方法都需要登录后才能访问。
完成代码后,运行项目,然后访问控制器,并且在请求报文中填上正确的用户名和密码,接着我们向服务器端发送请求,就会得到以下运行结果。
![](https://ichiblog.cn/wp-content/uploads/2024/09/图片-37-1024x599.png)
响应报文中的内容就是我们登录获得的JWT,请把这个JWT复制出来备用。
接下来,我们尝试错误的账户或密码,就会得到下面的请求结果
![](https://ichiblog.cn/wp-content/uploads/2024/09/图片-38-1024x312.png)
从运行结果可以看出,服务器端返回的HTTP状态码是401,也就是”没有授权”。
因为我们在BaseController标注了[Authorize],而我们请求的时候没有使用正确密码,所以ASP.NET Core就直接拒绝了这样非法的请求。
ASP.NET Core要求(HTTP规范)JWT放到名字为Authorization的HTTP请求报文头中,报文头的值为”Bearer JWT”。
默认情况下,我们无法在Swagger中添加请求报文头,因此我们需要借助第三方工具来发送带自定义报文头的HTTP请求。
以Postman为例,我们在请求的【Headers】中手工添加名字为Authorization的报文头,在Postman中发送的请求和服务器的响应如下图:
![](https://ichiblog.cn/wp-content/uploads/2024/09/图片-39-1024x523.png)
需要注意的是,Authorization的值中的”Bearer”和JWT之间一定要通过空格分隔。
从服务器的响应可以看出,服务器返回的HTTP状态码为200,而且响应报文体中也输出了JWT代表的用户ID、用户名、角色名等信息。
对于客户端获得的JWT,在前端项目中,我们可以把令牌保存到Cookie、LocalStorage等位置,从而在后续请求中重复使用。
而对于移动App、PC客户端,我们可以把令牌保存到配置文件中或者本地文件数据库中。
当执行【退出登录】操作的时候,我们只要在客户端本地把JWT删除即可
6. [Authorize]的注意事项
ASP,NET Core 中身份验证和授权验证的功能由Authentication、Authorization中间件提供。
我们在Program.cs中编写的app.UseAuthentication和app.UseAuthorization就用于添加相应中间件到管道中。
[Authorize]这个Attribute既可以被添加到控制器类上,也可以被添加到操作方法上。
我们可以在控制器类上标注[Authorize],那么这个控制器类中的所有操作方法都会被进行身份验证和授权验证;
对于标准了[Authorize]的控制器类,如果其中某个操作方法不想被验证,我们可以在这个操作方法上添加[AllowAnonymous]。
如果没有在控制器类上标注[Authorize],那么这个控制器类的所有操作方法都允许被自由地访问;
7. 让Swagger中调试带验证的请求更简单
Swagger中默认没有提供设置自定义HTTP请求报文头的方式,因此对于需要传递Authorization报文头的接口,调试起来很麻烦。
我们可以通过对OpenAPI进行配置,从而让Swagger中可以发送Authorization报文头。
我们需要修改Program.cs的AddSwaggerGen方法调用,修改后的内容如以下代码:
builder.Services.AddSwaggerGen(options =>
{
OpenApiSecurityScheme scheme = new()
{
Description = "Authorization Header. \r\nExample:'Bearer xxxxxxxxxxxxxxxxxxxx'",
Reference = new() { Type = ReferenceType.SecurityScheme,Id = "Authorization"},
Scheme = "Oauth2",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey
};
options.AddSecurityDefinition("Authorization", scheme);
OpenApiSecurityRequirement req = new()
{
[scheme] = []
};
options.AddSecurityRequirement(req);
});
添加完以上代码后,重启并运行项目,会发现在Swagger界面的右上角增加了一个【Authorize】按钮,如下图所示:
![](https://ichiblog.cn/wp-content/uploads/2024/09/图片-40-1024x161.png)
单击【Authorize】按钮后,界面中就会弹出如下图所示的对话框:
![](https://ichiblog.cn/wp-content/uploads/2024/09/图片-41.png)
在上面对话框的文本框中,我们输入”Bearer JWT”,然后单击【Authorize】按钮,这样在这个界面之后的请求中,浏览器都会自动在请求头中加入Authorization报文头。
当然,如果界面关闭或重启了,我们就必须重新输入Authorization报文头的值。
设置完授权对话框后,再在Swagger界面发起请求就可以看到服务器端的身份验证成功了。
8. JWT和Session的比较
指标 | JWT | Session |
适应场景 | 前后端分离项目,为多设备提供API的WebAPI项目 | 传统的基于视图的MVC项目 |
网络流量 | JWT长度一般比SessionId长,因此请求的流量消耗比Session的大 | 请求的流量消耗比较小 |
数据安全 | 数据本质上以明文的形式保存在令牌的负载中,不能在负载中保存步希望客户端看到的数据 | 数据保存在服务器端,因此Session中的数据不会被客户端访问到 |
服务器端存储资源占用 | 令牌数据存放到客户端,因此服务器端存储资源占用小 | Session数据保存在服务器端,因此服务器端存储资源占用比较大 |
有效期续签 | 需要重新生成包含新有效期的JWT | 只要在服务器端对Session数据续期即可 |