接上一篇:IdentityServer4實現OAuth2.0四種模式之授權碼模式
前面寫的四種OAuth2.0實現模式只涉及到IdentityServer4的OAuth2.0特性,並沒有涉及到OenId方面的。OpenIdConnect是OAuth2.0與OpenId的結合,並加入了一個重要的概念:id_token。我們之前所講的token是用於訪問授權的access_token,而id_token是用於身份驗證的,作用完全不同,這一點要區分開來。access_token是OAth2.0特性,而id_token是OpenIdConnect方案為改善OAuth2.0方案在身份驗證方面的薄弱而加入的特性。
客戶端獲取Id_token與隱藏模式和授權碼模式一樣,都是通過redirect_url參數返回的,所以前面的四種模式中的客戶端模式與密碼模式不支持獲取id_token,而授權碼模式受限於流程,必需先取得Code才能取到token,所以不能直接支持獲取id_token,如果需求是使用授權碼模式,同時又需要id_token,OpenIdConnect支持第五種模式:混合模式(Hybrid),就是基於隱藏模式與授權碼模式的結合。
一,IdentityServer服務配置
1)添加IdentityResouces
之前的OAuth2.0四種模式已經接觸過ApiResouces,ApiResources作用是用於標志Api接口域,與Client配合決定了一個access_token所能訪問的api區間,並且允許隨access_token攜帶一些指定的用戶Claim。IdentityResources是用於決定了一個id_token可以攜帶那些用戶的身份信息(Claim),其中,如果要從IdentityServer取得id_token,名為"openid"的IdentityResource是必需的
- 使用IdentityServer4定義好的IdentityResource
IdentityServer.Config.GetIdentityResources
public static IEnumerable<IdentityResource> GetIdentityResources()
{
return new IdentityResource[]
{
new IdentityResources.OpenId()
};
}
IdentityResources.OpenId的實現源碼如下
public OpenId()
{
Name = IdentityServerConstants.StandardScopes.OpenId;
DisplayName = "Your user identifier";
Required = true;
UserClaims.Add(JwtClaimTypes.Subject);
}
實際上new IdentityResources.OpenId()等效於:
new IdentityResource(IdentityServerConstants.StandardScopes.OpenId,"Your user identifier",new List<string>(){JwtClaimTypes.Subject})
或者
new IdentityResource("openid","Your user identifier",new List<string>(){ "sub"})
所用的重載方法說明最后一個參數List<string>決定了這個IdentityResource攜帶的Claim,“sub”是Client中定義的Subject屬性。
// 摘要:
// Initializes a new instance of the IdentityServer4.Models.IdentityResource class.
//
// 參數:
// name:
// The name.
//
// displayName:
// The display name.
//
// claimTypes:
// The claim types.
//
// 異常:
// T:System.ArgumentNullException:
// name
//
// T:System.ArgumentException:
// Must provide at least one claim type - claimTypes
public IdentityResource(string name, string displayName, IEnumerable<string> claimTypes);
IdentityServer4預定義了OenId,Profile,Email,Phone,Addrss這5個IdentityResource,其中Profile是比較重要的,他默認可攜帶包括用戶的名字、生日、個人網站等信息。Profile映射的Claim的源碼如下
{ IdentityServerConstants.StandardScopes.Profile, new[]
{
JwtClaimTypes.Name,
JwtClaimTypes.FamilyName,
JwtClaimTypes.GivenName,
JwtClaimTypes.MiddleName,
JwtClaimTypes.NickName,
JwtClaimTypes.PreferredUserName,
JwtClaimTypes.Profile,
JwtClaimTypes.Picture,
JwtClaimTypes.WebSite,
JwtClaimTypes.Gender,
JwtClaimTypes.BirthDate,
JwtClaimTypes.ZoneInfo,
JwtClaimTypes.Locale,
JwtClaimTypes.UpdatedAt
}},
- 添加自定義的IdentityResource
盡管IdentityServer定義好了這么多IdentityResource,但肯定不能包含所有用戶信息。比如我需要在id_token中攜帶用戶手機型號和用戶手機價格二個Claim。可以這樣自定義一個IdentityResource。
new IdentityResource("PhoneModel","User's phone Model",new List<string>(){ "phonemodel","phoneprise"})
2)將IdentityResource添加到IdentityServer
IdentityServer.StartUp
var builder = services.AddIdentityServer()
//身份信息資源
.AddInMemoryIdentityResources(Config.GetIdentityResources())
//API授權資源
.AddInMemoryApiResources(Config.GetApis())
//客戶端
.AddInMemoryClients(Config.GetClients())
//添加用戶
.AddTestUsers(Config.GetUsers());
3)配置用戶的Claim信息
new TestUser()
{
//用戶名
Username="apiUser",
//密碼
Password="apiUserPassword",
//用戶Id
SubjectId="0",
Claims=new List<Claim>(){
new Claim(ClaimTypes.Role,"admin"),
new Claim(ClaimTypes.Name,"apiUser"),
new Claim("prog","正式項目"),
new Claim("phonemodel","huawei"),
new Claim("phoneprise","5000元"),
}
},
4)配置隱藏模式客戶端允許訪問該IdentityResource
在前邊的四種模式中只有隱藏模式支持直接獲取id_token
new Client()
{
//客戶端Id
ClientId="apiClientImpl",
ClientName="ApiClient for Implicit",
//客戶端授權類型,Implicit:隱藏模式
AllowedGrantTypes=GrantTypes.Implicit,
//允許登錄后重定向的地址列表,可以有多個
RedirectUris = {"https://localhost:5002/auth.html"},
//允許訪問的資源
AllowedScopes={
"secretapi",
IdentityServerConstants.StandardScopes.OpenId,
"PhoneModel"
},
//允許將token通過瀏覽器傳遞
AllowAccessTokensViaBrowser=true,
//允許ID_TOKEN附帶Claims
AlwaysIncludeUserClaimsInIdToken=true
}
5)添加混合模式客戶端並配置AllowedSopes
new Client()
{
AlwaysIncludeUserClaimsInIdToken=true,
//客戶端Id
ClientId="apiClientHybrid",
ClientName="ApiClient for HyBrid",
//客戶端密碼
ClientSecrets={new Secret("apiSecret".Sha256()) },
//客戶端授權類型,Hybrid:混合模式
AllowedGrantTypes=GrantTypes.Hybrid,
//允許登錄后重定向的地址列表,可以有多個
RedirectUris = {"https://localhost:5002/auth.html"},
//允許訪問的資源
//允許訪問的資源
AllowedScopes={
"secretapi",
IdentityServerConstants.StandardScopes.OpenId,
"PhoneModel"
},
AllowOfflineAccess = true,
AllowAccessTokensViaBrowser=true
}
二,隱藏模式獲取id_token
先來回顧一下隱藏模式怎么請求access_token的
根據OAuth2.0協議,隱藏模式獲取access_token需要傳的參數如下所示。
client_id:客戶端Id redirect_uri=重定向Url,用戶登錄成功后跳回此地址 response_type=token,固定值,表示獲取token scope=secretapi,此token需要訪問的api
接受參數的地址則是IdentityServer的Discover文檔中的authorization_endpoint節點。把參數和地址拼接成以下地址:http://localhost:5000/connect/authorize?client_id=apiClientImpl&redirect_uri=https://localhost:5002/auth.html&response_type=token&scope=secretapi,直接訪問,會跳轉到用戶登錄頁面, 確認后,瀏覽器將會自動跳轉到redirect_url。
獲取id_token也是同樣的方法,但要注意以下四點
- response_type:隱藏模式支持三種response_type,上面獲取access_token已經使用了一種,第二種是獲取id_token:id_token。第三種是同時獲取access_token和id_token:token id_token
- scope:上面的scope值"scretapi"是一個ApiResource,我們要獲取Id_token,必需加入"openid",這是一個IdentityResource。其它的profile,email等按需添加。
- 除開上面的四個參數外,還需要添加一個參數:nonce。這個參數作用是協助你驗證這個id_token是否由你自己發出的,可以是一個隨機值,也可以是你自己的請求特征加密字符串,會隨id_token一並返回供你驗證。
- 可以選擇性添加一個參數:response_mode。這個參數的作用是指定id_token傳到redirect_Url的方法。支持三種方法:
1,query,用於獲取授權碼,通過url的Query部份傳遞。如(http://redirect_url.com?code=)。支持授權碼模式客戶端
2,fragment。和隱藏模式獲取access_token一樣,通過url的fragment部份傳遞,如(http://redirect_url.com#token=&id_token=)。支持隱藏模式和混合模式客戶端
3,form_post模式,通過form表單(x-www-form-urlencoded)Post到指定url。支持混合模式客戶端
根據這四點注意事項,請求url就變成了這樣
http://localhost:5000/connect/authorize?client_id=apiClientImpl&redirect_uri=https://localhost:5002/auth.html&response_type=token%20id_token&scope=secretapi%20openid%20PhoneModel&nonce=123&response_model=fragment
使用之前創建apiUser登錄成功后出現如下授權界面

三個紅色的方框代表請求的三個scope。
同意授權后,將會跳轉回redirect_url,id_token和access_token都獲取到了

在jwt.io中解析一下這個id_token
{
"nbf": 1569059940,
"exp": 1569060240,
"iss": "http://localhost:5000",
"aud": "apiClientImpl",
"nonce": "123",
"iat": 1569059940,
"at_hash": "PJZyIPRkonv7BWTF42asJw",
"sid": "4b2901045d883a8ba7cf6169b976a113",
"sub": "0",
"auth_time": 1569059940,
"idp": "local",
"phonemodel": "huawei",
"phoneprise": "5000元",
"amr": [
"pwd"
]
}
jwt.io中基本支持所有平台對jwt格式的解析和驗證,詳見https://jwt.io/
三,混合模式獲取id_token
1,使用fragment方式
混合模式獲取Id_token與隱藏模式獲取id_token大體相同,只有以下二點要注意
- 把client_id改成第一步創建的混合模式客戶端id
- 隱藏模式支持三種response_type:token、id_token、token id_token,分別用於請求access_token,id_token以及同時請求二者。而混合模式支持四種:code,code token,code id_token,code token id_token。可用於請求code,id_token,access_token以及同時請求三者。
根據這兩點,混合模式的請求url變成了
http://localhost:5000/connect/authorize?client_id=apiClientHybrid&redirect_uri=https://localhost:5002/auth.html&response_type=code token id_token&scope=secretapi openid PhoneModel&nonce=123&response_mode=fragment
用戶登錄並授權后重定向到redirect_url

可以看到code,id_token,access_code都返回了。拿到code后可以根據code去獲取access_token用於訪問被保護的api,參考之前的文章:IdentityServer4 實現OAuth2.0授權碼模式。也可以直接拿返回的acess_token用,直接返回的access_token由於是和隱藏模式一樣以url參數帶過來的,為安全考慮,這個access_code的有效時間很段,默認是一個小時。
2,使用form_post方式
先在IdentityMvc項目新建一個Mvc控制器,用於接收post數據請求。
TokenData類用於包裝從IdentityServer處Post回來的token數據
public class TokenData
{
public string code { get; set; }
public string id_token { get; set; }
public string access_token { get; set; }
public string token_type { get; set; }
public string expires_in { get; set; }
public string scope { get; set; }
public string session_state { get; set; }
}
HomeController.GetTokenData,用於identityserver的redirect_url
[HttpPost]
public IActionResult GetTokenData(TokenData data)
{
return new JsonResult(data);
}
建好控制器后,把該控制器的訪問路徑添加IdentityServer的混合模式客戶端的RedirectUris
new Client()
{
AlwaysIncludeUserClaimsInIdToken=true,
//客戶端Id
ClientId="apiClientHybrid",
ClientName="ApiClient for HyBrid",
//客戶端密碼
ClientSecrets={new Secret("apiSecret".Sha256()) },
//客戶端授權類型,Hybrid:混合模式
AllowedGrantTypes=GrantTypes.Hybrid,
//允許登錄后重定向的地址列表,可以有多個
RedirectUris = {"https://localhost:5002/auth.html","https://localhost:5002/home/gettokendata"},
//允許訪問的資源
//允許訪問的資源
AllowedScopes={
"secretapi",
IdentityServerConstants.StandardScopes.OpenId,
"PhoneModel"
},
AllowOfflineAccess = true,
AllowAccessTokensViaBrowser=true
}
構造請求url。把response_mode設置為form_post,把redirect_url設置為控制器路徑
http://localhost:5000/connect/authorize?client_id=apiClientHybrid&redirect_uri=https://localhost:5002/home/getTokenData&response_type=code token id_token&scope=secretapi openid PhoneModel&nonce=123&response_mode=form_post

四,id_token應用:單點登錄
id_token包含了用戶在openid和用戶基本信息,這表明了該用戶是有來源的,不是黑戶口,如果op(openid provider)值得依賴,第三方客戶端完全可以通過解析id_token獲取用戶信息允許用戶登錄,而不需要用戶重新注冊賬戶,重新登錄。
對於web應用來說,實現思路一般是這樣的:用戶打開頁面后,先在Cookie里查詢有沒有id_token信息,如果有,驗證該id_token,驗證成功則允許訪問,驗證失敗或者Cookie里沒有存儲id_token則去op請求id_token,用戶在op登錄成功並授權后,op返回id_token到第三方應用后,第三方應用把id_token存儲到cookie里,用戶下次再打開頁面就走重新驗證id_token過程。如何驗證id_token,請參考https://jwt.io/
按時這個思路,可以在任何平台實現單點登錄。如果你用的是asp.net core Mvc平台,微軟已經把一切都用中間件封裝好了,只需要幾行簡單的配置代碼。
測試步驟:
1,由於asp.net core的OpenIdConnect驗證方案默認會添加"openid"以及"profile"兩個IdentityResource的請求權限,所以需要在IdentityServer添加這兩個IdentityResource
IdentityServer.Config
public static IEnumerable<IdentityResource> GetIdentityResources()
{
return new IdentityResource[]
{
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
new IdentityResource("PhoneModel","User's phone Model",new List<string>(){ "phonemodel","phoneprise"})
};
}
把這兩個請求權限授權Client
IdentityServer.Config.GetClients
AllowedScopes={
"secretapi",
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
"PhoneModel"
}
2,由asp.net core的OpenIdConnect驗證方案默認的登錄重定向地址為signin-oidc,所以把這個地址加入到Client的RedirectUris
RedirectUris = {"https://localhost:5002/auth.html","https://localhost:5002/home/gettokendata","https://localhost:5002/signin-oidc"},
附asp.net core OpenIdConnect驗證方案默認設置源碼,有幾個地方需要注意:
CallbackPath:用於登錄重定向地址
SingedOutCallbackPath:注銷登錄回調地址,后邊我們加入注銷功能時會用到這個地址
RemoteSignOutPath:注銷登錄重定向地址
Scope.Add("openid")和Scope.Add("profile")解釋了上面第一步為什么要添加這兩個IdentityResource。
Microsoft.AspNetCore.Authentication.OpenIdConnect
public OpenIdConnectOptions()
{
CallbackPath = new PathString("/signin-oidc");
SignedOutCallbackPath = new PathString("/signout-callback-oidc");
RemoteSignOutPath = new PathString("/signout-oidc");
Events = new OpenIdConnectEvents();
Scope.Add("openid");
Scope.Add("profile");
ClaimActions.DeleteClaim("nonce");
ClaimActions.DeleteClaim("aud");
ClaimActions.DeleteClaim("azp");
ClaimActions.DeleteClaim("acr");
ClaimActions.DeleteClaim("iss");
ClaimActions.DeleteClaim("iat");
ClaimActions.DeleteClaim("nbf");
ClaimActions.DeleteClaim("exp");
ClaimActions.DeleteClaim("at_hash");
ClaimActions.DeleteClaim("c_hash");
ClaimActions.DeleteClaim("ipaddr");
ClaimActions.DeleteClaim("platf");
ClaimActions.DeleteClaim("ver");
// http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
ClaimActions.MapUniqueJsonKey("sub", "sub");
ClaimActions.MapUniqueJsonKey("name", "name");
ClaimActions.MapUniqueJsonKey("given_name", "given_name");
ClaimActions.MapUniqueJsonKey("family_name", "family_name");
ClaimActions.MapUniqueJsonKey("profile", "profile");
ClaimActions.MapUniqueJsonKey("email", "email");
_nonceCookieBuilder = new OpenIdConnectNonceCookieBuilder(this)
{
Name = OpenIdConnectDefaults.CookieNoncePrefix,
HttpOnly = true,
SameSite = SameSiteMode.None,
SecurePolicy = CookieSecurePolicy.SameAsRequest,
IsEssential = true,
};
}
3,修改IdentityMvc的Privacy視圖控制器,使其必需經過id_token驗證后方能訪問
IdentityMvc.HomeController
[Microsoft.AspNetCore.Authorization.Authorize]
public IActionResult Privacy()
{
return View();
}
4,修改Privacy視圖,展示id_token信息
Privacy.cshtml
@{
ViewData["Title"] = "Privacy Policy";
}
<h1>@ViewData["Title"]</h1>
<h2>Claims</h2>
<dl>
@foreach (var claim in User.Claims)
{
<dt>@claim.Type</dt>
<dd>@claim.Value</dd>
}
</dl>
5,配置客戶端
IdentityMvc.StartUp.ConfigureServices
services.AddAuthentication(opt=> {
//默認驗證方案
opt.DefaultScheme = "Cookies";
//默認token驗證失敗后的確認驗證結果方案
opt.DefaultChallengeScheme = "oidc";
})
//先添加一個名為Cookies的Cookie認證方案
.AddCookie("Cookies")
//添加OpenIdConnect認證方案
.AddOpenIdConnect("oidc", options =>
{
//指定遠程認證方案的本地登錄處理方案
options.SignInScheme = "Cookies";
//遠程認證地址
options.Authority = "http://localhost:5000";
//Https強制要求標識
options.RequireHttpsMetadata = false;
//客戶端ID(支持隱藏模式和授權碼模式,密碼模式和客戶端模式不需要用戶登錄)
options.ClientSecret = "apiSecret";
//令牌保存標識
options.SaveTokens = true;
//添加訪問secretapi域api的權限,用於access_token
options.Scope.Add("secretapi");
//請求授權用戶的PhoneModel Claim,隨id_token返回
options.Scope.Add("PhoneModel");
//使用隱藏模式
options.ClientId = "apiClientImpl";
//請求返回id_token以及token
options.ResponseType = OpenIdConnectResponseType.IdTokenToken;
});
IdentityMvc.Start.Config
app.UseCookiePolicy();
app.UseAuthentication();
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
注意:UseAuthentication必需在UseMvc前面。
6,訪問https://localhost:5002/Home/Privacy,會重定向到IdentityServer登錄界面

用戶授權后重定向回Mvc客戶端,已經進入Privacy視圖了。

7,上面是隱藏模式測試,試一下混合模式,把ClientId改成混合模式Client的Id,ResponseType改成CodeIdTokenToken
services.AddAuthentication(opt=> {
//默認驗證方案
opt.DefaultScheme = "Cookies";
//默認token驗證失敗后的確認驗證結果方案
opt.DefaultChallengeScheme = "oidc";
})
//先添加一個名為Cookies的Cookie認證方案
.AddCookie("Cookies")
//添加OpenIdConnect認證方案
.AddOpenIdConnect("oidc", options =>
{
//指定遠程認證方案的本地登錄處理方案
options.SignInScheme = "Cookies";
//遠程認證地址
options.Authority = "http://localhost:5000";
//Https強制要求標識
options.RequireHttpsMetadata = false;
//客戶端ID(支持隱藏模式和授權碼模式,密碼模式和客戶端模式不需要用戶登錄)
options.ClientSecret = "apiSecret";
//令牌保存標識
options.SaveTokens = true;
//添加訪問secretapi域api的權限,用於access_token
options.Scope.Add("secretapi");
//請求授權用戶的PhoneModel Claim,隨id_token返回
options.Scope.Add("PhoneModel");
//使用混合模式
options.ClientId = "apiClientHybrid";
//請求返回code,id_token以及token
options.ResponseType = OpenIdConnectResponseType.CodeIdTokenToken;
});
8,加入注銷登錄功能
8.1 配置IdentityServer 客戶端的PostLogoutRedirectUris屬性,值為第2步講的SingedOutCallbackPath值
IdentityServer.Config.GetClients
//允許登錄后重定向的地址列表,可以有多個
RedirectUris = {"https://localhost:5002/auth.html","https://localhost:5002/signin-oidc"},
//注銷登錄的回調地址列表,可以有多個
PostLogoutRedirectUris = { "https://localhost:5002/signout-callback-oidc" },
8.2,在IdentityMvc項目的HomeController添加一個新的視圖控制器,用於注銷登錄
public IActionResult Logout()
{
return SignOut("Cookies", "oidc");
}
8.3,把這個控制器加入布局頁的菜單
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Logout">Logout</a>
</li>
登錄后訪問Privacy頁面,然后訪問Logout視圖,會重定向到IdentityServer執行注銷邏輯

9,利用access_token訪問被保護的Api
IdentityMvc.HomeController.Detail
[Microsoft.AspNetCore.Authorization.Authorize]
public async Task<IActionResult> Detail()
{
var client = new HttpClient();
var token =await HttpContext.GetTokenAsync("access_token");
client.SetBearerToken(token);
string data = await client.GetStringAsync("https://localhost:5001/api/identity");
JArray json = JArray.Parse(data);
return new JsonResult(json);
}
訪問https://localhost:5002/home/detail,獲取access_token后請求被保護的api並顯示api返回結果。

示例中的客戶端數據、資源數據、用戶數據都是放在Config類中,如果這些數據需要實時配置,可以與Sql結合實現,下一篇IdentityServer4結合Mysql
