- 認證服務器:新建一個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的刷新方案。
五、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較多,對應的服務(器)也可能較多,所以泄露的概率更大一些。
轉:

