Identity Server4 基础应用(三)Authorization Code(Part II)
在上一篇文章记录了Authorization Code授权方式的基本流程和使用后,还遗留了几个问题,怎么利用Access Token去访问api资源,Refresh Token怎么刷新Access Token,接着试着实现一个退出当前的登录的操作,并试试如何使用第三方登录。
访问Api资源
前文我们访问的是授权服务器上的用户认证信息,及得到的都是用户的Claims数据,如果我们想通过MVC客户端去访问我们在第一篇文章中建立的WebApi的资源的话,由于这个资源也是被授权服务器保护的,所以在访问时我们也需要Access Token的。
接下来试着在上文的MVC程序中新建一个Action去请求WebApi的资源,我们建立一个名为CallApi的Action,其中利用一个现成扩展方法获取已经在取得授权时存下的Access Token,随后请求WebApi。
1 public async Task<IActionResult> CallApi() 2 { 3 //我们利用拓展方法获取存下来的Access Token 4 var accessToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken); 5 6 var client = new HttpClient(); 7 //携带上AccessToken 8 client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); 9 //去请求受保护的api1上的资源 10 var content = await client.GetStringAsync("http://localhost:5001/identity"); 11 ViewBag.Json = JArray.Parse(content).ToString(); 12 return View("Json"); 13 }
接着建立一个简单的View来显示获取到的数据
1 <pre>@ViewBag.Json</pre>
紧接着我们要确定在授权服务器上我们为名为mvc的这个Client的AllowedScopes中是否添加了api1,同样的在MVC程序中的ConfigureServices中是否也添加了api1这个scope。
随后我们进行调试,运行程序后,经过登录等认证操作后自动的存下了AccessToken,随后我们请求CallApi得到WebApi返回的数据。
Refresh Token
在前文最后请求Access Token时,从Fiddler捕获到的授权服务器的Response中看到,授权服务器返回给了MVC客户端的Tokens包含Access Token和Id Token。但是缺少了我们在介绍Authorization Code授权流程时说到的Refresh Token。我们需要修改下设置,确保在授权服务器要请求的client中将AllowOfflineAccess属性设置为True,并且在MVC程序中,在oidc配置中为scope添加offline_access。
1 new Client 2 { 3 ClientId = "mvc", 4 ClientName = "MVC Client", 5 AllowedGrantTypes = GrantTypes.Code, 6 RequirePkce = false, 7 ClientSecrets = { new Secret("mvc secret".Sha256()) }, 8 RedirectUris = { "http://localhost:5002/signin-oidc" }, 9 FrontChannelLogoutUri = "http://localhost:5002/signout-oidc", 10 PostLogoutRedirectUris = { "http://localhost:5002/signout-callback-oidc" }, 11 AlwaysIncludeUserClaimsInIdToken = true, 12 AllowOfflineAccess = true, //若要使用Refresh Token,必须将这个属性置成true 13 AccessTokenLifetime = 60, //设置Access Token的过期时间 14 AllowedScopes = 15 { 16 "api1", 17 //因为我们要请求的资源包含用户信息,所以在scope中需要包括上 18 IdentityServer4.IdentityServerConstants.StandardScopes.OpenId, 19 IdentityServer4.IdentityServerConstants.StandardScopes.Profile, 20 IdentityServer4.IdentityServerConstants.StandardScopes.Email, 21 IdentityServer4.IdentityServerConstants.StandardScopes.Phone, 22 } 23 }
1 .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options => 2 { 3 options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; 4 options.Authority = "http://localhost:5000"; //授权服务器地址 5 options.RequireHttpsMetadata = false; //暂时不用https 6 options.ClientId = "mvc"; 7 options.ClientSecret = "mvc secret"; 8 options.ResponseType = "code"; //代表Authorization Code 9 options.SaveTokens = true; //表示把获取的Token存到Cookie中 10 options.Scope.Add("api1"); //这个scope中对应了WebApi(API1)上的资源 11 12 //*************新增,用以获取Refresh Code:******************* 13 options.Scope.Add("offline_access"); 14 //*********************************************************** 15 });
接着我们修改下MVC中“HomeController/Index”中的代码,在这里用前面用过的扩展方法去获取这几个Token,包括RefreshToken。并在相应的View中将这几个值显示出来。当我们登录后便可以在浏览器中看到这几个Token了。
1 public async Task<IActionResult> Index() 2 { 3 var accessToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken); 4 var idToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.IdToken); 5 var refreshToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.RefreshToken); 6 7 ViewData["accessToken"] = accessToken; 8 ViewData["idToken"] = idToken; 9 ViewData["refreshToken"] = refreshToken; 10 return View(); 11 }
在能获取到Refresh Token后要怎么利用呢,客户端再获取到AccessToken后是有一个默认的有效时间的,时长是3600秒。在AccessToken的有效时间到期后便会失效,这个时候用户就需要重新授权去获取新的AccessToken来使用。在有了Refresh Token后,客户端在发现AccessToken失效时就可以使用Refresh Token向授权服务器发送请求,便可以获取到新的Access Token。
接下来我们撸码做实验,先在授权服务器上将Access Token过期时间修改的短一点,我们在clientId为mvc的Client中修改其属性。
1 AllowOfflineAccess = true, //若要使用Refresh Token,必须将这个属性置成true 2 AccessTokenLifetime = 60, //设置Access Token的过期时间为1分钟
还需要修改下WebApi的ConfigureServices中的配置
1 services.AddAuthentication("Bearer") 2 .AddJwtBearer("Bearer", options => 3 { 4 options.Authority = "http://localhost:5000"; //这里指定授权服务器的地址 5 options.RequireHttpsMetadata = false; //暂时先不用https 6 options.Audience = "api1"; //关联到授权服务器上的api资源,住进“api1”这个门牌号里 7 //************************新增************************* 8 options.TokenValidationParameters.ClockSkew = TimeSpan.FromMinutes(1); //每隔多长时间检查一次Token的有效性 9 options.TokenValidationParameters.RequireExpirationTime = true; //要求Access Token必须包含一个超时时间 10 //***************************************************** 11 });
现在我们试着清除一下Cookie,重新发起请求,继续请求CallApi这个Action,能够获取到WebApi返回的数据。接着等待一段时间后再次访问,会看到返回了401状态码显示未授权,这就表示WebApi再次去验证Access Token时发现已经过期失效了。
为了解决这个问题,我们需要在后台代码中利用Refresh Token去获取新的AccessToken。并修改之前CallApi中的代码,当检测到授权无效时就会获取新的Token并随后再次发起请求。具体代码如下。在IdentityModel中为我们提供了扩展方法RequestRefreshTokenAsync利用RefreshToken来获取新的有效Tokens,并且我们将过期失效的Tokens和新申请的Tokens都打印出来对比一下。
1 /// <summary> 2 /// 获取新的AccessToken 3 /// </summary> 4 /// <returns></returns> 5 private async Task<Dictionary<string, string>> RefreshAccessTokensAsync() 6 { 7 var client = new HttpClient(); 8 var disco = await client.GetDiscoveryDocumentAsync("http://localhost:5000");//需要引入“IdentityModel” 9 if (disco.IsError) 10 { 11 throw new Exception(disco.Error); 12 } 13 var refreshToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.RefreshToken); 14 15 // 获取新的Tokens 16 var tokenResult = await client.RequestRefreshTokenAsync(new RefreshTokenRequest 17 { 18 Address = disco.TokenEndpoint, 19 ClientId = "mvc", 20 ClientSecret = "mvc secret", 21 Scope = "api1 openid profile email phone address", 22 GrantType = OpenIdConnectGrantTypes.RefreshToken, 23 RefreshToken = refreshToken 24 }); 25 if (tokenResult.IsError) 26 { 27 throw new Exception(tokenResult.Error); 28 } 29 var newTokens = new Dictionary<string, string> 30 { 31 {OpenIdConnectParameterNames.AccessToken, tokenResult.AccessToken}, 32 {OpenIdConnectParameterNames.IdToken, tokenResult.IdentityToken}, 33 {OpenIdConnectParameterNames.RefreshToken, tokenResult.RefreshToken}, 34 }; 35 var expiresAt = DateTime.UtcNow + TimeSpan.FromSeconds(tokenResult.ExpiresIn); 36 // 获取身份认证结果,主要有两个属性: 37 //(1)Properties:包含用到的所有Tokens 38 //(2)Principal:包含用户的Claims 39 AuthenticateResult info = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme); 40 info.Properties.UpdateTokenValue("refresh_token", newTokens["refresh_token"]); 41 info.Properties.UpdateTokenValue("access_token", newTokens["access_token"]); 42 info.Properties.UpdateTokenValue("id_token", newTokens["id_token"]); 43 info.Properties.UpdateTokenValue("expires_at", expiresAt.ToString("o", CultureInfo.InvariantCulture)); 44 45 // 再次登录 46 await HttpContext.SignInAsync("Cookies", info.Principal, info.Properties); 47 return newTokens; 48 }
修改Action中的代码
1 [Route(nameof(CallApi))] 2 public async Task<IActionResult> CallApi() 3 { 4 //我们利用拓展方法获取存下来的Access Token 5 var accessToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken); 6 var client = new HttpClient(); 7 //携带上AccessToken 8 client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); 9 //去请求受保护的api1上的资源 10 //var content = await client.GetStringAsync("http://localhost:5001/identity"); 11 var response = await client.GetAsync("http://localhost:5001/identity"); 12 if (!response.IsSuccessStatusCode) 13 { 14 ViewBag.Json = response.ReasonPhrase; 15 if (response.StatusCode == HttpStatusCode.Unauthorized) //如果时授权无效,刷新AccessToken 16 { 17 Dictionary<string, string> newAccessTokens; 18 if (response.StatusCode == HttpStatusCode.Unauthorized) 19 { 20 Dictionary<string, string> UnValidTokens = new Dictionary<string, string> 21 { 22 { 23 OpenIdConnectParameterNames.AccessToken, 24 await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken) 25 }, 26 { 27 OpenIdConnectParameterNames.IdToken, 28 await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.IdToken) 29 }, 30 { 31 OpenIdConnectParameterNames.RefreshToken, 32 await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.RefreshToken) 33 } 34 }; 35 newAccessTokens = await RefreshAccessTokensAsync(); //使用RefreshToken去获取新的AccessToken 36 Debug.WriteLine(JsonConvert.SerializeObject(UnValidTokens)); 37 Debug.WriteLine(JsonConvert.SerializeObject(newAccessTokens)); 38 return RedirectToAction(); //再次请求这个Action 39 } 40 } 41 } 42 var content = await response.Content.ReadAsStringAsync(); 43 ViewBag.Json = JArray.Parse(content).ToString(); 44 return View("Json"); 45 }
可以看到debug输出了无效的Tokens和新取得的Tokens。容易发现这两个Token中的具体值差异其实不大,我们可以在https://jwt.io/将Token进行解码,可以看到两此Token的授权有效时间是不一样的。
增加登出功能
添加Logout的Action,并在View中添加一个链接指向这个Action
1 public IActionResult Logout() 2 { 3 return SignOut(CookieAuthenticationDefaults.AuthenticationScheme 4 , OpenIdConnectDefaults.AuthenticationScheme); 5 }
来到QuickStart/Account/AccountOptions.cs,设置一下登出后跳转到登录界面。
1 public static bool AutomaticRedirectAfterSignOut = true; //登出后可直接跳转到登录界面
当我们点击页面的Logout后,完成登出,紧接着跳转到登录界面
增加第三方登录
这里我们参照官网的例子,添加通过Google登录的功能
访问https://console.developers.google.com,创建一个Google+ API并启动它。
创建OAuth凭据
在创建凭据的时候我们将重定向的Url设置成“http://localhost:5000/signin-google”
完成之后我们就可以得到Id(Client Id)和密钥(Client Secret)
在ConfigureServices方法中继续追加下面的代码,将Id和密钥贴到相应的位置
1 services.AddAuthentication().AddGoogle("Google", options => 2 { 3 options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme; 4 options.ClientId = "<insert here>"; 5 options.ClientSecret = "<insert here>"; 6 });
完成以上配置后和原来一样启动程序,在登陆界面选择“Google登录”就可以完成登录啦!
参考: