在現代Web應用程序中,通常會使用Web, WebApp, NativeApp等多種呈現方式,而后端也由以前的Razor渲染HTML,轉變為Stateless的RESTFulAPI,因此,我們需要一種標准的,通用的,無狀態的,與語言無關的認證方式,也就是本文要介紹的JwtBearer認證。
目錄
Bearer認證
HTTP提供了一套標准的身份驗證框架:服務器可以用來針對客戶端的請求發送質詢(challenge),客戶端根據質詢提供身份驗證憑證。質詢與應答的工作流程如下:服務器端向客戶端返回401(Unauthorized,未授權)狀態碼,並在WWW-Authenticate頭中添加如何進行驗證的信息,其中至少包含有一種質詢方式。然后客戶端可以在請求中添加Authorization頭進行驗證,其Value為身份驗證的憑證信息。
在HTTP標准驗證方案中,我們比較熟悉的是"Basic"和"Digest",前者將用戶名密碼使用BASE64編碼后作為驗證憑證,后者是Basic的升級版,更加安全,因為Basic是明文傳輸密碼信息,而Digest是加密后傳輸。在前文介紹的Cookie認證屬於Form認證,並不屬於HTTP標准驗證。
本文要介紹的Bearer驗證也屬於HTTP協議標准驗證,它隨着OAuth協議而開始流行,詳細定義見: RFC 6570。
A security token with the property that any party in possession of the token (a "bearer") can use the token in any way that any other party in possession of it can. Using a bearer token does not require a bearer to prove possession of cryptographic key material (proof-of-possession).
Bearer驗證中的憑證稱為BEARER_TOKEN
,或者是access_token
,它的頒發和驗證完全由我們自己的應用程序來控制,而不依賴於系統和Web服務器,Bearer驗證的標准請求方式如下:
Authorization: Bearer [BEARER_TOKEN]
那么使用Bearer驗證有什么好處呢?
-
CORS: cookies + CORS 並不能跨不同的域名。而Bearer驗證在任何域名下都可以使用HTTP header頭部來傳輸用戶信息。
-
對移動端友好: 當你在一個原生平台(iOS, Android, WindowsPhone等)時,使用Cookie驗證並不是一個好主意,因為你得和Cookie容器打交道,而使用Bearer驗證則簡單的多。
-
CSRF: 因為Bearer驗證不再依賴於cookies, 也就避免了跨站請求攻擊。
-
標准:在Cookie認證中,用戶未登錄時,返回一個
302
到登錄頁面,這在非瀏覽器情況下很難處理,而Bearer驗證則返回的是標准的401 challenge
。
JWT(JSON WEB TOKEN)
上面介紹的Bearer認證,其核心便是BEARER_TOKEN,而最流行的Token編碼方式便是:JSON WEB TOKEN。
Json web token (JWT), 是為了在網絡應用環境間傳遞聲明而執行的一種基於JSON的開放標准(RFC 7519)。該token被設計為緊湊且安全的,特別適用於分布式站點的單點登錄(SSO)場景。JWT的聲明一般被用來在身份提供者和服務提供者間傳遞被認證的用戶身份信息,以便於從資源服務器獲取資源,也可以增加一些額外的其它業務邏輯所必須的聲明信息,該token也可直接被用於認證,也可被加密。
JWT是由.
分割的如下三部分組成:
頭部(Header)
Header 一般由兩個部分組成:
- alg
- typ
alg
是是所使用的hash算法,如:HMAC SHA256或RSA,typ
是Token的類型,在這里就是:JWT。
{ "alg": "HS256", "typ": "JWT" }
然后使用Base64Url編碼成第一部分:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.<second part>.<third part>
載荷(Payload)
這一部分是JWT主要的信息存儲部分,其中包含了許多種的聲明(claims)。
Claims的實體一般包含用戶和一些元數據,這些claims分成三種類型:
-
reserved claims:預定義的 一些聲明,並不是強制的但是推薦,它們包括 iss (issuer), exp (expiration time), sub (subject),aud(audience) 等(這里都使用三個字母的原因是保證 JWT 的緊湊)。
-
public claims: 公有聲明,這個部分可以隨便定義,但是要注意和 IANA JSON Web Token 沖突。
-
private claims: 私有聲明,這個部分是共享被認定信息中自定義部分。
一個簡單的Pyload可以是這樣子的:
{ "sub": "1234567890", "name": "John Doe", "admin": true }
這部分同樣使用Base64Url編碼成第二部分:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.<third part>
簽名(Signature)
Signature是用來驗證發送者的JWT的同時也能確保在期間不被篡改。
在創建該部分時候你應該已經有了編碼后的Header和Payload,然后使用保存在服務端的秘鑰對其簽名,一個完整的JWT如下:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
因此使用JWT具有如下好處:
-
通用:因為json的通用性,所以JWT是可以進行跨語言支持的,像JAVA,JavaScript,NodeJS,PHP等很多語言都可以使用。
-
緊湊:JWT的構成非常簡單,字節占用很小,可以通過 GET、POST 等放在 HTTP 的 header 中,非常便於傳輸。
-
擴展:JWT是自我包涵的,包含了必要的所有信息,不需要在服務端保存會話信息, 非常易於應用的擴展。
關於更多JWT的介紹,網上非常多,這里就不再多做介紹。下面,演示一下 ASP.NET Core 中 JwtBearer 認證的使用方式。
示例
模擬Token
ASP.NET Core 內置的JwtBearer驗證,並不包含Token的發放,我們先模擬一個簡單的實現:
[HttpPost("authenticate")] public IActionResult Authenticate([FromBody]UserDto userDto) { var user = _store.FindUser(userDto.UserName, userDto.Password); if (user == null) return Unauthorized(); var tokenHandler = new JwtSecurityTokenHandler(); var key = Encoding.ASCII.GetBytes(Consts.Secret); var authTime = DateTime.UtcNow; var expiresAt = authTime.AddDays(7); var tokenDescriptor = new SecurityTokenDescriptor { Subject = new ClaimsIdentity(new Claim[] { new Claim(JwtClaimTypes.Audience,"api"), new Claim(JwtClaimTypes.Issuer,"http://localhost:5200"), new Claim(JwtClaimTypes.Id, user.Id.ToString()), new Claim(JwtClaimTypes.Name, user.Name), new Claim(JwtClaimTypes.Email, user.Email), new Claim(JwtClaimTypes.PhoneNumber, user.PhoneNumber) }), Expires = expiresAt, SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) }; var token = tokenHandler.CreateToken(tokenDescriptor); var tokenString = tokenHandler.WriteToken(token); return Ok(new { access_token = tokenString, token_type = "Bearer", profile = new { sid = user.Id, name = user.Name, auth_time = new DateTimeOffset(authTime).ToUnixTimeSeconds(), expires_at = new DateTimeOffset(expiresAt).ToUnixTimeSeconds() } }); }
如上,使用微軟提供的Microsoft.IdentityModel.Tokens幫助類(源碼地址:azure-activedirectory-identitymodel-extensions-for-dotnet),可以很容易的創建出JwtToen,就不再多說。
注冊JwtBearer認證
首先添加JwtBearer
包引用:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 2.0.0
然后在Startup
類中添加如下配置:
public void ConfigureServices(IServiceCollection services) { services.AddAuthentication(x => { x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(o => { o.TokenValidationParameters = new TokenValidationParameters { NameClaimType = JwtClaimTypes.Name, RoleClaimType = JwtClaimTypes.Role, // 將下面兩個參數設置為false,可以不驗證Issuer和Audience,但是不建議這樣做。 //ValidateIssuer = false, // 默認為true //ValidateAudience = false, // 默認為true ValidIssuer = "http://localhost:5200", ValidAudience = "api", IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(Consts.Secret)) }; }); } public void Configure(IApplicationBuilder app) { app.UseAuthentication(); }
在JwtBearerOptions
的配置中,通常IssuerSigningKey(簽名秘鑰)
, ValidIssuer(Token頒發機構)
, ValidAudience(頒發給誰)
三個參數是必須的,后兩者用於與TokenClaims中的Issuer
和Audience
進行對比,不一致則驗證失敗(與上面發放Token中的Claims對應)。
而NameClaimType
和RoleClaimType
需與Token中的ClaimType一致,在IdentityServer中也是使用的JwtClaimTypes
,否則會造成User.Identity.Name
為空等問題。
添加受保護資源
創建一個需要授權的控制器,直接使用Authorize
即可:
[Authorize]
[Route("api/[controller]")] public class SampleDataController : Controller { [HttpGet("[action]")] public IEnumerable<WeatherForecast> WeatherForecasts() { return ... } }
運行
最后運行,直接訪問/api/SampleData/WeatherForecasts
,將返回一個401
:
HTTP/1.1 401 Unauthorized
Server: Kestrel
Content-Length: 0
WWW-Authenticate: Bearer
讓我們調用api/oauth/authenticate
,獲取一個JWT:
請求:
POST http://localhost:5200/api/oauth/authenticate HTTP/1.1
content-type: application/json
{
"username": "alice",
"password": "alice"
}
響應:
HTTP/1.1 200 OK
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJuYW1lIjoiYWxpY2UiLCJlbWFpbCI6ImFsaWNlQGdtYWlsLmNvbSIsInBob25lX251bWJlciI6IjE4ODAwMDAwMDAxIiwibmJmIjoxNTA5NDY0MzQwLCJleHAiOjE1MTAwNjkxNDAsImlhdCI6MTUwOTQ2NDM0MH0.Y1TDz8KjLRh_vjQ_3iYP4oJw-fmhoboiAGPqIZ-ooNc","token_type":"Bearer","profile":{"sid":1,"name":"alice","auth_time":1509464340,"expires_at":1510069140}}
最后使用該Token,再次調用受保護資源:
GET http://localhost:5200/api/SampleData/WeatherForecasts HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJuYW1lIjoiYWxpY2UiLCJlbWFpbCI6ImFsaWNlQGdtYWlsLmNvbSIsInBob25lX251bWJlciI6IjE4ODAwMDAwMDAxIiwibmJmIjoxNTA5NDY0MzQwLCJleHAiOjE1MTAwNjkxNDAsImlhdCI6MTUwOTQ2NDM0MH0.Y1TDz8KjLRh_vjQ_3iYP4oJw-fmhoboiAGPqIZ-ooNc
授權成功,返回了預期的數據:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
[{"dateFormatted":"2017/11/3","temperatureC":35,"summary":"Chilly","temperatureF":94}]
擴展
自定義Token獲取方式
JwtBearer認證中,默認是通過Http的Authorization
頭來獲取的,這也是最推薦的做法,但是在某些場景下,我們可能會使用Url或者是Cookie來傳遞Token,那要怎么來實現呢?
其實實現起來非常簡單,如前幾章介紹的一樣,JwtBearer也在認證的各個階段為我們提供了事件,來執行我們的自定義邏輯:
.AddJwtBearer(o =>
{
o.Events = new JwtBearerEvents() { OnMessageReceived = context => { context.Token = context.Request.Query["access_token"]; return Task.CompletedTask; } }; o.TokenValidationParameters = new TokenValidationParameters { ... };
然后在Url中添加access_token=[token]
,直接在瀏覽器中訪問:
同樣的,我們也可以很容易的在Cookie中讀取Token,就不再演示。
除了OnMessageReceived
外,還提供了如下幾個事件:
-
TokenValidated:在Token驗證通過后調用。
-
AuthenticationFailed: 認證失敗時調用。
-
Challenge: 未授權時調用。
使用OIDC服務
在上面的示例中,我們簡單模擬的Token頒發,功能非常簡單,並不適合在生產環境中使用,可是微軟也沒有提供OIDC服務的實現,好在.NET社區中提供了幾種實現,可供我們選擇:
Name | Description |
---|---|
AspNet.Security.OpenIdConnect.Server (ASOS) | Low-level/protocol-first OpenID Connect server framework for ASP.NET Core and OWIN/Katana |
IdentityServer4 | OpenID Connect and OAuth 2.0 framework for ASP.NET Core - officially certified by the OpenID Foundation and under governance of the .NET Foundation |
OpenIddict | Easy-to-use OpenID Connect server for ASP.NET Core |
PwdLess | Simple, stateless, passwordless authentication for ASP.NET Core |
我們在這里使用IdentityServer4來搭建一個OIDC服務器,並添加如下配置:
/********************OIDC服務器代碼片段********************/ public void ConfigureServices(IServiceCollection services) { services.AddMvc(); // 配置IdentitryServer services.AddIdentityServer() .AddInMemoryPersistedGrants() .AddInMemoryApiResources(Config.GetApis()) .AddInMemoryIdentityResources(Config.GetIdentityResources()) .AddInMemoryClients(Config.GetClients()) .AddTestUsers(Config.GetUsers()) .AddDeveloperSigningCredential(); } new Client { ClientId = "jwt.implicit", ClientName = "Implicit Client (Web)", AllowedGrantTypes = GrantTypes.Implicit, AllowAccessTokensViaBrowser = true, RedirectUris = { "http://localhost:5200/callback" }, PostLogoutRedirectUris = { "http://localhost:5200/home" }, AllowedCorsOrigins = { "http://localhost:5200" }, AllowedScopes = { "openid", "profile", "email", "api" }, }
而JwtBearer客戶端的配置就更加簡單了,因為OIDC具有配置發現的功能:
public void ConfigureServices(IServiceCollection services) { services.AddAuthentication(x => { x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(o => { o.Authority = "https://oidc.faasx.com/"; o.Audience = "api"; o.TokenValidationParameters = new TokenValidationParameters { NameClaimType = JwtClaimTypes.Name, RoleClaimType = JwtClaimTypes.Role, }; }); }
如上,最重要的是Authority
參數,用來表示OIDC服務的地址,然后便可以自動發現Issuer
, IssuerSigningKey
等配置,而o.Audience
與o.TokenValidationParameters = new TokenValidationParameters { ValidAudience = "api" }
是等效的,后面分析源碼時會介紹。
OIDC兼容OAuth2協議,我們可以使用上一章介紹的授權碼模式來獲取Token,也可以直接用戶名密碼模式來獲取Token:
請求:
POST https://oidc.faasx.com/connect/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
client_id=client.rop&client_secret=secret&grant_type=password&scope=api&username=alice&password=alice
響應:
HTTP/1.1 200 OK
Content-Type: application/json
{"access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6IjdlYzk5MjVlMmUzMTA2NmY2ZmU2ODgzMDRhZjU1ZmM0IiwidHlwIjoiSldUIn0.eyJuYmYiOjE1MDk2NzI1NjksImV4cCI6MTUwOTY3NjE2OSwiaXNzIjoiaHR0cHM6Ly9vaWRjLmZhYXN4LmNvbSIsImF1ZCI6WyJodHRwczovL29pZGMuZmFhc3guY29tL3Jlc291cmNlcyIsImFwaSJdLCJjbGllbnRfaWQiOiJjbGllbnQucm9wIiwic3ViIjoiMDAxIiwiYXV0aF90aW1lIjoxNTA5NjcyNTY5LCJpZHAiOiJsb2NhbCIsIm5hbWUiOiJBbGljZSBTbWl0aCIsImVtYWlsIjoiQWxpY2VTbWl0aEBlbWFpbC5jb20iLCJzY29wZSI6WyJhcGkiXSwiYW1yIjpbInB3ZCJdfQ.PM93LThOZA3lkgPFVwieqGQQQtgmYDCY0oSFVmudv1hpKO6UaaZsmnn4ci9QjbGl5g2433JkDks5UIZsZ0xE62Qqq8PicPBBuaNoYrCf6dxR7j-0uZcoa7-FCKGu-0TrM8OL-NuMvN6_KEpbWa3jlkwibCK9YDIwJZilVoWUOrbbIEsKTa-DdLScmzHLUzksT8GBr0PAVhge9PRFiGqg8cgMLjsA62ZeDsR35f55BucSV5Pj0SAj26anYvrBNTHKOF7ze1DGW51Dbz6DRu1X7uEIxSzWiNi4cRVJ6Totjkwk5F78R9R38o_mYEdehZBjRHFe6zLd91hXcCKqOEh5eQ","expires_in":3600,"token_type":"Bearer"}
我在本章的示例代碼中,使用前端Angular框架演示了如何從本地登錄獲取Tokek或使用簡化模式(implicit)從OIDC服務器獲取Token,然后保存到sesstionStorage,在發送請求時附加到請求頭中的示例,可供大家參考:JwtBearerSample。
源碼探索
JwtBearerPostConfigureOptions
在ASP.NET Core 2.0 Options框架中,新增了一種PostConfigure模式,用來在我們所注冊的Options配置執行完之后,再對Options做一些修改。
JwtBearerPostConfigureOptions用來實現配置發現:
public class JwtBearerPostConfigureOptions : IPostConfigureOptions<JwtBearerOptions> { public void PostConfigure(string name, JwtBearerOptions options) { // 如果未設置options.TokenValidationParameters.ValidAudience,則使用options.Audience if (string.IsNullOrEmpty(options.TokenValidationParameters.ValidAudience) && !string.IsNullOrEmpty(options.Audience)) { options.TokenValidationParameters.ValidAudience = options.Audience; } if (options.ConfigurationManager == null) { // 如果未設置MetadataAddress,則使用options.Authority+.well-known/openid-configuration .... options.ConfigurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(options.MetadataAddress, new OpenIdConnectConfigurationRetriever(), new HttpDocumentRetriever(httpClient) { RequireHttps = options.RequireHttpsMetadata }); } } } }
JwtBearerHandler
JwtBearerHandler相對於前幾章介紹的CookieHandler, OpenIdConnectHandler等,都簡單的多。
首先便是從請求中獲取Token:
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() { var messageReceivedContext = new MessageReceivedContext(Context, Scheme, Options); // 先觸發MessageReceived事件,來獲取Token await Events.MessageReceived(messageReceivedContext); if (messageReceivedContext.Result != null) { return messageReceivedContext.Result; } token = messageReceivedContext.Token; // Token為空時,從Authorization頭中獲取 if (string.IsNullOrEmpty(token)) { string authorization = Request.Headers["Authorization"]; if (string.IsNullOrEmpty(authorization)) { return AuthenticateResult.NoResult(); } if (authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { token = authorization.Substring("Bearer ".Length).Trim(); } if (string.IsNullOrEmpty(token)) { return AuthenticateResult.NoResult(); } } ... }
然后初始化TokenValidationParameters
參數,為Token驗證做准備:
if (_configuration == null && Options.ConfigurationManager != null) { _configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted); } var validationParameters = Options.TokenValidationParameters.Clone(); if (_configuration != null) { var issuers = new[] { _configuration.Issuer }; validationParameters.ValidIssuers = validationParameters.ValidIssuers?.Concat(issuers) ?? issuers; validationParameters.IssuerSigningKeys = validationParameters.IssuerSigningKeys?.Concat(_configuration.SigningKeys) ?? _configuration.SigningKeys; }
可以看到,從OIDC服務器提供的配置發現中,獲取ValidIssuers
和IssuerSigningKeys
。
最后對Token進行驗證:
// Options.SecurityTokenValidators 默認為: new List<ISecurityTokenValidator> { new JwtSecurityTokenHandler() } foreach (var validator in Options.SecurityTokenValidators) { if (validator.CanReadToken(token)) { ClaimsPrincipal principal; try { principal = validator.ValidateToken(token, validationParameters, out validatedToken); } catch (Exception ex) { // RefreshOnIssuerKeyNotFound默認為True, 在SignatureKey未找到時,重新從OIDC服務器獲取 if (Options.RefreshOnIssuerKeyNotFound && Options.ConfigurationManager != null && ex is SecurityTokenSignatureKeyNotFoundException) { Options.ConfigurationManager.RequestRefresh(); } continue; } ... // 觸發TokenValidated事件 await Events.TokenValidated(tokenValidatedContext); // 默認為true,保存Token到`AuthenticationProperties`中,可以通過`context.AuthenticateAsync()`來獲取,在我們需要在服務端使用用戶Token調用其他資源是非常有用。 if (Options.SaveToken) { tokenValidatedContext.Properties.StoreTokens(new[] { new AuthenticationToken { Name = "access_token", Value = token } }); } // 驗證成功 tokenValidatedContext.Success(); return tokenValidatedContext.Result; } }
其核心的驗證也是在Microsoft.IdentityModel.Tokens
中,就不在深究。
當使用JwtBearer認證時,我們肯定不希望在未登錄時返回一個302
,因此在前面的示例中,我們配置了x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
,對應的,會執行JwtBearerHandler的HandleChallengeAsync
方法:
protected override async Task HandleChallengeAsync(AuthenticationProperties properties) { var authResult = await HandleAuthenticateOnceSafeAsync(); var eventContext = new JwtBearerChallengeContext(Context, Scheme, Options, properties) { AuthenticateFailure = authResult?.Failure }; if (Options.IncludeErrorDetails && eventContext.AuthenticateFailure != null) { eventContext.Error = "invalid_token"; eventContext.ErrorDescription = CreateErrorDescription(eventContext.AuthenticateFailure); } await Events.Challenge(eventContext); if (eventContext.Handled) { return; } Response.StatusCode = 401; // 最終將相應報文拼接成如下: // https://tools.ietf.org/html/rfc6750#section-3.1 // WWW-Authenticate: Bearer realm="example", error="invalid_token", error_description="The access token expired" }
ASP.NET Core JwtBearer認證的完整源碼地址:Microsoft.AspNetCore.Authentication.JwtBearer。
總結
JwtToken其實與Cookie認證中加密后的Cookie值很像,他們都是基於Claim的,認證時無需STS(Security token service)的參與,這在分布式環境下提供了極大的便利。而他們的本質上的區別是:Cookie是微軟式的,很難與其他語言集成,而JwtToken則是開放再開放,與平台,語言無關,在前端也可以直接解析出Claims。
PS: 在使用在Bearer認證時,通常還需與刷新Token配合來使用,因為JwtToken的驗證是無需經過STS的,而當用戶執行了退出,修改密碼等操作時,是無法使該Token失效的。所以,通常會給access_token
設置一個較短的有效期(JwtBearer認證默認會驗證有效期,通過notBefore
和expires
來驗證),當access_token
過期后,可以在用戶無感知的情況下,使用refresh_token
自動從STS重新獲取access_token
,但這就不屬於Bearer認證的范疇了,在后續介紹IdentityServer時再來詳細介紹一下。