本文將經過實際的例子來演示如何在ASP.NET Core中應用JWT進行用戶認證以及Token的刷新方案(ASP.NET Core 系列目錄)html
1、什麼是JWT?
JWT(json web token)基於開放標準(RFC 7519),是一種無狀態的分佈式的身份驗證方式,主要用於在網絡應用環境間安全地傳遞聲明。它是基於JSON的,因此它也像json同樣能夠在.Net、JAVA、JavaScript,、PHP等多種語言使用。
爲何要使用JWT?
傳統的Web應用通常採用Cookies+Session來進行認證。但對於目前愈來愈多的App、小程序等應用來講,它們對應的服務端通常都是RestFul 類型的無狀態的API,再採用這樣的的認證方式就不是很方便了。而JWT這種無狀態的分佈式的身份驗證方式剛好符合這樣的需求。git
2、JWT的組成:
JWT是什麼樣子的呢?它就是下面這樣的一段字符串:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjAwMiIsImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWUiOiLmnY7lm5siLCJuYmYiOjE1NjU5MjMxMjIsImV4cCI6MTU2NTkyMzI0MiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDIxNCIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTQyMTUifQ.Mrta7nftmfXeo_igBVd4rl2keMmm0rg0WkqRXoVAeik
它是由三段「亂碼」字符串經過兩個「.」鏈接在一塊兒組成。官網https://jwt.io/提供了它的驗證方式
它的三個字符串分別對應了上圖右側的Header、Payload和Signature三部分。github
Header:
Header:
{
"alg": "HS256", "typ": "JWT" }
標識加密方式爲HS256,Token類型爲JWT, 這段JSON經過Base64Url編碼造成上例的第一個字符串web
Payload
Payload是JWT用於信息存儲部分,其中包含了許多種的聲明(claims)。
能夠自定義多個聲明添加到Payload中,系統也提供了一些默認的類型
iss (issuer):簽發人
exp (expiration time):過時時間
sub (subject):主題
aud (audience):受衆
nbf (Not Before):生效時間
iat (Issued At):簽發時間
jti (JWT ID):編號數據庫
這部分經過Base64Url編碼生成第二個字符串。json
Signature
Signature是用於Token的驗證。它的值相似這樣的表達式:Signature = HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret),也就是說,它是經過將前兩個字符串加密後生成的一個新字符串。小程序
因此只有擁有一樣加密密鑰的人,才能經過前兩個字符串得到一樣的字符串,經過這種方式保證了Token的真實性。api
3、認證流程
大概的流程是這樣的:緩存
- 認證服務器:用於用戶的登陸驗證和Token的發放。
- 應用服務器:業務數據接口。被保護的API。
- 客戶端:通常爲APP、小程序等。
認證流程:安全
- 用戶首先經過登陸,到認證服務器獲取一個Token。
- 在訪問應用服務器的API的時候,將獲取到的Token放置在請求的Header中。
- 應用服務器驗證該Token,經過後返回對應的結果。
說明:這只是示例方案,實際項目中可能有所不一樣。
- 對於小型項目,可能認證服務和應用服務在一塊兒。本例經過分開的方式來實現,使咱們能更好的瞭解兩者之間的認證流程。
- 對於複雜一些的項目,可能存在多個應用服務,用戶獲取到的Token能夠在多個分佈式服務中被認證,這也是JWT的優點之一。
關於JWT的文章不少,這裏就不作過多介紹了。下面經過實際的例子來看一下 它是如何在ASP.NET Core 中應用的。
4、應用實例
上一節的圖:「JWT的認證流程」中涉及到客戶端、認證服務器、應用服務器三部分,下面經過示例來對這三部分進行模擬:
- 認證服務器:新建一個WebApi的解決方案,名爲FlyLolo.JWT.Server。
- 應用服務器:新建一個WebApi的解決方案,名爲FlyLolo.JWT.API。
- 客戶端:這裏用Fiddler發送請求作測試。
認證服務
首先新建一個ASP.NET Core 的解決方案WebApi的解決方案
將其命名爲FlyLolo.JWT.Server。
首先新建一個TokenController用於登陸和Token的發放:
[Route("api/[controller]")] public class TokenController : Controller { private ITokenHelper tokenHelper = null; public TokenController(ITokenHelper _tokenHelper) { tokenHelper = _tokenHelper; } [HttpGet] public IActionResult Get(string code, string pwd) { User user = TemporaryData.GetUser(code); if (null != user && user.Password.Equals(pwd)) { return Ok(tokenHelper.CreateToken(user)); } return BadRequest(); } }
它有個名爲Get的Action用於接收提交的用戶名和密碼,並進行驗證,驗證經過後,調用TokenHelper的CreateToken方法生成Token返回。
這裏涉及到了User和TokenHelper兩個類。
User相關:
public class User { public string Code { get; set; } public string Name { get; set; } public string Password { get; set; } }
因爲只是Demo,User類只含有以上三個字段。在TemporaryData類中作了User的模擬數據
/// <summary> /// 虛擬數據,模擬從數據庫或緩存中讀取用戶 /// </summary> public static class TemporaryData { private static List<User> Users = new List<User>() { new User { Code = "001", Name = "張三", Password = "111111" }, new User { Code = "002", Name = "李四", Password = "222222" } }; public static User GetUser(string code) { return Users.FirstOrDefault(m => m.Code.Equals(code)); } }
這只是模擬數據,實際項目中應該從數據庫或者緩存等讀取。
TokenHelper:
public class TokenHelper : ITokenHelper { private IOptions<JWTConfig> _options; public TokenHelper(IOptions<JWTConfig> options) { _options = options; } public Token CreateToken(User user) { Claim[] claims = { new Claim(ClaimTypes.NameIdentifier,user.Code),new Claim(ClaimTypes.Name,user.Name) }; return CreateToken(claims); } private Token CreateToken(Claim[] claims) { var now = DateTime.Now;var expires = now.Add(TimeSpan.FromMinutes(_options.Value.AccessTokenExpiresMinutes)); var token = new JwtSecurityToken( issuer: _options.Value.Issuer, audience: _options.Value.Audience, claims: claims, notBefore: now, expires: expires, signingCredentials: new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Value.IssuerSigningKey)), SecurityAlgorithms.HmacSha256)); return new Token { TokenContent = new JwtSecurityTokenHandler().WriteToken(token), Expires = expires }; } }
經過CreateToken方法建立Token,這裏有幾個關鍵參數:
- issuer Token發佈者
- Audience Token接受者
- expires 過時時間
- IssuerSigningKey 簽名祕鑰
對應的Token代碼以下:
public class Token { public string TokenContent { get; set; } public DateTime Expires { get; set; } }
這樣經過TokenHelper的CreateToken方法生成了一個Token返回給了客戶端。到如今來看,貌似全部的工做已經完成了。並不是如此,咱們還須要在Startup文件中作一些設置。
public class Startup {
// 。。。。。。此處省略部分代碼
public void ConfigureServices(IServiceCollection services) {
//讀取配置信息 services.AddSingleton<ITokenHelper, TokenHelper>(); services.Configure<JWTConfig>(Configuration.GetSection("JWT")); //啓用JWT services.AddAuthentication(Options => { Options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; Options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }). AddJwtBearer(); services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); } public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); }
//啓用認證中間件 app.UseAuthentication(); app.UseMvc(); } }
這裏用到了配置信息,在appsettings.json中對認證信息作配置以下:
"JWT": { "Issuer": "FlyLolo", "Audience": "TestAudience", "IssuerSigningKey": "FlyLolo1234567890", "AccessTokenExpiresMinutes": "30" }
運行這個項目,並經過Fidder以Get方式訪問api/token?code=002&pwd=222222,返回結果以下:
{"tokenContent":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8
yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjAwMiIsImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL
3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWUiOiLmnY7lm5siLCJuYmYiOjE1NjY3OTg0NzUsImV4cCI6MTU2NjgwMDI
3NSwiaXNzIjoiRmx5TG9sbyIsImF1ZCI6IlRlc3RBdWRpZW5jZSJ9.BVf3gOuW1E9RToqKy8XXp8uIvZKL-lBA-q9fB9QTEZ4",
"expires":"2019-08-26T21:17:55.1183172+08:00"}
客戶端登陸成功併成功返回了一個Token,認證服務建立完成
應用服務
新建一個WebApi的解決方案,名爲FlyLolo.JWT.API。
添加BookController用做業務API。
[Route("api/[controller]")] [Authorize] public class BookController : Controller { // GET: api/<controller> [HttpGet] [AllowAnonymous] public IEnumerable<string> Get() { return new string[] { "ASP", "C#" }; } // POST api/<controller> [HttpPost] public JsonResult Post() { return new JsonResult("Create Book ..."); } }
對此Controller添加了[Authorize]標識,表示此Controller的Action被訪問時須要進行認證,而它的名爲Get的Action被標識了[AllowAnonymous],表示此Action的訪問能夠跳過認證。
在Startup文件中配置認證:
public class Startup { // 省略部分代碼 public void ConfigureServices(IServiceCollection services) { #region 讀取配置 JWTConfig config = new JWTConfig(); Configuration.GetSection("JWT").Bind(config); #endregion #region 啓用JWT認證 services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }). AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidIssuer = config.Issuer, ValidAudience = config.Audience, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config.IssuerSigningKey)), ClockSkew = TimeSpan.FromMinutes(1) }; }); #endregion services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseAuthentication(); app.UseMvc(); } }
這裏一樣用到了配置:
public class JWTConfig { public string Issuer { get; set; } public string Audience { get; set; } public string IssuerSigningKey { get; set; } public int AccessTokenExpiresMinutes { get; set; } }
appsettings.json:
"JWT": { "Issuer": "FlyLolo", "Audience": "TestAudience", "IssuerSigningKey": "FlyLolo1234567890", "AccessTokenExpiresMinutes": "30" }
關於JWT認證,這裏經過options.TokenValidationParameters對認證信息作了設置,ValidIssuer、ValidAudience、IssuerSigningKey這三個參數用於驗證Token生成的時候填寫的Issuer、Audience、IssuerSigningKey,因此值要和生成Token時的設置一致。
ClockSkew默認值爲5分鐘,它是一個緩衝期,例如Token設置有效期爲30分鐘,到了30分鐘的時候是不會過時的,會有這麼個緩衝時間,也就是35分鐘纔會過時。爲了方便測試(不想等太長時間),這裏我設置了1分鐘。
TokenValidationParameters還有一些其餘參數,在它的構造方法中已經作了默認設置,代碼以下:
public TokenValidationParameters() { RequireExpirationTime = true; RequireSignedTokens = true; SaveSigninToken = false; ValidateActor = false; ValidateAudience = true; //是否驗證接受者 ValidateIssuer = true; //是否驗證發佈者 ValidateIssuerSigningKey = false; //是否驗證祕鑰 ValidateLifetime = true; //是否驗證過時時間 ValidateTokenReplay = false; }
訪問api/book,正常返回告終果
["ASP","C#"]
經過POST方式訪問,返回401錯誤。
這就須要使用獲取到的Toke了,以下圖方式再次訪問
添加了「Authorization: bearer Token內容」這樣的Header,能夠正常訪問了。
至此,簡單的JWT認證示例就完成了,代碼地址https://github.com/FlyLolo/JWT.Demo/releases/tag/1.0。
這裏可能會有個疑問,例如:
1.Token被盜了怎麼辦?
答: 在啓用Https的狀況下,Token被放在Header中仍是比較安全的。另外Token的有效期不要設置過長。例如能夠設置爲1小時(微信公衆號的網頁開發的Token有效期爲2小時)。
2. Token到期瞭如何處理?
答:理論上Token過時應該是跳到登陸界面,但這樣太不友好了。能夠在後臺根據Token的過時時間按期去請求新的Token。下一節來演示一下Token的刷新方案。
5、Token的刷新
爲了使客戶端可以獲取到新的Token,對上文的例子進行改造,大概思路以下:
- 用戶登陸成功的時候,一次性給他兩個Token,分別爲AccessToken和RefreshToken,AccessToken用於正常請求,也就是上例中原有的Token,RefreshToken做爲刷新AccessToken的憑證。
- AccessToken的有效期較短,例如一小時,短一點安全一些。RefreshToken有效期能夠設置長一些,例如一天、一週等。
- 當AccessToken即將過時的時候,例如提早5分鐘,客戶端利用RefreshToken請求指定的API獲取新的AccessToken並更新本地存儲中的AccessToken。
因此只須要修改FlyLolo.JWT.Server便可。
首先修改Token的返回方案,新增一個Model
public class ComplexToken { public Token AccessToken { get; set; } public Token RefreshToken { get; set; } }
包含AccessToken和RefreshToken,用於用戶登陸成功後的Token結果返回。
修改 appsettings.json,添加兩個配置項:
"RefreshTokenAudience": "RefreshTokenAudience", "RefreshTokenExpiresMinutes": "10080" //60*24*7
RefreshTokenExpiresMinutes用於設置RefreshToken的過時時間,這裏設置了7天。RefreshTokenAudience用於設置RefreshToken的接受者,與原Audience值不一致,做用是使RefreshToken不能用於訪問應用服務的業務API,而AccessToken不能用於刷新Token。
修改TokenHelper:
public enum TokenType { AccessToken = 1, RefreshToken = 2 } public class TokenHelper : ITokenHelper { private IOptions<JWTConfig> _options; public TokenHelper(IOptions<JWTConfig> options) { _options = options; } public Token CreateAccessToken(User user) { Claim[] claims = new Claim[] { new Claim(ClaimTypes.NameIdentifier, user.Code), new Claim(ClaimTypes.Name, user.Name) }; return CreateToken(claims, TokenType.AccessToken); } public ComplexToken CreateToken(User user) { Claim[] claims = new Claim[] { new Claim(ClaimTypes.NameIdentifier, user.Code), new Claim(ClaimTypes.Name, user.Name) //下面兩個Claim用於測試在Token中存儲用戶的角色信息,對應測試在FlyLolo.JWT.API的兩個測試Controller的Put方法,若用不到可刪除 , new Claim(ClaimTypes.Role, "TestPutBookRole"), new Claim(ClaimTypes.Role, "TestPutStudentRole") }; return CreateToken(claims); } public ComplexToken CreateToken(Claim[] claims) { return new ComplexToken { AccessToken = CreateToken(claims, TokenType.AccessToken), RefreshToken = CreateToken(claims, TokenType.RefreshToken) }; } /// <summary> /// 用於建立AccessToken和RefreshToken。 /// 這裏AccessToken和RefreshToken只是過時時間不一樣,【實際項目】中兩者的claims內容可能會不一樣。 /// 由於RefreshToken只是用於刷新AccessToken,其內容能夠簡單一些。 /// 而AccessToken可能會附加一些其餘的Claim。 /// </summary> /// <param name="claims"></param> /// <param name="tokenType"></param> /// <returns></returns> private Token CreateToken(Claim[] claims, TokenType tokenType) { var now = DateTime.Now; var expires = now.Add(TimeSpan.FromMinutes(tokenType.Equals(TokenType.AccessToken) ? _options.Value.AccessTokenExpiresMinutes : _options.Value.RefreshTokenExpiresMinutes));//設置不一樣的過時時間 var token = new JwtSecurityToken( issuer: _options.Value.Issuer, audience: tokenType.Equals(TokenType.AccessToken) ? _options.Value.Audience : _options.Value.RefreshTokenAudience,//設置不一樣的接受者 claims: claims, notBefore: now, expires: expires, signingCredentials: new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Value.IssuerSigningKey)), SecurityAlgorithms.HmacSha256)); return new Token { TokenContent = new JwtSecurityTokenHandler().WriteToken(token), Expires = expires }; } public Token RefreshToken(ClaimsPrincipal claimsPrincipal) { var code = claimsPrincipal.Claims.FirstOrDefault(m => m.Type.Equals(ClaimTypes.NameIdentifier)); if (null != code ) { return CreateAccessToken(TemporaryData.GetUser(code.Value.ToString())); } else { return null; } } }
在登陸後,生成兩個Token返回給客戶端。在TokenHelper添加了一個RefreshToken方法,用於生成新的AccessToken。對應在TokenController中添加一個名爲Post的Action,用於調用這個RefreshToken方法刷新Token
[HttpPost] [Authorize] public IActionResult Post() { return Ok(tokenHelper.RefreshToken(Request.HttpContext.User)); }
這個方法添加了[Authorize]標識,說明調用它須要RefreshToken認證經過。既然啓用了認證,那麼在Startup文件中須要像上例的業務API同樣作JWT的認證配置。
public void ConfigureServices(IServiceCollection services) { #region 讀取配置信息 services.AddSingleton<ITokenHelper, TokenHelper>(); services.Configure<JWTConfig>(Configuration.GetSection("JWT")); JWTConfig config = new JWTConfig(); Configuration.GetSection("JWT").Bind(config); #endregion #region 啓用JWT services.AddAuthentication(Options => { Options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; Options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }). AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidIssuer = config.Issuer, ValidAudience = config.RefreshTokenAudience, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config.IssuerSigningKey)) }; }); #endregion services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); }
注意這裏的ValidAudience被賦值爲config.RefreshTokenAudience,和FlyLolo.JWT.API中的不一致,用於防止AccessToken和RefreshToken的混用。
再次訪問/api/token?code=002&pwd=222222,會返回兩個Token:
{"accessToken":{"tokenContent":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8y
MDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjAwMiIsImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUva
WRlbnRpdHkvY2xhaW1zL25hbWUiOiLmnY7lm5siLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW
1zL3JvbGUiOlsiVGVzdFB1dEJvb2tSb2xlIiwiVGVzdFB1dFN0dWRlbnRSb2xlIl0sIm5iZiI6MTU2NjgwNjQ3OSwiZXhwIjoxNTY2ODA4Mjc5LCJ
pc3MiOiJGbHlMb2xvIiwiYXVkIjoiVGVzdEF1ZGllbmNlIn0.wlMorS1V0xP0Fb2MDX7jI7zsgZbb2Do3u78BAkIIwGg",
"expires":"2019-08-26T22:31:19.5312172+08:00"},
"refreshToken":{"tokenContent":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8y
MDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjAwMiIsImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUva
WRlbnRpdHkvY2xhaW1zL25hbWUiOiLmnY7lm5siLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW
1zL3JvbGUiOlsiVGVzdFB1dEJvb2tSb2xlIiwiVGVzdFB1dFN0dWRlbnRSb2xlIl0sIm5iZiI6MTU2NjgwNjQ3OSwiZXhwIjoxNTY3NDExMjc5LCJ
pc3MiOiJGbHlMb2xvIiwiYXVkIjoiUmVmcmVzaFRva2VuQXVkaWVuY2UifQ.3EDi6cQBqa39-ywq2EjFGiM8W2KY5l9QAOWaIDi8FnI",
"expires":"2019-09-02T22:01:19.6143038+08:00"}}
可使用RefreshToken去請求新的AccessToken
測試用AccessToken能夠正常訪問FlyLolo.JWT.API,用RefreshToken則不能夠。
至此,Token的刷新功能改造完成。代碼地址:https://github.com/FlyLolo/JWT.Demo/releases/tag/1.1
疑問:RefreshToken有效期那麼長,被盜了怎麼辦,和直接將AccessToken的有效期延長有什麼區別?
我的認爲:1. RefreshToken不像AccessToken那樣在大多數請求中都被使用。2. 應用類的API較多,對應的服務(器)也可能較多,因此泄露的機率更大一些。