" 【.NET Core項目實戰 統一認證平臺】開篇及目錄索引 " 上篇文章介紹了基於 密碼授權模式,從使用場景、原理分析、自定義帳戶體系集成完整的介紹了密碼授權模式的內容,並最後給出了三個思考問題,本篇就針對第一個思考問題詳細的講解下 是如何生成access_token的,如何驗證access_t ...
【.NET Core項目實戰-統一認證平臺】開篇及目錄索引
上篇文章介紹了基於
Ids4
密碼授權模式,從使用場景、原理分析、自定義帳戶體系集成完整的介紹了密碼授權模式的內容,並最後給出了三個思考問題,本篇就針對第一個思考問題詳細的講解下Ids4
是如何生成access_token的,如何驗證access_token的有效性,最後我們使用.net webapi來實現一個外部介面(本來想用JAVA來實現的,奈何沒學好,就當拋磚引玉吧,有會JAVA的朋友根據我寫的案例使用JAVA來實現一個案例)。.netcore項目實戰交流群(637326624),有興趣的朋友可以在群里交流討論。
一、JWT簡介
什麼是JWT?
JSON Web Token (JWT)是一個開放標準(RFC 7519),它定義了一種緊湊的、自包含的方式,用於作為JSON對象在各方之間安全地傳輸信息。該信息可以被驗證和信任,因為它是數字簽名的。什麼時候使用JWT?
1)、認證,這是比較常見的使用場景,只要用戶登錄過一次系統,之後的請求都會包含簽名出來的token,通過token也可以用來實現單點登錄。
2)、交換信息,通過使用密鑰對來安全的傳送信息,可以知道發送者是誰、放置消息是否被篡改。
- JWT的結構是什麼樣的?
JSON Web Token由三部分組成,它們之間用圓點(.)連接。這三部分分別是:
- Header
- Payload
- Signature
Header
header典型的由兩部分組成:token的類型(“JWT”)和演算法名稱(比如:HMAC SHA256或者RSA等等)。
例如:
{
"alg": "RS256",
"typ": "JWT"
}
然後,用Base64對這個JSON編碼就得到JWT的第一部分
Payload
JWT的第二部分是payload,它包含聲明(要求)。聲明是關於實體(通常是用戶)和其他數據的聲明。聲明有三種類型: registered, public 和 private。
- Registered claims : 這裡有一組預定義的聲明,它們不是強制的,但是推薦。比如:iss (issuer), exp (expiration time), sub (subject), aud (audience)等。
- Public claims : 可以隨意定義。
- Private claims : 用於在同意使用它們的各方之間共用信息,並且不是註冊的或公開的聲明。
下麵是一個例子:
{
"nbf": 1545919058,
"exp": 1545922658,
"iss": "http://localhost:7777",
"aud": [
"http://localhost:7777/resources",
"mpc_gateway"
],
"client_id": "clienta",
"sub": "1",
"auth_time": 1545919058,
"idp": "local",
"nickname": "金焰的世界",
"email": "[email protected]",
"mobile": "13888888888",
"scope": [
"mpc_gateway",
"offline_access"
],
"amr": [
"pwd"
]
}
對payload進行Base64編碼就得到JWT的第二部分
註意,不要在JWT的payload或header中放置敏感信息,除非它們是加密的。
Signature
為了得到簽名部分,你必須有編碼過的header、編碼過的payload、一個秘鑰,簽名演算法是header中指定的 那個,然對它們簽名即可。
例如:HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
簽名是用於驗證消息在傳遞過程中有沒有被更改,並且,對於使用私鑰簽名的token,它還可以驗證JWT的發送方是否為它所稱的發送方。
二、IdentityServer4是如何生成jwt的?
在瞭解了JWT
的基本概念介紹後,我們要知道JWT
是如何生成的,加密的方式是什麼,我們如何使用自己的密鑰進行加密。
IdentityServer4的加密方式?
Ids4
目前使用的是RS256
非對稱方式,使用私鑰進行簽名,然後客戶端通過公鑰進行驗簽。可能有的人會問,我們在生成Ids4
時,也沒有配置證書,為什麼也可以運行起來呢?這裡就要講解證書的使用,以及Ids4
使用證書的加密流程。
1、載入證書
Ids4
預設使用臨時證書來進行token
的生成,使用代碼 .AddDeveloperSigningCredential()
,這裡會自動給生成tempkey.rsa
證書文件,所以項目如果使用預設配置的根目錄可以查看到此文件,實現代碼如下:
public static IIdentityServerBuilder AddDeveloperSigningCredential(this IIdentityServerBuilder builder, bool persistKey = true, string filename = null)
{
if (filename == null)
{
filename = Path.Combine(Directory.GetCurrentDirectory(), "tempkey.rsa");
}
if (File.Exists(filename))
{
var keyFile = File.ReadAllText(filename);
var tempKey = JsonConvert.DeserializeObject<TemporaryRsaKey>(keyFile, new JsonSerializerSettings { ContractResolver = new RsaKeyContractResolver() });
return builder.AddSigningCredential(CreateRsaSecurityKey(tempKey.Parameters, tempKey.KeyId));
}
else
{
var key = CreateRsaSecurityKey();
RSAParameters parameters;
if (key.Rsa != null)
parameters = key.Rsa.ExportParameters(includePrivateParameters: true);
else
parameters = key.Parameters;
var tempKey = new TemporaryRsaKey
{
Parameters = parameters,
KeyId = key.KeyId
};
if (persistKey)
{
File.WriteAllText(filename, JsonConvert.SerializeObject(tempKey, new JsonSerializerSettings { ContractResolver = new RsaKeyContractResolver() }));
}
return builder.AddSigningCredential(key);
}
}
這也就可以理解為什麼沒有配置證書也一樣可以使用了。
註意:在生產環境我們最好使用自己配置的證書。
如果我們已經有證書了,可以使用如下代碼實現,至於證書是如何生成的,網上資料很多,這裡就不介紹了。
.AddSigningCredential(new X509Certificate2(Path.Combine(basePath,"test.pfx"),"123456"));
然後註入證書相關信息,代碼如下:
builder.Services.AddSingleton<ISigningCredentialStore>(new DefaultSigningCredentialsStore(credential));
builder.Services.AddSingleton<IValidationKeysStore>(new DefaultValidationKeysStore(new[] { credential.Key }));
後面就可以在項目里使用證書的相關操作了,比如加密、驗簽等。
2、使用證書加密
上篇我介紹了密碼授權模式,詳細的講解了流程,當所有信息校驗通過,Claim
生成完成後,就開始生成token
了,核心代碼如下。
public virtual async Task<string> CreateTokenAsync(Token token)
{
var header = await CreateHeaderAsync(token);
var payload = await CreatePayloadAsync(token);
return await CreateJwtAsync(new JwtSecurityToken(header, payload));
}
//使用配置的證書生成JWT頭部
protected virtual async Task<JwtHeader> CreateHeaderAsync(Token token)
{
var credential = await Keys.GetSigningCredentialsAsync();
if (credential == null)
{
throw new InvalidOperationException("No signing credential is configured. Can't create JWT token");
}
var header = new JwtHeader(credential);
// emit x5t claim for backwards compatibility with v4 of MS JWT library
if (credential.Key is X509SecurityKey x509key)
{
var cert = x509key.Certificate;
if (Clock.UtcNow.UtcDateTime > cert.NotAfter)
{//如果證書過期提示
Logger.LogWarning("Certificate {subjectName} has expired on {expiration}", cert.Subject, cert.NotAfter.ToString(CultureInfo.InvariantCulture));
}
header["x5t"] = Base64Url.Encode(cert.GetCertHash());
}
return header;
}
//生成內容
public static JwtPayload CreateJwtPayload(this Token token, ISystemClock clock, ILogger logger)
{
var payload = new JwtPayload(
token.Issuer,
null,
null,
clock.UtcNow.UtcDateTime,
clock.UtcNow.UtcDateTime.AddSeconds(token.Lifetime));
foreach (var aud in token.Audiences)
{
payload.AddClaim(new Claim(JwtClaimTypes.Audience, aud));
}
var amrClaims = token.Claims.Where(x => x.Type == JwtClaimTypes.AuthenticationMethod);
var scopeClaims = token.Claims.Where(x => x.Type == JwtClaimTypes.Scope);
var jsonClaims = token.Claims.Where(x => x.ValueType == IdentityServerConstants.ClaimValueTypes.Json);
var normalClaims = token.Claims
.Except(amrClaims)
.Except(jsonClaims)
.Except(scopeClaims);
payload.AddClaims(normalClaims);
// scope claims
if (!scopeClaims.IsNullOrEmpty())
{
var scopeValues = scopeClaims.Select(x => x.Value).ToArray();
payload.Add(JwtClaimTypes.Scope, scopeValues);
}
// amr claims
if (!amrClaims.IsNullOrEmpty())
{
var amrValues = amrClaims.Select(x => x.Value).Distinct().ToArray();
payload.Add(JwtClaimTypes.AuthenticationMethod, amrValues);
}
// deal with json types
// calling ToArray() to trigger JSON parsing once and so later
// collection identity comparisons work for the anonymous type
try
{
var jsonTokens = jsonClaims.Select(x => new { x.Type, JsonValue = JRaw.Parse(x.Value) }).ToArray();
var jsonObjects = jsonTokens.Where(x => x.JsonValue.Type == JTokenType.Object).ToArray();
var jsonObjectGroups = jsonObjects.GroupBy(x => x.Type).ToArray();
foreach (var group in jsonObjectGroups)
{
if (payload.ContainsKey(group.Key))
{
throw new Exception(string.Format("Can't add two claims where one is a JSON object and the other is not a JSON object ({0})", group.Key));
}
if (group.Skip(1).Any())
{
// add as array
payload.Add(group.Key, group.Select(x => x.JsonValue).ToArray());
}
else
{
// add just one
payload.Add(group.Key, group.First().JsonValue);
}
}
var jsonArrays = jsonTokens.Where(x => x.JsonValue.Type == JTokenType.Array).ToArray();
var jsonArrayGroups = jsonArrays.GroupBy(x => x.Type).ToArray();
foreach (var group in jsonArrayGroups)
{
if (payload.ContainsKey(group.Key))
{
throw new Exception(string.Format("Can't add two claims where one is a JSON array and the other is not a JSON array ({0})", group.Key));
}
var newArr = new List<JToken>();
foreach (var arrays in group)
{
var arr = (JArray)arrays.JsonValue;
newArr.AddRange(arr);
}
// add just one array for the group/key/claim type
payload.Add(group.Key, newArr.ToArray());
}
var unsupportedJsonTokens = jsonTokens.Except(jsonObjects).Except(jsonArrays);
var unsupportedJsonClaimTypes = unsupportedJsonTokens.Select(x => x.Type).Distinct();
if (unsupportedJsonClaimTypes.Any())
{
throw new Exception(string.Format("Unsupported JSON type for claim types: {0}", unsupportedJsonClaimTypes.Aggregate((x, y) => x + ", " + y)));
}
return payload;
}
catch (Exception ex)
{
logger.LogCritical(ex, "Error creating a JSON valued claim");
throw;
}
}
//生成最終的Token
protected virtual Task<string> CreateJwtAsync(JwtSecurityToken jwt)
{
var handler = new JwtSecurityTokenHandler();
return Task.FromResult(handler.WriteToken(jwt));
}
知道了這些原理後,我們就能清楚的知道access_token
都放了那些東西,以及我們可以如何來驗證生成的Token
。
三、如何驗證access_token的有效性?
知道瞭如何生成後,最主要的目的還是要直接我們服務端是如何來保護介面安全的,為什麼服務端只要加入下代碼就能夠保護配置的資源呢?
services.AddAuthentication("Bearer")
.AddIdentityServerAuthentication(options =>
{
options.Authority ="http://localhost:7777";
options.RequireHttpsMetadata = false;
options.ApiName = "Api1";
options.SaveToken = true;
});
//啟用授權
app.UseAuthentication();
在理解這個前,我們需要瞭解系統做的驗證流程,這裡使用一張圖可以很好的理解流程了。
看完後是不是豁然開朗?這裡就可以很好的理解/.well-known/openid-configuration/jwks
原來就是證書的公鑰信息,是通過訪問/.well-known/openid-configuration
暴露給所有的客戶端使用,安全性是用過非對稱加密的原理保證,私鑰加密的信息,公鑰只能驗證,所以也不存在密鑰泄漏問題。
雖然只是短短的幾句代碼,就做了那麼多事情,這說明Ids4封裝的好,減少了我們很多編碼工作。這是有人會問,那如果我們的項目不是.netcore
的,那如何接入到網關呢?
網上有一個Python例子,用 Identity Server 4 (JWKS 端點和 RS256 演算法) 來保護 Python web api.
本來準備使用Java來實現,好久沒摸已經忘了怎麼寫了,留給會java的朋友實現吧,原理都是一樣。
下麵我就已webapi
為例來開發服務端介面,然後使用Ids4來保護介面內容。
新建一個webapi
項目,項目名稱Czar.AuthPlatform.WebApi
,為了讓輸出的結果為json
,我們需要在WebApiConfig
增加config.Formatters.Remove(config.Formatters.XmlFormatter);
代碼,然後修改預設的控制器ValuesController
,修改代碼如下。
[Ids4Auth("http://localhost:6611", "mpc_gateway")]
public IEnumerable<string> Get()
{
var Context = RequestContext.Principal;
return new string[] { "WebApi Values" };
}
為了保護api安全,我們需要增加一個身份驗證過濾器,實現代碼如下。
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using System.Web.Http.Controllers;
using System.Web.Http.Filters;
namespace Czar.AuthPlatform.WebApi
{
public class Ids4AuthAttribute : AuthorizationFilterAttribute
{
/// <summary>
/// 認證伺服器地址
/// </summary>
private string issUrl = "";
/// <summary>
/// 保護的API名稱
/// </summary>
private string apiName = "";
public Ids4AuthAttribute(string IssUrl,string ApiName)
{
issUrl = IssUrl;
apiName = ApiName;
}
/// <summary>
/// 重寫驗證方式
/// </summary>
/// <param name="actionContext"></param>
public override void OnAuthorization(HttpActionContext actionContext)
{
try
{
var access_token = actionContext.Request.Headers.Authorization?.Parameter; //獲取請求的access_token
if (String.IsNullOrEmpty(access_token))
{//401
actionContext.Response = actionContext.Request.CreateResponse(HttpStatusCode.Unauthorized);
actionContext.Response.Content = new StringContent("{\"errcode\":401,\"errmsg\":\"未授權\"}");
}
else
{//開始驗證請求的Token是否合法
//1、獲取公鑰
var httpclient = new HttpClient();
var jwtKey= httpclient.GetStringAsync(issUrl + "/.well-known/openid-configuration/jwks").Result;
//可以在此處緩存jwtkey,不用每次都獲取。
var Ids4keys = JsonConvert.DeserializeObject<Ids4Keys>(jwtKey);
var jwk = Ids4keys.keys;
var parameters = new TokenValidationParameters
{ //可以增加自定義的驗證項目
ValidIssuer = issUrl,
IssuerSigningKeys = jwk ,
ValidateLifetime = true,
ValidAudience = apiName
};
var handler = new JwtSecurityTokenHandler();
//2、使用公鑰校驗是否合法,如果驗證失敗會拋出異常
var id = handler.ValidateToken(access_token, parameters, out var _);
//請求的內容保存
actionContext.RequestContext.Principal = id;
}
}
catch(Exception ex)
{
actionContext.Response = actionContext.Request.CreateResponse(HttpStatusCode.Unauthorized);
actionContext.Response.Content = new StringContent("{\"errcode\":401,\"errmsg\":\"未授權\"}");
}
}
}
public class Ids4Keys
{
public JsonWebKey[] keys { get; set; }
}
}
代碼非常簡潔,就實現了基於Ids4的訪問控制,現在我們開始使用PostMan來測試介面地址。
我們直接請求介面地址,返回401未授權。
然後我使用Ids4
生成的access_token
再次測試,可以得到我們預期結果。
為了驗證是不是任何地方簽發的token
都可以通過驗證,我使用其他項目生成的access_token
來測試,發現提示的401未授權,可以達到我們預期結果。
現在就可以開心的使用我們熟悉的webapi
開發我們的介面了,需要驗證的地方增加類似[Ids4Auth("http://localhost:6611", "mpc_gateway")]
代碼即可。
使用其他語言實現的原理基本一致,就是公鑰來驗簽,只要通過驗證證明是允許訪問的請求,由於公鑰一直不變(除非認證伺服器更新了證書),所以我們請求到後可以緩存到本地,這樣驗簽時可以省去每次都獲取公鑰這步操作。
四、總結
本篇我們介紹了JWT
的基本原理和Ids4
的JWT
實現方式,然後使用.NET webapi
實現了使用Ids4
保護介面,其他語言實現方式一樣,這樣我們就可以把網關部署後,後端服務使用任何語言開發,然後接入到網關即可。
有了這些知識點,感覺是不是對Ids4
的理解更深入了呢?JWT
確實方便,但是有些特殊場景是我們希望Token
在有效期內通過人工配置的方式立即失效,如果按照現有Ids4
驗證方式是沒有辦法做到,那該如何實現呢?我將會在下一篇來介紹如何實現強制token
失效,敬請期待吧。