ASP.NET Core提供了内置的数据保护(加密)机制IDataProtectionProvider
,基于内置数据加密器,ASP.NET Core还提供了完整的认证和授权模块。这篇笔记我们对ASP.NET Core中如何实现数据保护、基于Cookie的认证授权,以及基于JWT认证授权进行介绍。
在具体学习认证授权之前,我们需要了解ASP.NET Core的数据保护API,因为认证授权中很多涉及加密的部分是基于数据保护API实现的。
ASP.NET Core中IDataProtectionProvider
是用于加解密数据的核心接口,它负责提供数据保护服务,ASP.NET Core默认的认证机制就采用这个加解密器来处理Cookie或JWT。我们也可以直接注入这个加解密器实现,用于我们自定义的加解密功能。下面例子代码中,我们在Program.cs
注册了DataProtection
服务。
builder.Services.AddDataProtection();
在控制器中,我们注入了IDataProtectionProvider
的实现,控制器方法里我们分别实现了加密和解密功能。
using DemoWebAPI.Model;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc;
namespace DemoWebAPI.Controllers;
[ApiController]
[Route("api/[controller]")]
public class EncryptionController : ControllerBase
{
private readonly IDataProtectionProvider _dataProtectionProvider;
public EncryptionController(IDataProtectionProvider dataProtectionProvider)
{
_dataProtectionProvider = dataProtectionProvider;
}
[HttpPost("[action]")]
public ActionResult<ApiResult> Encrypt([FromBody] string data)
{
var protector = _dataProtectionProvider.CreateProtector("MyPurpose");
return ApiResult.Success(protector.Protect(data));
}
[HttpPost("[action]")]
public ActionResult<ApiResult> Decrypt([FromBody] string data)
{
var protector = _dataProtectionProvider.CreateProtector("MyPurpose");
return ApiResult.Success(protector.Unprotect(data));
}
}
CreateProtector()
的参数是加解密器的“目标(purpose)”,其实就是一个字符串键值,相同的purpose
也可以多次调用CreateProtector()
,它仅会在对应加解密器所需的资源不存在时重新创建,但一组加解密操作必须采用相同purpose
,不同的purpose
是不能互相加解密操作的。
IDataProtectionProvider
的密钥是自动随机生成的,在Windows操作系统下,我们可以在文件系统的以下目录找到密钥信息,这是一个XML格式文件。
C:\Users\<用户名>\AppData\Local\ASP.NET\DataProtection-Keys
根据文件的内容,我们可以看到IDataProtectionProvider
默认采用了AES-256-CBC进行对称密钥加密和HMACSHA256进行数字签名。此外,这个文件里还包含了密钥的创建时间、生效时间和过期时间,ASP.NET Core默认的密钥过期时间是3个月,密钥过期后将自动轮换。
密钥的很多参数可以手动指定,下面例子我们将密钥保存在H:\Keys
文件夹,密钥的有效期是100年(可以看作关闭密钥轮换)。
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(@"H:\Keys"))
.SetDefaultKeyLifetime(TimeSpan.FromDays(365 * 100));
当然,从信息安全角度考虑,关闭密钥轮换可能不是个安全的做法,实际开发中,我们应该根据情况合理指定密钥轮换周期和密钥轮换时对于旧加密数据的处理逻辑。
对于分布式系统,如果每个服务端节点都使用自己的密钥可能造成密钥不一致而解密失败。如何保证每个分布式节点使用的密钥都相同呢?同步密钥文件是一个办法但有点复杂,实际上,ASP.NET Core支持将密钥存储在Redis上,这需要安装数据保护对应的Redis扩展。
dotnet add package Microsoft.AspNetCore.DataProtection.StackExchangeRedis --version 6.0.36
注册数据保护服务时,我们指定将密钥保存到Redis上,这样对于分布式系统就可以获取到同一个密钥了。
builder.Services.AddDataProtection()
.PersistKeysToStackExchangeRedis(ConnectionMultiplexer.Connect("localhost:6379"), "DataProtection-Keys");
ASP.NET Core中微软官方提供了两种认证授权机制,基于Cookie和基于JWT。两种方式其实是类似的,它们都遵循了无状态服务设计,认证和授权信息会以加密形式存储在Cookie或JWT中,请求需要认证和授权的接口时,服务端会检查并解密Cookie或JWT,判断客户端是否有权限访问资源。
很多同学可能不熟悉认证授权机制,我们这里再额外介绍一些核心概念。
认证:认证可以简单理解为就是登录,用户名密码登录就是一种认证机制,除此之外还有其它的认证机制,例如手机号登录,邮箱登录,OIDC登录等。认证阶段主要是为了确认用户的身份,例如用户名密码登录成功后,操作者就被以用户名标识并确认了,此外在有些系统中,“匿名”也是一种身份。
授权:授权是检查用户是否有权限访问某个资源,这通常发生在认证后根据用户的一系列角色和权限进行判断。一个授权过程涉及访问主体、访问客体和策略三个概念。比较常用的授权机制是RBAC角色访问控制机制,它基于用户-角色-权限
三个多对多的实体来表达。
ASP.NET Core将认证和授权抽象为了几个基本概念。
Identity:身份信息的抽象,包含用户的具体信息,例如用户名、邮箱、手机号等。其中的实现ClaimsIdentity
就由多个Claim
组成,每个Claim
就是一个信息字段。ASP.NET Core包含了若干内置Claim
,我们也可以自定义Claim
。
Role:角色,一般就用字符串表示。
Principal:Principal
包含Identity
和Role
,它是身份信息和角色组合而成表示完整用户身份的对象,表达用户的所有声明信息。
Policy:规则,即具体授权检查的逻辑。ASP.NET Core内置了基于角色的检查策略和基于Claim
的检查策略。
注:实际上,我们可能更熟悉的是基于Session的认证授权机制,但微软没有提供该实现。
使用基于Cookie认证和授权前我们需要先进行相关配置。
// 配置加密器
builder.Services.AddDataProtection();
// 配置认证和授权服务
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options =>
{
options.Cookie.Name = ".AspNetCore.Auth";
options.Cookie.HttpOnly = true;
options.Events.OnRedirectToLogin = context =>
{
var apiResult = ApiResult.Failure("403", "未登录");
return context.Response.WriteAsJsonAsync(apiResult);
};
options.Events.OnRedirectToAccessDenied = context =>
{
var apiResult = ApiResult.Failure("403", "无权限");
return context.Response.WriteAsJsonAsync(apiResult);
};
});
代码中,AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
指定使用Cookie认证方案,AddCookie()
方法中我们对Cookie认证的行为进行了配置,其中options.Cookie.Name
配置了Cookie名,options.Cookie.HttpOnly
指定使用HttpOnly的Cookie,options.Events.OnRedirectToLogin
和options.Events.OnRedirectToAccessDenied
指定了未登录和无权限两种情况时的默认行为,它的默认行为是跳转到登录和无权限页面,这适用于老式MVC工程但不适用于WebAPI工程,我们将其修改为输出JSON报错信息。更多配置如Cookie的同源策略,超时时间等可以参考官方文档。
除了注册认证和授权服务,我们还需要配置认证和授权中间件。
app.UseAuthentication();
app.UseAuthorization();
如上配置后,我们可以创建一个控制器来验证效果。下面控制器我们添加了一个[Authorize]
注解,该注解使得认证授权中间件生效,此时我们直接访问该控制器将返回未登录错误。
using DemoWebAPI.Model;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DemoWebAPI.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class DemoController : ControllerBase
{
[HttpGet("[action]")]
public ActionResult<ApiResult> DemoAction()
{
return ApiResult.Success();
}
}
上面代码会返回未登录错误,那么如何登录呢?我们可以编写一个登录和退出登录接口,下面是一个例子。
using System.Security.Claims;
using DemoWebAPI.Model;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
namespace DemoWebAPI.Controllers;
[ApiController]
[Route("api/[controller]")]
public class LoginController : ControllerBase
{
[HttpPost("[action]")]
public ActionResult<ApiResult> Login(LoginDto loginDto)
{
if (loginDto is { Username: "tom", Password: "123" })
{
// 登录成功
ClaimsIdentity claimsIdentity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme);
claimsIdentity.AddClaims(new[]
{
new Claim(ClaimTypes.Name, loginDto.Username),
});
ClaimsPrincipal claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, claimsPrincipal);
return ApiResult.Success();
}
else
{
// 登录失败
return ApiResult.Failure("403", "用户名或密码错误");
}
}
[HttpGet("[action]")]
public ActionResult<ApiResult> Logout()
{
HttpContext.SignOutAsync();
return ApiResult.Success();
}
}
按照之前介绍的认证授权核心概念,登录接口中当用户名密码验证通过时,我们组织了用户认证相关的信息并调用HttpContext.SignInAsync()
方法,我们可以在浏览器中测试该方法,如果一切正常,当登录成功时,我们可以看到响应设置了一个名字叫.AspNetCore.Auth
的Cookie,其中就包含了加密的认证信息。如果需要退出登录,调用HttpContext.SignOutAsync()
即可。
前面代码我们只用到了认证,认证通过即可调用接口,这里我们继续学习授权的使用。ASP.NET Core中授权可以基于Role
,也可以基于Claim
,又或者是两者的组合。我们先介绍基于角色的授权,下面例子我们首先注册授权服务,然后在前面例子代码的登录接口中添加了角色信息。
builder.Services.AddAuthorization();
claimsIdentity.AddClaims(new[]
{
new Claim(ClaimTypes.Name, loginDto.Username),
new Claim(ClaimTypes.Role, "Admin")
});
此时,DemoController
中我们添加基于角色的授权检查。
[Authorize(Roles = "Admin")]
[ApiController]
[Route("api/[controller]")]
public class DemoController : ControllerBase
{
// ...
}
所谓的角色其实就是一个字符串,实际开发中它可能存储在一个角色表中并和用户表多对多关联,登录时角色信息将从数据库加载,这里出于简单起见我们直接就通过代码固定写了一个角色。我们可以通过浏览器进行验证,当登录时指定的角色不符合时,访问DemoController
将返回无权限错误。如果需要检查多个角色(“或”关系),可以用逗号分隔,例如[Authorize(Roles = "Admin,RootAdmin")]
,此外,[Authorize]
注解也可以添加多个,检查多个条件是否全部满足(“且”关系)。
除了基于Role
检查权限,我们还可以基于Claim
检查权限,后者用起来稍微麻烦一点,需要我们手动添加验证Policy
。在Program.cs
中,我们添加一条规则,规则的名字叫LevelChaim2or3Check
,这条规则检查名为Level
的Claim
,要求它的值必须是2
或者3
。
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("LevelChaim2or3Check", policy => policy.RequireClaim("Level", "2", "3"));
});
登陆时我们添加这样一个叫Level
的自定义Claim
,控制器中我们使用Policy
属性指定规则名进行检查。
claimsIdentity.AddClaims(new[]
{
new Claim(ClaimTypes.Name, loginDto.Username),
new Claim(ClaimTypes.Role, "Admin"),
new Claim("Level", "2")
});
[Authorize(Policy = "LevelChaim2or3Check")]
[ApiController]
[Route("api/[controller]")]
public class DemoController : ControllerBase
{
// ...
}
如果登陆时我们设置的Level
不是2
或3
,访问控制器时将返回无权限报错。
Policy
规则可以组成AND或OR关系,上面我们实现的其实是OR关系,它检查Level
的值必须是2
或3
,AND关系写法如下。
options.AddPolicy("LevelChaim2andVIP1Check", policy => policy.RequireClaim("Level", "2").RequireClaim("VIP", "1"));
有关JWT的概念在很多其它章节笔记中都有涉及,具体可以查看软件工程/信息安全/身份认证/JWT身份认证
,这里就不赘述了,我们直接看ASP.NET Core中如何实现基于JWT认证和授权。
基于JWT认证需要安装额外的依赖。
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 6.0.36
在Program.cs
中我们添加如下配置。代码中注意JWT密钥的管理,我们这里比较简单的硬编码了一个非常弱的密钥,实际开发中不要这样写,此外JWT默认使用AES-256进行对称密钥加密,这要求密钥也必须是256bit以上的(超出会截断),ASP.NET Core并没有实现密钥长度不足补位的逻辑,如果密钥长度不够会直接启动报错。
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true, // 是否验证发行人(Issuer)
ValidateAudience = true, // 是否验证观众(Audience)
ValidateLifetime = true, // 是否验证有效期
ValidateIssuerSigningKey = true, // 是否指定JWT签名密钥
ValidIssuer = "iss",
ValidAudience = "aud",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("abcd1234abcd1234abcd1234abcd1234"))
};
});
下面代码实现了登录接口,其中验证用户名密码后,设置了身份信息并签发了JWT密钥。
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using DemoWebAPI.Model;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
namespace DemoWebAPI.Controllers;
[ApiController]
[Route("api/[controller]")]
public class LoginController : ControllerBase
{
[HttpPost("[action]")]
public ActionResult<ApiResult> Login(LoginDto loginDto)
{
if (loginDto is { Username: "tom", Password: "123" })
{
// 登录成功,设置身份信息
Claim[] claims = {
new Claim(ClaimTypes.Name, loginDto.Username)
};
// 签发JWT
SymmetricSecurityKey key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("abcd1234abcd1234abcd1234abcd1234"));
SigningCredentials credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
JwtSecurityToken token = new JwtSecurityToken(
issuer: "iss",
audience: "aud",
claims: claims,
expires: DateTime.UtcNow.AddMinutes(30),
signingCredentials: credentials
);
string tokenStr = new JwtSecurityTokenHandler().WriteToken(token);
return ApiResult.Success(tokenStr);
}
else
{
// 登录失败
return ApiResult.Failure("403", "用户名或密码错误");
}
}
}
携带JWT请求被认证授权过滤器保护的接口时,JWT的传递方式例子如下,我们需要使用Authorization
请求头,具体格式为Bearer <JWT>
。
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoidG9tIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjpbIkFkbWluIiwiT3duZXIiXSwiZXhwIjoxNzM3Mjg2OTYwLCJpc3MiOiJpc3MiLCJhdWQiOiJhdWQifQ.KzHKg441oxoq8Rjw3ntvVRLrJqY0NjK6NzXbwu647mE
JWT无法实现退出功能,所谓的退出我们在客户端把JWT删除即可,这也是JWT本身的缺陷。至于授权,我们在登录时添加角色Claim
或自定义Claim
即可,用法和之前基于Cookie的授权完全相同,这里就不多介绍了。