asp.net core本身就自带了认证和授权框架,其中包含了Identity框架,可以自动生成相关的数据库表结构,调用UserManager、RoleManager、SiginManager这些服务,可以自动生成SQL语句访问用户、角色等功能。但是不同的项目,业务功能不一样,Identity自动生成的表结构并不符合所有项目的业务需求,所以我不太看好使用Identity框架来搭建项目,这里总结一下使用asp.net core里面的自定义认证和授权功能来搭建项目的方法。
1、安装EF Core,配置数据库连接
EFCore访问MySQL的nuget包Pomelo.EntityFrameworkCore.MySql,在appsettings.json文件里面配置MySQL的连接字符串
"ConnectionStrings": {
"Mysql": "server=localhost;port=3306;uid=root;pwd=123456;database=authorization_demo"
}
2、创建和配置实体类、生成表结构
这里用到了用于认证和授权的5个基础的实体类,分别代表
[Table("t_user")]
public class User
{
[Key]
public long Id {
get; set; }
public string UserName {
get; set; }
public string Password {
get; set; }
public string Email {
get; set; }
public List<Role> Roles {
get; set; }
}
用户
[Table("t_role")]
public class Role
{
[Key]
public long Id {
get; set; }
public string Name {
get; set; }
public List<User> Users {
get; set; }
public List<Authority> Authoritys {
get; set; }
}
角色
[Table("t_authority")]
public class Authority
{
[Key]
public long Id {
get; set; }
//权限码
public string code {
get; set; }
//描述
public string Description {
get; set; }
public List<Role> Roles {
get; set; }
}
权限
[Table("t_user_role")]
public class UserRole
{
public long UserId {
get; set; }
public long RoleId {
get; set; }
}
用户与角色的中间实体类
[Table("t_role_authority")]
public class RoleAuthority
{
public long RoleId {
get; set; }
public long AuthorityId {
get; set; }
}
角色与权限的中间实体类
然后,创建数据库上下文类,配置用户与角色的多对多关系,角色与权限的多对多关系
public class ApplicationContext : DbContext
{
public ApplicationContext(DbContextOptions options) : base(options)
{
}
public DbSet<User> Users {
get; set; }
public DbSet<Role> Roles {
get; set; }
public DbSet<Authority> Authoritys {
get; set; }
public DbSet<UserRole> UserRoles {
get; set; }
public DbSet<RoleAuthority> RoleAuthoritys {
get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>().HasMany(user => user.Roles).WithMany(role => role.Users).UsingEntity<UserRole>();
modelBuilder.Entity<Role>().HasMany(role => role.Authoritys).WithMany(authority => authority.Roles).UsingEntity<RoleAuthority>();
}
}
在Program.cs文件中注册数据库上下文服务
builder.Services.AddDbContext<ApplicationContext>(options =>
{
string connectionString = builder.Configuration.GetConnectionString("Mysql");
var serverVersion = ServerVersion.AutoDetect(connectionString);
options.UseMySql(connectionString, serverVersion);
});
安装nuget包Microsoft.EntityFrameworkCore.Tools,打开nuget控制台,使用efcore的迁移命令add-migration [迁移名称]和update-database生成数据库表。
3、注册加密服务
Program.cs中的配置如下,使用PasswordHasher类来做密码加密和校验。
builder.Services.AddScoped<IPasswordHasher<User>, PasswordHasher<User>>();
4、定义接口响应格式
public record Result<T>(ResultCode Code,string Message,T Data)
{
public static Result<T> Success(T data=default)
{
return new Result<T>(ResultCode.SUCCESS, "成功", data);
}
public static Result<T> Fail(string message=null)
{
return new Result<T>(ResultCode.FAILURE, message, default);
}
}
public enum ResultCode {
SUCCESS=1, FAILURE }
5、创建新增用户的接口
[HttpPost]
public async Task<Result<object>> save(UserParam userParam)
{
var (userName,password,email) = userParam;
var exist=await context.Users.AnyAsync(user => user.UserName == userName);
if (exist)
{
return Result<object>.Fail("用户名已存在");
}
var user = new User {
UserName = userName, Email = email };
var encriptPassword=passwordHasher.HashPassword(user,password);
user.Password = encriptPassword;
context.Users.Add(user);
await context.SaveChangesAsync();
return Result<object>.Success();
}
这里面使用了HashPassword方法对用户密码进行加密,然后保存到数据库。
6、封装JWT的工具类
安装nuget包System.IdentityModel.Tokens.Jwt,定义一个工具类用于生成token和解析token。
public class JwtUtil
{
private const string KEY = "aaaaabbbbbcccccdddddeeeeefffffggggg";
//生成token
public static string Create(string userId,IEnumerable<long> roleIds)
{
var claims = new List<Claim>();
claims.Add(new Claim(ClaimTypes.NameIdentifier, userId));
foreach (var roleId in roleIds)
{
claims.Add(new Claim(ClaimTypes.Role, roleId.ToString()));
}
var expires=DateTime.Now.AddMinutes(30);
var keyBytes =Encoding.UTF8.GetBytes(KEY);
var securityKey = new SymmetricSecurityKey(keyBytes);
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature);
var tokenDescriptor=new JwtSecurityToken(claims:claims,expires:expires,signingCredentials:credentials);
var token = new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);
return token;
}
//验证token
public static bool ValidateToken(string token,out IEnumerable<Claim> claims)
{
claims = null;
var tokenHandler =new JwtSecurityTokenHandler();
var valParam=new TokenValidationParameters();
var securityKey=new SymmetricSecurityKey(Encoding.UTF8.GetBytes(KEY));
valParam.IssuerSigningKey = securityKey;
valParam.ValidateIssuer = false;
valParam.ValidateAudience = false;
ClaimsPrincipal claimsPrincipal;
try
{
claimsPrincipal = tokenHandler.ValidateToken(token, valParam, out SecurityToken validatedToken);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
return false;
}
claims=claimsPrincipal.Claims;
return true;
}
}
这里面我把用户id和角色id保存到了token里面,之所以不直接保存权限码,是因为一个项目可能有几百个接口,这是正常的,如果每个接口的权限码都不一样,那么一个token里面就要保存几百个权限码,会导致token字符串过长,每次请求都占用很多网络带宽。所以,我的想法就是在项目启动的时候,把数据库中所有角色的id和关联的权限码都查出来,放到缓存里面,然后在用户授权的时候,根据token中拿到的角色id去缓存中查询关联的权限码,再跟接口的权限码做比对,就能判断用户有没有权限访问。
一个项目一般顶多也就几十种角色,几百个权限码,放入缓存不会太占内存空间。
7、创建登录接口
[HttpPost("login")]
public async Task<Result<string>> login(string username, string password)
{
var user = await context.Users.Include(user => user.Roles).Where(user => user.UserName == username).FirstOrDefaultAsync();
if (user==null)
{
return Result<string>.Fail("用户名不存在");
}
var result=passwordHasher.VerifyHashedPassword(user, user.Password,password);
if (result==PasswordVerificationResult.Failed)
{
return Result<string>.Fail("密码错误");
}
var roleIds= user.Roles.Select(role => role.Id).ToArray();
var token = JwtUtil.Create(user.Id.ToString(), roleIds);
return Result<string>.Success(token);
}
这里调用VerifyHashedPassword方法做密码校验,执行result==PasswordVerificationResult.Failed判断密码是否正确,最后生成token发送给前端。
8、自定义认证逻辑
这里面需要定义一个类,继承IAuthenticationHandler接口,并实现AuthenticateAsync、ChallengeAsync、ForbidAsync、InitializeAsync方法
public class MyAuthenticationHandler : IAuthenticationHandler
{
public const string MY_SCHEMA_NAME = "myAuth";
private const string HEADER = "Authorization";
private HttpContext context;
private AuthenticationScheme scheme;
public Task<AuthenticateResult> AuthenticateAsync()
{
AuthenticateResult result;
var getHeaderSuccess =context.Request.Headers.TryGetValue(HEADER, out var value);
if (getHeaderSuccess)
{
string token=value.ToString();
if (!string.IsNullOrWhiteSpace(token))
{
var validateSuccess=JwtUtil.ValidateToken(token,out var claims);
if (validateSuccess)
{
ClaimsIdentity claimsIdentity = new(claims, MY_SCHEMA_NAME);
ClaimsPrincipal claimsPrincipal=new(claimsIdentity);
AuthenticationTicket ticket = new(claimsPrincipal, scheme.Name);
result = AuthenticateResult.Success(ticket);
}
else
{
result = AuthenticateResult.Fail("token解析失败");
}
}
else
{
result = AuthenticateResult.Fail("token为空");
}
}
else
{
result = AuthenticateResult.Fail("token请求头不存在");
}
return Task.FromResult(result);
}
/// <summary>
/// 未认证的处理方法
/// </summary>
/// <param name="properties"></param>
/// <returns></returns>
public Task ChallengeAsync(AuthenticationProperties? properties)
{
context.Response.StatusCode= (int)HttpStatusCode.Unauthorized;
return context.Response.WriteAsync("你还未登录");
}
/// <summary>
/// 未授权的处理方法
/// </summary>
/// <param name="properties"></param>
/// <returns></returns>
public Task ForbidAsync(AuthenticationProperties? properties)
{
context.Response.StatusCode = (int)HttpStatusCode.Forbidden;
return context.Response.WriteAsync("没有权限");
}
public Task InitializeAsync(AuthenticationScheme scheme, HttpContext context)
{
this.scheme = scheme;
this.context = context;
return Task.CompletedTask;
}
}
其中,AuthenticateAsync方法是用来实现认证逻辑的,从请求头中取出token,进行解析,调用AuthenticateResult.Success方法表示认证成功,并将token解析得到的数据保存在claims里面,通过ticket变量传给授权中间件;调用AuthenticateResult.Fail方法代表认证失败。ChallengeAsync方法是未认证的处理方法,ForbidAsync方法是未授权的处理方法。
然后,需要在Program.cs中注册这个类,作为认证的处理类
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = MyAuthenticationHandler.MY_SCHEMA_NAME;
options.DefaultChallengeScheme = MyAuthenticationHandler.MY_SCHEMA_NAME;
options.DefaultForbidScheme= MyAuthenticationHandler.MY_SCHEMA_NAME;
options.AddScheme<MyAuthenticationHandler>(MyAuthenticationHandler.MY_SCHEMA_NAME, MyAuthenticationHandler.MY_SCHEMA_NAME);
});
这里面配置了3个默认的Scheme,分别代表处理认证、认证失败和授权失败的处理类。
9、注册缓存服务
Program.cs中的配置如下
builder.Services.AddMemoryCache();
10、创建后台服务
定义一个类,继承BackgroundService类,并重写ExecuteAsync方法
public class MemoryBgService : BackgroundService
{
private readonly ApplicationContext context;
private readonly IMemoryCache cache;
public MemoryBgService(IServiceScopeFactory scopeFactory,IMemoryCache cache)
{
var serviceScop = scopeFactory.CreateScope();
var serviceProvider = serviceScop.ServiceProvider;
this.context=serviceProvider.GetRequiredService<ApplicationContext>();
this.cache = cache;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var roles=await context.Roles.Include(role => role.Authoritys)
.Select(role => new {
RoleId=role.Id,AuthorityCodes=role.Authoritys.Select(authority => authority.code).ToHashSet()})
.ToArrayAsync();
foreach (var role in roles)
{
cache.Set(role.RoleId,role.AuthorityCodes);
}
}
}
这里面需要注入缓存服务,并且会在项目启动的时候运行ExecuteAsync方法,首先会从数据库查询所有的角色和关联的权限码,然后保存到本地缓存里面,其中权限码保存在HashSet里面,方便进行查找。
然后,在Program.cs里面注册后台服务
builder.Services.AddHostedService<MemoryBgService>();
11、自定义授权Attribute
定义一个类,继承AuthorizeAttribute, IAuthorizationRequirement, IAuthorizationRequirementData,这些类和接口
public class AuthorityAttribute : AuthorizeAttribute, IAuthorizationRequirement, IAuthorizationRequirementData
{
public AuthorityAttribute(string code)
{
this.Code = code;
}
public string Code {
get; }
public IEnumerable<IAuthorizationRequirement> GetRequirements()
{
yield return this;
}
}
其中,Code属性代表权限码。
12、自定义授权逻辑
定义一个类,继承AuthorizationHandler类,并传入前面定义的AuthorityAttribute类型,重写HandleRequirementAsync方法
public class MyAuthorizationHandler : AuthorizationHandler<AuthorityAttribute>
{
private readonly IMemoryCache cache;
public MyAuthorizationHandler(IMemoryCache cache)
{
this.cache = cache;
}
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, AuthorityAttribute requirement)
{
var code=requirement.Code;
var roleIds=context.User.Claims.Where(claim => claim.Type==ClaimTypes.Role).Select(claim => claim.Value).ToArray();
foreach (var roleId in roleIds)
{
var codes=cache.Get<HashSet<string>>(long.Parse(roleId));
if (!codes.IsNullOrEmpty())
{
if (codes.Contains(code))
{
context.Succeed(requirement);
return Task.CompletedTask;
}
}
}
context.Fail();
return Task.CompletedTask;
}
}
通过requirement参数可以获取接口的权限码,通过context参数可以获取Claims中保存的角色id,然后根据角色id从缓存中获取权限码的HashSet集合,依次判断其中是否包含接口的权限码,调用context.Succeed表示授权成功,调用context.Fail()表示授权失败。
然后,在Program.cs里面注册授权处理类的服务
builder.Services.AddSingleton<IAuthorizationHandler,MyAuthorizationHandler>();
13、设置需要认证的接口
[Authorize]属性是用来设置需要认证的接口的,如果每个控制层的类都要加上这个属性,也太麻烦了,所以为了一劳永逸,我在Program.cs里面注册了如下服务
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build();
});
这表示所有接口都要认证才能访问,可以给不需要认证的接口加上[AllowAnonymous]属性,比如登录接口。
14、设置接口权限码
可以在接口上加上前面定义的AuthorityAttribute属性,设置权限码
[Authority("user:save")]
[HttpPost]
public async Task<Result<object>> save(UserParam userParam)
其中,user:save代表接口的权限码,只有拥有该权限的用户才能访问。