升级到 Asp.Net Core 2.0 (2017/08/29 更新)git
最近,移动开发的劲头愈来愈足,学校搞的各类比赛都须要用手机 APP 来撑场面,因此,做为写后端的,颇有必要改进一下以往的基于 Session 的身份认证方式了,理由以下:算法
因此我选择了使用 Jwt (Json Web Token) 这个技术。Jwt 是一种无状态的分布式的身份验证方式,与 Session 相反,Jwt 将用户信息存放在 Token 的 payload 字段保存在客户端,经过 RSA 加密的方式,保证数据不会被篡改,验证数据有效性。
下面是一个使用 Jwt 的系统的身份验证流程:数据库
能够看出,用户的信息保存在 Token 中,而 Token 分布在用户的设备中,因此服务端再也不须要在内存中保存用户信息了
身份认证的 Token 传递时以一种至关简单的格式保存在 header 中,方便客户端对其进行操做json
Jwt 形式的 token 通常分为 3 个部分,分别是 Header,Payload,Signature,这三个部分使用 .
分隔。其中前两部分使用 Base64 编码,未经加密处理,第三个部分使用 RSA 加密。
因此一个 Jwt 看起来大概是这个样子:后端
header.payload.signature
下面是一个真实的 Jwt:api
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6InplZWtvIiwicm9sZSI6IiIsIm5hbWVpZCI6MSwianRpIjoiNjNjN2Q3OWY2N2VhMDhjYjRiYzNjMmNkOTJiY2JkNTgiLCJuYmYiOjE0OTQ0MDMwMjQsImV4cCI6MTQ5NTAwNzgyMywiaWF0IjoxNDk0NDAzMDI0LCJpc3MiOiJUZXN0SXNzdWVyIiwiYXVkIjoiVGVzdEF1ZGllbmNlIn0.V7Mfi3FGOTLYV0O5DmOWju7LkDJwZNO6HZN19CHb3ekYxcoVbP51YjYAr0fUHc3RPIp3gxITzziHY-07xZ2swCaV0K-hiF5IbwpDuvyxsnlgaRxS94wKDGKSJkArC82KukCtm7IuFBxnNr6kxe7tGcebVhqtaqgnxEUg5lKtDtVI85kd17YtzBp9Vxnc3Ie0r-6KPgUa2HacCf2Pc3hkvY7tZdWZ6ininZlZ-EbcyZI2KTx-vOqdK63MS2JYSw7W2qwf89tsRsORwbB2P4dOBBFK8YSXJpeyGeJWFEMjAMkiH3AeMmW2w_H7r_6Pn-jh5gozzBei4JoHTU6RVDUg1A
Header 部分通常用来记录加密算法跟 Token 类型
举个例子:安全
{ "alg": "HS256", "typ": "JWT" }
Payload 存放的是一些不敏感的用户数据,由于这一部分仅仅只是使用 Base64 加密,因此不该该用来保存用户的密码之类的信息。服务器
一个例子:app
{ "sub": "1234567890", "name": "John Doe", "admin": true }
这一部分是 Jwt 最重要的部分,使用 header 中记录的算法进行了加密,加密方式以下:asp.net
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
因此这个部分能够用来保证用户信息不被篡改,起到验证用户身份的做用
在开发过程当中,能够访问 https://jwt.io 来调试 Token
固然,为了更快的访问速度,还可使用 这个网站
由于 Jwt 自己的特色,因此用来签发 Token 的服务器能够跟应用服务器不是同一台,这样就能够搞微服务之类的东西(反正我不懂。。。)
所以,在这篇博客中,将会建立两个 Web 应用:
首先来搭建咱们的 Token 签发服务器吧!
因为要使用到 RSA 加密,因此先建立一个辅助类来帮助简化调用:
RSAUtils.cs
using System.IO; using System.Security.Cryptography; using Newtonsoft.Json; namespace JwtUtils { public static class RSAUtils { /// <summary> /// 从本地文件中读取用来签发 Token 的 RSA Key /// </summary> /// <param name="filePath">存放密钥的文件夹路径</param> /// <param name="withPrivate"></param> /// <param name="keyParameters"></param> /// <returns></returns> public static bool TryGetKeyParameters(string filePath, bool withPrivate, out RSAParameters keyParameters) { string filename = withPrivate ? "key.json" : "key.public.json"; keyParameters = default(RSAParameters); if (Directory.Exists(filePath) == false) return false; keyParameters = JsonConvert.DeserializeObject<RSAParameters>(File.ReadAllText(Path.Combine(filePath, filename))); return true; } /// <summary> /// 生成并保存 RSA 公钥与私钥 /// </summary> /// <param name="filePath">存放密钥的文件夹路径</param> /// <returns></returns> public static RSAParameters GenerateAndSaveKey(string filePath) { RSAParameters publicKeys, privateKeys; using (var rsa = new RSACryptoServiceProvider(2048)) { try { privateKeys = rsa.ExportParameters(true); publicKeys = rsa.ExportParameters(false); } finally { rsa.PersistKeyInCsp = false; } } File.WriteAllText(Path.Combine(filePath, "key.json"), JsonConvert.SerializeObject(privateKeys)); File.WriteAllText(Path.Combine(filePath, "key.public.json"), JsonConvert.SerializeObject(publicKeys)); return privateKeys; } } }
这个工具类可以帮助咱们生成 RSA 密钥,并把生成的私钥跟公钥保存在两个文件中,还能从文件中读取密钥。
而后定义一个数据类,用来帮助咱们在应用的各个地方获取加密相关的信息:
JWTTokenOptions.cs
using Microsoft.IdentityModel.Tokens; namespace JwtUtils { public class JWTTokenOptions { public string Audience { get; set; } public RsaSecurityKey Key { get; set; } public SigningCredentials Credentials { get; set; } public string Issuer { get; set; } } }
接下来在 Startup.cs 中配置 Jwt 的加密选项:
public void ConfigureServices(IServiceCollection services) { // 省略了其余的东西 // 从文件读取密钥 string keyDir = PlatformServices.Default.Application.ApplicationBasePath; if (RSAUtils.TryGetKeyParameters(keyDir, true, out RSAParameters keyParams) == false) { keyParams = RSAUtils.GenerateAndSaveKey(keyDir); } _tokenOptions.Key = new RsaSecurityKey(keyParams); _tokenOptions.Issuer = "TestIssuer"; // 签发者名称 _tokenOptions.Credentials = new SigningCredentials(_tokenOptions.Key, SecurityAlgorithms.RsaSha256Signature); // 添加到 IoC 容器 services.AddSingleton(_tokenOptions); services.AddMvc(); }
接下来建立一个控制器,用来提供签发 Token 的 API
TokenController.cs
namespace JwtIssuer.Controllers { [Route("api/[controller]")] public class TokenController : Controller { private readonly JWTTokenOptions _tokenOptions; private readonly AuthDbContext _dbContext; public TokenController(JWTTokenOptions tokenOptions, AuthDbContext dbContext) { _tokenOptions = tokenOptions; _dbContext = dbContext; } /// <summary> /// 生成一个新的 Token /// </summary> /// <param name="user">用户信息实体</param> /// <param name="expire">token 过时时间</param> /// <param name="audience">Token 接收者</param> /// <returns></returns> private string CreateToken(User user, DateTime expire, string audience) { var handler = new JwtSecurityTokenHandler(); string jti = audience + user.Username + expire.GetMilliseconds(); jti = jti.GetMd5(); // Jwt 的一个参数,用来标识 Token var claims = new[] { new Claim(ClaimTypes.Role, user.Role ?? string.Empty), // 添加角色信息 new Claim(ClaimTypes.NameIdentifier, user.Id.ToString(), // 用户 Id ClaimValueTypes.Integer32), new Claim("jti",jti,ClaimValueTypes.String) // jti,用来标识 token }; ClaimsIdentity identity = new ClaimsIdentity(new GenericIdentity(user.Username, "TokenAuth"), claims); var token = handler.CreateEncodedJwt(new SecurityTokenDescriptor { Issuer = "TestIssuer", // 指定 Token 签发者,也就是这个签发服务器的名称 Audience = audience, // 指定 Token 接收者 SigningCredentials = _tokenOptions.Credentials, Subject = identity, Expires = expire }); return token; } /// <summary> /// 用户登陆 /// </summary> /// <param name="user">用户登陆信息</param> /// <param name="audience">要访问的网站</param> /// <returns></returns> [HttpPost("{audience}")] public IActionResult Post([FromBody]User user, string audience) { DateTime expire = DateTime.Now.AddDays(7); // 在这里来验证用户的用户名、密码 var result = _dbContext.Users.First(u => u.Username == user.Username && u.Password == user.Password); if (result == null) { return Json(new { Error = "用户名或密码错误" }); } return Json(new { Token = CreateToken(result, expire, audience) }); } } }
如今,访问这个 API(http://localhost:port/api/token/TestAudience) 就能够获取用户的 Token 了
在 Startup.cs 中注册 Jwt 相关的服务:
public void ConfigureServices(IServiceCollection services) { // 省略了其余的内容 // 从文件读取密钥 string keyDir = PlatformServices.Default.Application.ApplicationBasePath; if (RSAUtils.TryGetKeyParameters(keyDir, false, out RSAParameters keyparams) == false) { _tokenOptions.Key = default(RsaSecurityKey); } else { _tokenOptions.Key = new RsaSecurityKey(keyparams); } _tokenOptions.Issuer = "TestIssuer"; // 设置签发者 _tokenOptions.Audience = "TestAudience"; // 设置签收者,也就是这个应用服务器的名称 _tokenOptions.Credentials = new SigningCredentials(_tokenOptions.Key, SecurityAlgorithms.RsaSha256Signature); services.AddAuthorization(auth => { auth.AddPolicy("Bearer", new AuthorizationPolicyBuilder() .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) .RequireAuthenticatedUser() .Build()); }); // Add framework services. services.AddMvc(); }
而后在 Startup.cs 添加 Jwt 认证中间件:
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) { // 省略了其余的内容 app.UseJwtBearerAuthentication(new JwtBearerOptions { TokenValidationParameters = new TokenValidationParameters { IssuerSigningKey = _tokenOptions.Key, ValidAudience = _tokenOptions.Audience, // 设置接收者必须是 TestAudience ValidIssuer = _tokenOptions.Issuer, // 设置签发者必须是 TestIssuer ValidateLifetime = true } }); }
接着随便建立一个 API 控制器
namespace JwtAudience.Controllers { [Route("api/[controller]")] public class ValuesController : Controller { // GET api/values [HttpGet] [Authorize] public IEnumerable<string> Get() { return new string[] { "value1", "value2" }; } } }
首先编译一下应用服务器,可是不要急着运行。由于应用服务器验证 Token 是须要公钥的,因此如今去以前的签发服务器的 build 目录
能够看到生成了两个json文件,将其中的 key.public.json 拷贝到应用服务器的对应的目录下面,而后运行应用服务器。
若是咱们直接访问应用服务器的 API,就会被挡在外面:
因此如今去把以前拿到的 token 复制出来,而后给这个请求加个请求头——Authorization
值是 Bearer 你的Token
这样,基本的身份验证就完成了~
有兴趣的话还能够把这个 Token 放在前面提到的用来调试 Jwt 网站上,个人 Token 的解析结果是:
这里面的 iss 指的就是签发者,aud 指的是接收者,对于咱们的应用服务器来讲,这两个参数错了任意一个都将没法经过验证(这里就不演示了,等会儿会有测试代码~)
至此,咱们已经把 Jwt 的身份认证基本实现了,可是仔细想一想,却发现存在一个很严重的问题————用户的 Token 在过时时间以内根本没法手动设置失效,随之而来的还有重放攻击等等问题!
Jwt官方也没有提供很好的应对方法,如今就只有一条路能够走,就是把失效的 Token 加入黑名单。只要可以让 Token 失效,以后应对这些安全问题就只是策略上的选择。
在 Jwt 的官方说明中,jti
这个参数就是用来标识 Token 的。因此,让一个 Token 失效只须要把这个 Token 中的 jti
加入应用服务器的数据库的黑名单就行了。
得益于微软对 Identity 良好的设计,咱们能够很容易的拓展默认的 Jwt 认证规则
首先建立一个 ValidJtiRequirement 类
public class ValidJtiRequirement : IAuthorizationRequirement { }
嗯,他的结构就是这么简单。。。
而后建立一个用来验证这个 Requirement 的 ValidJtiHandler
public class ValidJtiHandler : AuthorizationHandler<ValidJtiRequirement> { private readonly AudienceDbContext _dbContext; public ValidJtiHandler(AudienceDbContext dbContext) { _dbContext = dbContext; } protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, ValidJtiRequirement requirement) { // 检查 Jti 是否存在 var jti = context.User.FindFirst("jti")?.Value; if (jti == null) { context.Fail(); // 显式的声明验证失败 return Task.CompletedTask; } // 检查 jti 是否在黑名单 var tokenExists = _dbContext.BlackRecords.Any(r => r.Jti == jti); if (tokenExists) { context.Fail(); } else { context.Succeed(requirement); // 显式的声明验证成功 } return Task.CompletedTask; } }
最后,稍微的修改一下注册服务时的代码
services.AddAuthorization(auth => { auth.AddPolicy("Bearer", new AuthorizationPolicyBuilder() .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) .RequireAuthenticatedUser() .AddRequirements(new ValidJtiRequirement()) // 添加上面的验证要求 .Build()); }); // 注册验证要求的处理器,可经过这种方式对同一种要求添加多种验证 services.AddSingleton<IAuthorizationHandler, ValidJtiHandler>();
最后再来提供一个使 Token 失效的 API
namespace JwtAudience.Controllers { [Route("api/[controller]")] public class TokenController : Controller { private readonly AudienceDbContext _dbContext; public TokenController(AudienceDbContext dbContext) { _dbContext = dbContext; } [HttpGet] public IActionResult Get() => Json(_dbContext.BlackRecords); /// <summary> /// 使用户的 Token 失效 /// </summary> /// <returns></returns> [Authorize("Bearer")] [HttpDelete] public IActionResult Delete() { // 从 payload 中提取 jti 字段 var jti = User.FindFirst("jti")?.Value; var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (jti == null) { HttpContext.Response.StatusCode = 400; return Json(new { Result = false }); } // 把这个 jti 加入数据库 _dbContext.BlackRecords.Add(new BlackRecord { Jti = jti, UserId = userId }); _dbContext.SaveChanges(); return Json(new {Result = true}); } } }
这里须要注意的是,由于拓展了默认的验证策略,因此须要在 Authorize
这个特性钦定使用 Bearer
策略:
[Authorize("Bearer")]
可是这样就容易在编码的时候出现拼写错误,因此来建立一个继承自这个特性的BearerAuthorize
类。
namespace JwtAudience { /// <summary> /// Jwt 验证 /// </summary> public class BearerAuthorizeAttribute : AuthorizeAttribute { public BearerAuthorizeAttribute() : base("Bearer") { } } }
如今咱们就可使用[BearerAuthorize]
来替代[Authorize]
至此,使 token 失效的能力就具有了。
而后附带一份测试代码,用来检验认证过程是否符合咱们的预期:
https://coding.net/u/zeeko/p/JwtApplication/git/blob/master/Test/Test.cs
花了一天时间来把项目升级到 2.0,并非由于 API 变化很大,而是以前的 bug 有些多,修起来有些慢。
首先要升级 Program.cs 里面的 Main
函数:
public class Program { public static void Main(string[] args) { BuildWebHost(args).Run(); } public static IWebHost BuildWebHost(string[] args) => WebHost.CreateDefaultBuilder(args) .UseStartup<Startup>() .Build(); }
看起来更简短了一些。
接下来升级认证配置,按照官方的说明,全部的 app.UseXxxAuthentication
方法都变成了 service.AddAuthentication(XxxSchema).AddXxx()
,因此改动不是很大:
JwtIssuer/Startup.cs/ConfigureServices
services.AddAuthentication().AddJwtBearer(jwtOptions => { jwtOptions.TokenValidationParameters = new TokenValidationParameters { IssuerSigningKey = _tokenOptions.Key, ValidAudience = _tokenOptions.Audience, ValidIssuer = _tokenOptions.Issuer, ValidateLifetime = true }; });
JwtAudience/Startup.cs/ConfigureServices
services.AddAuthentication().AddJwtBearer(jwtOptions => { jwtOptions.TokenValidationParameters = new TokenValidationParameters { IssuerSigningKey = _tokenOptions.Key, ValidAudience = _tokenOptions.Audience, ValidIssuer = _tokenOptions.Issuer, ValidateLifetime = true }; });
至此,须要升级的地方就修改好了,可是到目前为止仍是没法运行,由于有些在 1.0 里面没有严格检验的地方开始报错了。
第一个地方是 ValidJtiHandler
,以前在注册的时候,生命周期选的是单例并无报错,可是由于这个类依赖了一个生命周期是 Scoped 的对象—— AudienceDbContext
,这会引起一个异常,解决方法是把 ValidJtiHandler
也改为 Scoped:
services.AddScoped<IAuthorizationHandler, ValidJtiHandler>();
第二个地方是 RSAParameters
在 2.x 里面,它的私钥属性不能被 Json.Net 序列化,解决方法也很简单,加一个对应的相似 DTO 的类:
class RsaParameterStorage { public byte[] D { get; set; } public byte[] DP { get; set; } public byte[] DQ { get; set; } public byte[] Exponent { get; set; } public byte[] InverseQ { get; set; } public byte[] Modulus { get; set; } public byte[] P { get; set; } public byte[] Q { get; set; } }
而后在导出私钥前将 RSAParameters
映射成一个 RsaParameterStorage
对象,而后使用 Json.Net 来序列化,映射使用的是我本身写的一个 Mapper(因此升级项目只花了几十分钟,调教 Mapper 花了一天),代码更改以下:
// 转换成 json 字符串 static string ToJsonString(this RSAParameters parameters) { var content = parameters.Map().To<RsaParameterStorage>(); return JsonConvert.SerializeObject(content); } // 从文件中读取 keyParameters = JsonConvert.DeserializeObject<RsaParameterStorage>(File.ReadAllText(filePath)).Map().To<RSAParameters>();