升級到 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>();