使用Identity Server 4建立Authorization Server (4)

solenovex發表於2017-11-07

預備知識: http://www.cnblogs.com/cgzl/p/7746496.html

第一部分: http://www.cnblogs.com/cgzl/p/7780559.html

第二部分: http://www.cnblogs.com/cgzl/p/7788636.html

第三部分: http://www.cnblogs.com/cgzl/p/7793241.html

上一篇講了使用OpenId Connect進行Authentication.

下面講

Hybrid Flow和Offline Access

目前我們解決方案裡面有三個專案 Authorization Server, Web api和Mvc Client. 在現實世界中, 他們可能都在不同的地方.

現在讓我們從MvcClient使用從Authorization Server獲取的token來訪問web api. 並且確保這個token不過期.

現在我們的mvcClient使用的是implicit flow, 也就是說, token 被髮送到client. 這種情況下 token的生命可能很短, 但是我們可以重定向到authorization server 重新獲取新的token.

例如, 在SPA(Single Page Application)中, implicit flow基本上就是除了resource owner password flow 以外唯一合適的flow, 但是我們的網站可能會在client(SPA client/或者指使用者)沒使用網站的時候訪問api, 為了這樣做, 不但要保證token不過期, 我們還需要使用別的flow. 我們要介紹一下authorization code flow. 它和implicit flow 很像, 不同的是, 在重定向回到網站的時候獲取的不是access token, 而是從authorization server獲取了一個code, 使用它網站可以交換一個secret, 使用這個secret可以獲取access token和refresh tokens.

Hybrid Flow, 是兩種的混合, 首先identity token通過瀏覽器傳過來了, 然後客戶端可以在進行任何工作之前對其驗證, 如果驗證成功, 客戶端就會再開啟一個通道向Authorization Server請求獲取access token.

首先在Authorization server的InMemoryConfiguration新增一個Client:

new Client
                {
                    ClientId = "mvc_code",
                    ClientName = "MVC Code Client",
                    AllowedGrantTypes = GrantTypes.HybridAndClientCredentials,
                    ClientSecrets =
                    {
                        new Secret("secret".Sha256())
                    },
                    RedirectUris = { "http://localhost:5002/signin-oidc" },
                    PostLogoutRedirectUris = { "http://localhost:5002/signout-callback-oidc" },
                    AllowedScopes = new List<string>
                    {
                        IdentityServerConstants.StandardScopes.OpenId,
                        IdentityServerConstants.StandardScopes.Profile,
                        IdentityServerConstants.StandardScopes.Email,
                        "socialnetwork"
                    },
                    AllowOfflineAccess = true,
                    AllowAccessTokensViaBrowser = true
                }

 

首先肯定要修改一下ClientId.

GrantType要改成Hybrid或者HybrdAndClientCredentials, 如果只使用Code Flow的話不行, 因為我們的網站使用Authorization Server來進行Authentication, 我們想獲取Access token以便被授權來訪問api. 所以這裡用HybridFlow.

還需要新增一個新的Email scope, 因為我想改變api來允許我基於email來建立使用者的資料, 因為authorization server 和 web api是分開的, 所以使用者的資料庫也是分開的. Api使用使用者名稱(email)來查詢資料庫中的資料.

AllowOfflineAccess. 我們還需要獲取Refresh Token, 這就要求我們的網站必須可以"離線"工作, 這裡離線是指使用者和網站之間斷開了, 並不是指網站離線了.

這就是說網站可以使用token來和api進行互動, 而不需要使用者登陸到網站上. 

修改MvcClient的Startup的ConfigureServices:

public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();

            JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
            services.AddAuthentication(options =>
            {
                options.DefaultScheme = "Cookies";
                options.DefaultChallengeScheme = "oidc";
            })
            .AddCookie("Cookies")
            .AddOpenIdConnect("oidc", options =>
            {
                options.SignInScheme = "Cookies";
                options.Authority = "http://localhost:5000";
                options.RequireHttpsMetadata = false;
                options.ClientId = "mvc_code";
                options.ClientSecret = "secret";
                options.ResponseType = "id_token code";
                options.Scope.Add("socialnetwork");
                options.Scope.Add("offline_access");
                options.SaveTokens = true;
                options.GetClaimsFromUserInfoEndpoint = true;
            });
        }

首先改ClientId和Authorization server一致. 這樣使用者訪問的時候和implicit差不多, 只不過重定向回來的時候, 獲取了一個code, 使用這個code可以換取secret然後獲取access token.

所以需要在網站(MvcClient)上指定Client Secret. 這個不要洩露出去.

還需要改變reponse type, 不需要再獲取access token了, 而是code, 這意味著使用的是Authorization Code flow.

還需要指定請求訪問的scopes: 包括 socialnetwork api和離線訪問

最後還可以告訴它從UserInfo節點獲取使用者的Claims.

執行

點選About, 重定向到Authorization Server:

同時在Authorization Server的控制檯可以看見如下資訊:

這裡可以看到請求訪問的scope, response_type. 還告訴我們respose mode是from_post, 這就是說, 在這登陸後重定向回到網站是使用的form post方式.

然後登陸:

這裡可以看到請求訪問的範圍, 包括個人資訊和Application Access.

點選Yes, Allow:

重定向回到了網站. 這裡看起來好像和以前一樣. 但是如果看一下Authorization Server的控制檯:

就會看到一個request. 中介軟體發起了一個請求使用Authorization Code和ClientId和secret來換取了Access token.

當Authorization驗證上述資訊後, 它就會建立一個token.

列印Refresh Token

修改MvcClient的About.cshtml:

@using Microsoft.AspNetCore.Authentication
<div>
    <strong>id_token</strong>
    <span>@await ViewContext.HttpContext.GetTokenAsync("id_token")</span>
</div>
<div>
    <strong>access_token</strong>
    <span>@await ViewContext.HttpContext.GetTokenAsync("access_token")</span>
</div>
<div>
    <strong>refresh_token</strong>
    <span>@await ViewContext.HttpContext.GetTokenAsync("refresh_token")</span>
</div>
<dl>
    @foreach (var claim in User.Claims)
    {
        <dt>@claim.Type</dt>
        <dd>@claim.Value</dd>
    }
</dl>

重新整理頁面:

看到了refresh token.

這些token包含了什麼時候過期的資訊.

如果access token過期了, 就無法訪問api了. 所以需要確保access token不過期. 這就需要使用refresh token了.

複製一下refresh token, 然後使用postman:

使用這個refresh token可以獲取到新的access token和refresh_token, 當這個access_token過期的時候, 可以使用refresh_token再獲取一個access_token和refresh_token......

而如果使用同一個refresh token兩次, 就會得到下面的結果:

看看Authorization Server的控制檯, 顯示是一個invalid refresh token:

所以說, refresh token是一次性的.

獲取自定義Claims

web api 要求request請求提供access token, 以證明請求的使用者是已經授權的. 現在我們準備從Access token裡面提取一些自定義的Claims, 例如Email.

看看Authorization Server的Client配置:

Client的AllowedScopes已經包括了Email. 但是還沒有配置Authorization Server允許這個Scope. 所以需要修改GetIdentityResources()(我自己的程式碼可能改名成IdentityResources()了):

public static IEnumerable<IdentityResource> IdentityResources()
        {
            return new List<IdentityResource>
            {
                new IdentityResources.OpenId(),
                new IdentityResources.Profile(),
                new IdentityResources.Email()
            };
        }

然後需要為TestUser新增一個自定義的Claims;

public static IEnumerable<TestUser> Users()
        {
            return new[]
            {
                new TestUser
                {
                    SubjectId = "1",
                    Username = "mail@qq.com",
                    Password = "password",
                    Claims = new [] { new Claim("email", "mail@qq.com") }
                }
            };
        }

然後需要對MvcClient進行設定, Startup的ConfigureServices:

public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();

            JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
            services.AddAuthentication(options =>
            {
                options.DefaultScheme = "Cookies";
                options.DefaultChallengeScheme = "oidc";
            })
            .AddCookie("Cookies")
            .AddOpenIdConnect("oidc", options =>
            {
                options.SignInScheme = "Cookies";
                options.Authority = "http://localhost:5000";
                options.RequireHttpsMetadata = false;
                options.ClientId = "mvc_code";
                options.ClientSecret = "secret";
                options.ResponseType = "id_token code";
                options.Scope.Add("socialnetwork");
                options.Scope.Add("offline_access");
                options.Scope.Add("email");
                options.SaveTokens = true;
                options.GetClaimsFromUserInfoEndpoint = true;
            });
        }

新增email scope. 所以MvcClient就會也請求這個scope.

執行:

這時在同意(consent)頁面就會出現email address一欄.

同意之後, 可以看到email已經獲取到了.

使用Access Token呼叫Web Api

首先在web api專案建立一個IdentityController:

namespace WebApi.Controllers
{
    [Route("api/[controller]")]
    public class IdentityController: Controller
    {
        [Authorize]
        [HttpGet]
        public IActionResult Get()
        {
            var username = User.Claims.First(x => x.Type == "email").Value;
            return Ok(username);
            //return new JsonResult(from c in User.Claims select new { c.Type, c.Value});
        }

    }
}

我們想要通過自定義的claim: email的值.

然後回到mvcClient的HomeController, 新增一個方法:

        [Authorize]
        public async Task<IActionResult> GetIdentity()
        {
            var token = await HttpContext.GetTokenAsync("access_token");
            using (var client = new HttpClient())
            {
                client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
                var content = await client.GetStringAsync("http://localhost:5001/api/identity");
                // var json = JArray.Parse(content).ToString();
                return Ok(new { value = content });
            }
        }

這裡首先通過HttpContext獲得access token, 然後在請求的Authorization Header加上Bearer Token.

讓我們執行一下, 並在MvcClient和Web Api裡面都設好斷點,

登入後在瀏覽器輸入 http://localhost:5002/Home/GetIdentity 以執行GetIdenttiy方法, 然後進入Web Api看看斷點除錯情況:

由於我們已經授權了, 所以可以看到User的一些claims, 而其中沒有email這個claim. 再執行就報錯了.

這是怎麼回事? 我們回到About頁面, 複製一下access_token, 去jwt.io分析一下:

確實沒有email的值, 所以提取不出來.

所以我們需要把email新增到access token的資料裡面, 這就需要告訴Authorization Server的Api Resource裡面要包括User的Scope, 因為這是Identity Scope, 我們想要把它新增到access token裡:

修改Authorization Server的InMemoryConfiguration的ApiResources():

public static IEnumerable<ApiResource> ApiResources()
        {
            return new[]
            {
                new ApiResource("socialnetwork", "社交網路")
                {
                    UserClaims = new [] { "email" }
                }
            };
        }

這對這個Api Resouce設定它的屬性UserClaims, 裡面寫上email.

然後再執行一下程式, 這裡需要重新登陸, 首先分析一下token:

有email了. 

然後執行GetIdentity(), 在web api斷點除錯, 可以看到UserClaims已經包含了email:

上面這些如果您不會的話, 需要整理總結一下.

使用者使用Authorization Server去登入網站(MvcClient), 也就是說使用者從網站跳轉到第三方的系統完成了身份的驗證, 然後被授權可以訪問web api了(這裡講的是使用者通過mvcClient訪問api). 當訪問web api的時候, 首先和authorization server溝通確認access token的正確性, 然後就可以成功的訪問api了.

重新整理Access Token

根據配置不同, token的有效期可能差別很大, 如果token過期了, 那麼傳送請求之後就會返回401 UnAuthorized.

當然如果token過期了, 你可以讓使用者重定向到Authorization Server重新登陸,再回來操作, 不過這樣太不友好, 太繁瑣了.

既然我們有refresh token了, 那不如向authorization server請求一個新的access token和refresh token. 然後再把這些更新到cookie裡面. 所以下次再呼叫api的時候使用的是新的token.

在MvcClient的HomeController新增RefreshTokens()方法:

首先需要安裝IdentityModel, 它是OpenIdConnect, OAuth2.0的客戶端庫:

        [Authorize]
        public async Task RefreshTokensAsync()
        {
            var authorizationServerInfo = await DiscoveryClient.GetAsync("http://localhost:5000/");
            var client = new TokenClient(authorizationServerInfo.TokenEndpoint, "mvc_code", "secret");
            var refreshToken = await HttpContext.GetTokenAsync("refresh_token");
            var response = await client.RequestRefreshTokenAsync(refreshToken);
            var identityToken = await HttpContext.GetTokenAsync("identity_token");
            var expiresAt = DateTime.UtcNow + TimeSpan.FromSeconds(response.ExpiresIn);
            var tokens = new[]
            {
                new AuthenticationToken
                {
                    Name = OpenIdConnectParameterNames.IdToken,
                    Value = identityToken
                },
                new AuthenticationToken
                {
                    Name = OpenIdConnectParameterNames.AccessToken,
                    Value = response.AccessToken
                },
                new AuthenticationToken
                {
                    Name = OpenIdConnectParameterNames.RefreshToken,
                    Value = response.RefreshToken
                },
                new AuthenticationToken
                {
                    Name = "expires_at",
                    Value = expiresAt.ToString("o", CultureInfo.InvariantCulture)
                }
            };
            var authenticationInfo = await HttpContext.AuthenticateAsync("Cookies");
            authenticationInfo.Properties.StoreTokens(tokens);
            await HttpContext.SignInAsync("Cookies", authenticationInfo.Principal, authenticationInfo.Properties);
        }

首先使用一個叫做discovery client的東西來獲取Authorization Server的資訊. Authorization Server裡面有一個discovery節點(endpoint), 可以通過這個地址檢視: /.well-known/openid-configuration. 從這裡可以獲得很多資訊, 例如: authorization節點, token節點, 釋出者, key, scopes等等.

然後使用TokenClient, 引數有token節點, clientId和secret. 然後可以使用這個client和refreshtoken來請求新的access token等. 

找到refresh token後, 使用client獲取新的tokens, 返回結果是tokenresponse. 你可以設斷點檢視一下token reponse裡面都有什麼東西, 這裡就不弄了, 裡面包括identitytoken, accesstoken, refreshtoken等等.

然後需要找到原來的identity token, 因為它相當於是cookie中儲存的主鍵...

然後設定一下過期時間.

然後將老的identity token和新獲取到的其它tokens以及過期時間, 組成一個集合.

然後使用這些tokens來重新登陸使用者. 不過首先要獲取當前使用者的authentication資訊, 使用HttpContext.AuthenticateAsync("Cookies"), 引數是AuthenticationScheme. 然後修改屬性, 儲存新的tokens.

最後就是重登入, 把當前使用者資訊的Principal和Properties傳進去. 這就會更新客戶端的Cookies, 使用者也就保持登陸並且重新整理了tokens.

先簡單呼叫一下這個方法:

[Authorize]
        public async Task<IActionResult> GetIdentity()
        {
            await RefreshTokensAsync();
            var token = await HttpContext.GetTokenAsync("access_token");
            using (var client = new HttpClient())
            {
                client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
                var content = await client.GetStringAsync("http://localhost:5001/api/identity");
                //var json = JArray.Parse(content).ToString();
                return Ok(new { value = content });
            }
        }

正式生產環境中可不要這麼做, 正式環境中應該在401之後, 呼叫這個方法, 如果再失敗, 再返回錯誤.

執行一下:

發現獲取的access token是空的, 一定是哪出現了問題, 看一下 authorization server的控制檯:

說refresh token不正確(應該是記憶體資料和cookie資料不匹配). 那就重新登陸.

看斷點, 有token了:

並且和About頁面顯示的不一樣, 說明重新整理token了.

也可以看一下authorization server的控制檯:

說明成功請求了token.

今天先到這裡.

相關文章