如何用.NET Core Identity实现定制化的用户身份验证系统
目录
初识标识框架(Identity)
重置密码操作
JWT基本使用
Swagger添加报文头
初识标识框架(Identity)
.net core Identity是一个完整的身份验证和授权框架,它帮助开发人员处理用户的登录、注册、角色管理、权限控制以及其他与用户身份相关的操作,标识框架采用基于角色访问控制(Role-Based Access Control,简称RBAC)策略,内置了对用户、角色等表的管理以及相关的接口,支持外部登录、2FA等。标识框架使用EF Core对数据库进行操作,因此标识框架支持几乎所有数据库。
在日常开发中要区分以下以下两种控制策略:
1)Authentication:对访问者的用户身份进行验证,“用户是否登录成功”。
2)Authorization:验证访问者的用户身份是否有对资源访问的访问权限,“用户是否有权限访问这个地址”。
接下来在包管理器下载这个标识框架即可,如果.net core版本过低的话,该包也降低版本即可:
然后这里我们创建一个用户和角色的实体类,分别去继承IdentityUser和IdentityRole,可以增加自定义属性,如下所示:
然后创建继承自IdentityDbContext的类,使用泛型参数来指定自定义的用户类和角色类:
namespace webapi_study
{
public class MyDbContext: IdentityDbContext<MyUser, MyRole,long>
{
public MyDbContext(DbContextOptions<MyDbContext> options) : base(options)
{
}
}
}
然后我们把我们这个dbcontext连接到数据库之后,配置用户和角色相关的设置,然后迁移数据库里即可,如下所示:
builder.Services.AddDbContext<MyDbContext>(options =>
{
string connStr = builder.Configuration.GetSection("ConStr").Value;
options.UseMySQL(connStr);
});
builder.Services.AddDataProtection(); // 添加数据保护服务
builder.Services.AddIdentityCore<MyUser>(options =>
{
options.Password.RequireDigit = true; // 密码至少包含一个数字
options.Password.RequiredLength = 6; // 密码至少包含6个字符
options.Password.RequireNonAlphanumeric = false; // 密码可以不包含非字母数字字符
options.Password.RequireUppercase = false; // 密码可以不包含大写字母
options.Password.RequireLowercase = false; // 密码可以不包含小写字母
options.Tokens.PasswordResetTokenProvider = TokenOptions.DefaultEmailProvider; // 密码重置令牌提供程序
options.Tokens.EmailConfirmationTokenProvider = TokenOptions.DefaultProvider; // 邮箱确认令牌提供程序
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); // 默认锁定时间
options.Lockout.MaxFailedAccessAttempts = 5; // 最大失败尝试次数
options.Lockout.AllowedForNewUsers = true; // 新用户允许锁定
});
IdentityBuilder identityBuilder = new IdentityBuilder(typeof(MyUser), typeof(MyRole), builder.Services);
identityBuilder.AddEntityFrameworkStores<MyDbContext>().AddDefaultTokenProviders()
.AddUserManager<UserManager<MyUser>>().AddRoleManager<RoleManager<MyRole>>(); // 添加EF Core存储和默认令牌提供程序
执行Add-migration init和update-database命令将继承的实体类迁移到数据库之后,可以看到数据库已经生成了一堆用户和角色权限设置的表了:
接下来初始化系统中的角色和用户权限配置:检查角色是否存在、检查并创建用户、将用户添加到角色、返回成功,如下所示:
[HttpPost]
public async Task<ActionResult<string>> Test()
{
if (await roleManager.RoleExistsAsync("admin") == false)
{
var result = await roleManager.CreateAsync(new MyRole { Name = "admin" });
if (!result.Succeeded) return BadRequest("roleManager CreateAsync Failed");
}
MyUser user = await userManager.FindByNameAsync("zhangsan");
if (user == null) {
user = new MyUser { UserName = "zhangsan" }; // 确保邮箱不为空
var result = await userManager.CreateAsync(user, "123456");
if (!result.Succeeded) {
var errors = string.Join(", ", result.Errors.Select(e => e.Description));
return BadRequest($"userManager CreateAsync Failed: {errors}");
}
}
if (!await userManager.IsInRoleAsync(user, "admin"))
{
var result = await userManager.AddToRoleAsync(user, "admin");
if (!result.Succeeded) return BadRequest("userManager AddToRoleAsync Failed");
}
return "success";
}
接下来我们通过调用下面的接口来校验,输入的用户和密码是否正确,如下所示,如果是在开发环境下就正常提示用户不存在即可,当尝试输入的用户密码过多,超出我们的限制之后,账户就会被锁定:
[HttpPost]
public async Task<ActionResult> CheckPwd(CheckPwdRequest request)
{
string userName = request.UserName;
string password = request.Password;
var user = await userManager.FindByNameAsync(userName);
if (user == null)
{
if (env.IsDevelopment())
{
return BadRequest("用户不存在");
}
else
{
return BadRequest();
}
};
if (await userManager.IsLockedOutAsync(user))
{
return BadRequest("用户被锁定,锁定结束事件"+user.LockoutEnd);
}
if (await userManager.CheckPasswordAsync(user, password))
{
await userManager.ResetAccessFailedCountAsync(user); // 重置登录失败次数
return Ok("登录成功");
}
else
{
await userManager.AccessFailedAsync(user); // 登录失败次数+1
return BadRequest("用户名或密码错误");
}
}
重置密码操作
在标识框架这如果想进行重置密码的操作,需要参考以下流程:
1)生成重置Token
2)Token发给客户(邮件、短信等),形式:链接、验证码等
3)根据Token完成密码的重置
如下我们模拟手机发送验证码的方式,获取数据库当中的重置密码的token:
[HttpPost]
public async Task<ActionResult> SendResetPasswordToken(string userName)
{
var user = await userManager.FindByNameAsync(userName);
if (user == null) return BadRequest("用户不存在");
string token = await userManager.GeneratePasswordResetTokenAsync(user);
return Ok(token);
}
如下我们输入我们的用户名,然后点击接口触发重置密码指令,返回我们重置的令牌:
接下来我们编写重置密码的接口:
[HttpPost]
public async Task<ActionResult> ResetPassword(string userName, string token, string newPwd)
{
var user = await userManager.FindByNameAsync(userName);
if (user == null) return BadRequest("用户不存在");
IdentityResult result = await userManager.ResetPasswordAsync(user, token, newPwd);
if (result.Succeeded) {
await userManager.ResetAccessFailedCountAsync(user);
return Ok("重置密码成功");
} else {
await userManager.AccessFailedAsync(user);
return Ok("重置密码失败");
}
}
输入账号之后,点击获取重置token的接口,输入token,然后再输入重置密码即可进行密码重置:
JWT基本使用
JWT:是一种开放标准用于在网络应用环境中传递声明,它是一种轻量级的、安全的认证和授权机制,广泛用于现代web应用程序中,JWT把登录信息(也称作令牌)保存再客户端,为了防止客户端的数据造假,保存再客户端的令牌经过了签名处理,而签名的密钥只有服务器才知道,每次服务器端收到客户端提交过来的令牌的时候都要检查一下签名,以下是JWT的使用流程:
1)用户通过用户名和密码登录系统,服务器验证身份后生成一个JWT
2)服务器将JWT返回给用户
3)用户在后续的请求中,将该JWT放入HTTP请求的Authorization头中(通常使用Bearer作为前缀)
4)服务器验证JWT的有效性(比如检查签名和过期时间),如果有效则允许访问受保护的资源
接下来我们NuGet安装如下操作JWT的包:
接下来我们创建一个JWT的类,并且把类中相关的属性设置在配置文件当中:
接下来我们在入口文件当中进行如下配置,读取JWT文件并设置对应的JWT配置:
builder.Services.Configure<JWTSettings>(builder.Configuration.GetSection("JWT")); // 配置JWT设置
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
{
var jwtSettings = builder.Configuration.GetSection("JWT").Get<JWTSettings>();
byte[] keyBytes = Encoding.UTF8.GetBytes(jwtSettings.SecKey);
var secKey = new SymmetricSecurityKey(keyBytes);
options.TokenValidationParameters = new()
{
ValidateIssuer = false, // 验证发行者
ValidateAudience = false, // 验证受众
ValidateLifetime = true, // 验证过期时间
ValidateIssuerSigningKey = true, // 验证签名密钥
IssuerSigningKey = secKey // 签名密钥
};
});
然后在app.UseAuthorization();这行代码之前添加app.UseAuthentication(); 开启身份验证:
app.UseAuthentication(); // 开启身份验证
app.UseAuthorization();
接下来我们就在控制器当中执行开始登录的代码,如下所示:
[HttpPost]
public ActionResult<string> Login(string userName, string password)
{
if (userName == "zhangsan" && password == "123456")
{
List<Claim> claims = new List<Claim>();
claims.Add(new Claim(ClaimTypes.NameIdentifier, "1"));
claims.Add(new Claim(ClaimTypes.Name, userName));
string key = jwtSettings.Value.SecKey;
DateTime expiore = DateTime.Now.AddSeconds(jwtSettings.Value.ExpireSeconds);
byte[] secBytes = Encoding.UTF8.GetBytes(key);
var secKey = new SymmetricSecurityKey(secBytes);
var creds = new SigningCredentials(secKey, SecurityAlgorithms.HmacSha256Signature);
var token = new JwtSecurityToken(claims: claims, expires: expiore, signingCredentials: creds);
string jwt = new JwtSecurityTokenHandler().WriteToken(token);
return jwt;
}
else
{
return BadRequest("用户名或密码错误");
}
}
执行登录之后,直接返回一个jwt加密后的字符串,如下所示:
接下来就需要在服务器解析客户端提交过来的这个jwt字符串,这里我们可以在需要登录才能访问的控制器类或者Action方法上添加:[Authorize],如下所示:
然后测试登录和访问的时候,需要通过如下格式自定义报文头:Authorization的值为"Bearer JWTToken", Authorization的值中的"Bearer"和JWT令牌之间一定要通过空格分隔。前后不能多出来额外的空格、换行等。
注意:制器类上标法[Authorize],则所有操作方法都会被进行身份验证和授权验证;对于标注了[Authorize]控制器中,如果其中某个操作方法不想被验证,可以在操作方法上添如[AllowAnonymous]。如果没有在控制器类上标注[Authorize],那么这个控制器中的所有操作方法都允许被自由地访问;对于没有标注[Authorize]的控制器中,如果其中某个操作方法需要被验证,我们也可以在操作方法上添加[Authorize]。
ASP.NET Core会按照HTTP协议的规范,从Authorization取出来令牌,并且进行校验、解析,然后把解析结果填充到User属性中,这一切都是ASP.NETCore完成的,不需要开发人员自己编写代码。但是一旦出现401,没有详细的报错信息,很难排查,这是初学者遇到的难题。
解决JWT无法提前撤回的问题:由于JWT本质上是一种自包含的认证机制,它用于在客户端和服务器之间安全地传递信息,由于JWT是由服务器签发并且可以在客户端进行存储,因此它本身没有撤回(即失效)机制,这意味着一旦JWT被签发,它通常会一直有效直到过期时间为止,为了解决这个情况可以采用以下思路:
在用户表中增加一个整数类型的列JWTVersion,代表最后一次发放出去的令牌的版本号,每次登录、发放令牌的时候都让JWTVersion的值自增,同时将JWTVersion的值也放到JWT令牌的负载中;
1)当执行禁用用户、撤回用户的令牌等操作的时候,把这个用户对应的JWTVersion列的值自增;
2)当服务器端收到客户端提交的WT令后,先把JWT令牌中的JWTVersion值和数据库中JWTVersion的值做一下比较,如果JWT令牌中JWTVersion的值小于数据库中JWTVersion的值,就说明这个JWT令牌过期了。
实现步骤如下所示:
1)为用户实体User类增加一个long类型的属性JWTVersion.
2)修改登录并发放令牌的代码,把用户的JWTVersion属性的值自增,并且把JWTVersion的值写入JWT令牌。
3)编写一个操作筛选器,统一实现对所有的控制器的操作方法中JWT令牌的检查操作。把JWTValidationFilter注册到Program.cs中MVC的全局筛选器中
Swagger添加报文头
接下来我们可以给Swagger添加对应的身份校验的设置,首先我们先在入口文件中对Swagger进行相应的配置,如下所示:
// 设置Swagger添加JWT认证信息报文头
builder.Services.AddSwaggerGen(options =>
{
var scheme = new OpenApiSecurityScheme()
{
Description = "JWT认证信息报文头",
Reference = new OpenApiReference()
{
Id = "Authorization",
Type = ReferenceType.SecurityScheme
},
Scheme = "oauth2",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey,
};
options.AddSecurityDefinition("Authorization", scheme);
var requirement = new OpenApiSecurityRequirement();
requirement[scheme] = new List<string>();
options.AddSecurityRequirement(requirement);
});
配置完成之后,运行项目然后打开的Swagger文档中就会有Authorize进行输入JWT的按钮:
然后我们运行一个需要身份校验的接口,输入对应的JWT校验的字符串,可以看到我们请求的接口当中也会带上我们的身份校验的Authorization:
FluentValidation使用
.net core中内置了对数据校验支持,在System.ComponentModel.DataAnnotations这个命名空间下, 比如[Required]、[EmailAddress] 、[RegularExpression],CustomValidationAttribute、IValidatableObject。不过其内置的校验机制,校验规则都是和模型类耦合在一起,违反“单一职责原则";很多常用的校验都需要编写自定义校验规则,而且写起来麻烦。FluentValidation用类似于EF Core中Fluent API的方式进行校验规则的配置,也就是我们可以把对模型类的校验放到单独的校验类中进行。在NuGet中安装如下包:
然后我们在入口文件当中开启自动校验:
builder.Services.AddFluentValidation(options => // 添加Fluent验证自动验证
{
options.RegisterValidatorsFromAssembly(Assembly.GetEntryAssembly());
});
然后我们声明一个实体类,然后可以在声明一个对该实体类字段进行校验的请求类:
请求接口的时候可以看到外面输入的参数会自动进行校验,类似如下所示,报错的提示可自定义: