06 Authorization Code Flow 实例.mp4

使用 Authorization Code Flow 保护 ASP.NET Core MVC 客户端(为其做用户的身份认证),并访问被保护资源。

简单说就是 MVC 做客户端,IdentityServer4 做身份认证和授权。

OAuth 2.0 vs OpenID Connect

image.png

OAuth 2.0 - Authorization Code Grant

image.png

流程按字母先后顺序执行。

OpenlD Connect - Authorization Code Flow

image.png

主要差别就是除了 Access Token,客户端还能从授权服务器获得 Id Token,进而通过它获得最终用户的相关信息。

  • D 通过前端浏览器的重定向完成
  • E 通过后端服务器间的通讯完成

Authorization Code

  • 适用于机密客户端(Confidential Client)
  • 服务器端的 Web 应用
  • 对用户和客户端进行身份认证

客户端类型:

image.png

Authorization Code 实战

新建 MVC 项目

  1. ![image.png](https://cdn.nlark.com/yuque/0/2019/png/101969/1556959889409-c9154ba2-e655-461b-9404-64c553aef04b.png "image.png")


修改 launchSettings.json 把端口改为 5002:

  1. {
  2. "profiles": {
  3. "MvcClient": {
  4. "commandName": "Project",
  5. "launchBrowser": true,
  6. "applicationUrl": "http://localhost:5002",
  7. "environmentVariables": {
  8. "ASPNETCORE_ENVIRONMENT": "Development"
  9. }
  10. }
  11. }
  12. }

安装 IdentityModel NuGet 包。

在 Startup 里面注册并启用身份认证中间件:

  1. public void ConfigureServices(IServiceCollection services)
  2. {
  3. ...
  4. services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
  5. // 关闭 JWT Claim 映射
  6. JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
  7. // 注册身份认证中间件
  8. services.AddAuthentication(options =>
  9. {
  10. // CookieAuthenticationDefaults.AuthenticationScheme == "Cookies"
  11. options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
  12. // OpenIdConnectDefaults.AuthenticationScheme == "OpenIdConnect"
  13. options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
  14. })
  15. .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
  16. .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
  17. {
  18. options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
  19. options.Authority = "http://localhost:5000";
  20. options.RequireHttpsMetadata = false;
  21. options.ClientId = "mvc client";
  22. options.ClientSecret = "mvc secret";
  23. options.SaveTokens = true;
  24. options.ResponseType = "code";
  25. options.Scope.Clear();
  26. options.Scope.Add("api1");
  27. options.Scope.Add(OidcConstants.StandardScopes.OpenId);
  28. options.Scope.Add(OidcConstants.StandardScopes.Profile);
  29. options.Scope.Add(OidcConstants.StandardScopes.Email);
  30. options.Scope.Add(OidcConstants.StandardScopes.Phone);
  31. options.Scope.Add(OidcConstants.StandardScopes.Address);
  32. // 必须添加 OfflineAccess 才能获取到 Refresh Token
  33. options.Scope.Add(OidcConstants.StandardScopes.OfflineAccess);
  34. });
  35. }
  36. public void Configure(IApplicationBuilder app, IHostingEnvironment env)
  37. {
  38. if (env.IsDevelopment())
  39. {
  40. app.UseDeveloperExceptionPage();
  41. }
  42. else
  43. {
  44. app.UseExceptionHandler("/Home/Error");
  45. }
  46. // 要在添加 MVC 中间件之前添加 Authentication 到管道
  47. app.UseAuthentication();
  48. app.UseStaticFiles();
  49. ...
  50. }

这些代码的具体作用可以参考官方文档 Adding User Authentication with OpenID Connect 中的解释。

最后在 HomeController 上标注 [Authorize] 以保护 HomeController。

添加 MVC Client

打开 Idp 项目,添加 MVC Client:

  1. // MVC client, authorization code
  2. new Client
  3. {
  4. ClientId = "mvc client",
  5. ClientName = "ASP.NET Core MVC Client",
  6. AllowedGrantTypes = GrantTypes.CodeAndClientCredentials,
  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. // 总是在 Id Token 里面包含所有 User Claims 信息
  12. AlwaysIncludeUserClaimsInIdToken = true,
  13. // 设为 True 即支持 Refresh Token
  14. AllowOfflineAccess = true, // offline_access
  15. AllowedScopes =
  16. {
  17. "api1",
  18. IdentityServerConstants.StandardScopes.OpenId,
  19. IdentityServerConstants.StandardScopes.Email,
  20. IdentityServerConstants.StandardScopes.Address,
  21. IdentityServerConstants.StandardScopes.Phone,
  22. IdentityServerConstants.StandardScopes.Profile
  23. }
  24. },

运行项目

依次启动 Idp、Api1 和 MVC Client 三个项目。

由于 MVC Client 的 HomeController 被保护了,所以启动项目后会自动跳转到授权服务器进行身份认证。

image.png

身份认证完毕后跳转到 grant 界面:

image.png

同意后回到 MVC Client 的 Home 界面:

image.png

在 MVC Client 中获取各个 Token

修改 HomeController 的 Privacy 方法,获取各个 Token:

  1. public async Task<IActionResult> Privacy()
  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. return View();
  7. }

注:修改 MVC Client 后每次重新调试记得手动清除浏览器 Cookie。

通过 Preserve log 查看 Authorization Token:

image.png

通过 Fiddler 监视到两个往返

image.png

点下 Yes, Allow 的瞬间,可以通过 Fiddler 监视到两个往返。

image.png

1 - 身份认证请求(浏览器与授权服务器间通讯):

  1. HTTP/1.1 302 Found
  2. Location: https://server.example.com/authorize?
  3. response _type=code
  4. &scope=openid%20profile&20email
  5. &client_id=s6BhdRkqt3
  6. &state=af0ifjsldkj
  7. &redirect_uri=https$3A%2F%2Fclient.example.org%2Fcb

1 - 身份认证请求的响应:

  1. HTTP/1.1 302 Found
  2. Location: https://client.example.org/cb?
  3. code=Splx10BeZQQYbYS6WxSbIA
  4. &state=af0ifjsldkj

2 - Token 请求(客户端服务器与授权服务器间通讯):

  1. POST /token HTTP/1.1
  2. Host: server.example.com
  3. Content-Iype: application/x-www-form-urlencoded
  4. Authorization: Basic czzCaGRSa3FOMzpniDFmQmFOM2JW
  5. grant_type=authorization_code
  6. &code=Splx10BeZQQYbYS6WxSbIA
  7. &redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb

2 - Token 请求的响应:

  1. HTTP/1.1 200 OK
  2. Content-Type: application/json
  3. Cache-Control: no-store
  4. Pragma: no-cache
  5. {
  6. "access_token": "S1AV32hkKG",
  7. "token_type": "Bearer",
  8. "refresh_token": "8xLOxBtZp8",
  9. "expires_in": 3600
  10. "id_token": "eyJhbGci0iJSUzI1NiIsImtpZCI6IjF10WdkazcifQ.ewogImlzc
  11. yI6ICJodHRwOi8vc2VyimVyLmV4YW1wbGUuY29tIiwKICJzdWIi0iAiM)Q4Mjg5
  12. NzYXMDAXIiwKICJh/Qi0iAiczZCaGRSa3FOMyIsCiAibm9uY2Ui0iAibi0wUz2
  13. fV3pBMK1qIiwKICJLeHAi01AXMzExM)gxOTcwLAogImlhdCI6IDEzMTEyODA5Nz
  14. AKEQ.ggwehZ1EuVLuxNuuIJKX V8a OMXzROEHR9R6jgdqrOOF4daGU96Sr P6g
  15. Jp6IcmD3HP990bi1PR3-cwh3LO-p146waJ8IhehcwL7F09JdijmBqkvPeB2T9CU
  16. NgeGpe-gccMg4vfKjkMBFcGvnzZUN4 _KSP0aAp1tOJ1zZwgjxqGByKHiOtX7TpG
  17. QyHE51cMiKPXEEIQILVqOpC E2DzL7emopioaoZTF mO NOYzFC6g6EJbOEoRoS
  18. K5hoDalrcvRYLSrQAZZKf1yuVCyixEoV9GfNQC3_o3jzM2PAithfubEEBLuVWk4
  19. XUVrWOLrL10nx7RkKUSNXNHg-rvMzgg"
  20. }

展示 Token

修改 HomeController 的 Privacy 方法,展示 Token:

  1. public async Task<IActionResult> Privacy()
  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. ViewData["accessToken"] = accessToken;
  7. ViewData["idToken"] = idToken;
  8. ViewData["refreshToken"] = refreshToken;
  9. return View();
  10. }

Privacy 的 View:

  1. @{
  2. ViewData["Title"] = "Privacy Policy";
  3. }
  4. <h1>@ViewData["Title"]</h1>
  5. <h2>Access Token:</h2>
  6. <p>@ViewData["accessToken"]</p>
  7. <h2>Id Token:</h2>
  8. <p>@ViewData["idToken"]</p>
  9. <h2>Refresh Token:</h2>
  10. <p>@ViewData["refreshToken"]</p>
  11. <dl>
  12. @foreach (var claim in User.Claims)
  13. {
  14. <dt>@claim.Type</dt>
  15. <dd>@claim.Value</dd>
  16. }
  17. </dl>

image.png

使用 Access Token 访问 Api1 的资源

先修改 MVC Client HomeController 的 Index Action:

  1. public async Task<IActionResult> Index()
  2. {
  3. var client = new HttpClient();
  4. var disco = await client.GetDiscoveryDocumentAsync("http://localhost:5000/");
  5. if (disco.IsError) throw new Exception(disco.Error);
  6. var accessToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
  7. client.SetBearerToken(accessToken);
  8. var response = await client.GetAsync("http://localhost:5001/identity");
  9. if (!response.IsSuccessStatusCode) throw new Exception(response.ReasonPhrase);
  10. var content = await response.Content.ReadAsStringAsync();
  11. return View("Index", content);
  12. }

再修改 Index View:

  1. @model string
  2. @{
  3. ViewData["Title"] = "Home Page";
  4. }
  5. <div class="text-center">
  6. <h1 class="display-4">Api1 Resource Respose:</h1>
  7. <p>@Model</p>
  8. </div>

效果:

image.png

单点登录

用户登录后,再打开授权服务器可以看到 alice 处于登录状态(即会话被 Idp 项目保持了)。

此时如果有其他 Web 应用请求授权,跳转到授权服务器后会发现 alice 已经登录了,它就无需登录直接跳转回去。整个过程就相当于是一个单点登录。

image.png

登出

登出时既要登出 MVC 客户端,又要登出 IdentityServer4 用户会话。

MVC Client _Layout 中添加 Logout 链接:

  1. <ul class="navbar-nav flex-grow-1">
  2. <li class="nav-item">
  3. <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
  4. </li>
  5. <li class="nav-item">
  6. <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
  7. </li>
  8. @if (User.Identity.IsAuthenticated)
  9. {
  10. <li class="nav-item">
  11. <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Logout">Logout</a>
  12. </li>
  13. }
  14. </ul>

Logout Action:

  1. public async Task Logout()
  2. {
  3. // 登出当前网站的 Cookie
  4. await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
  5. // 登出 IdentityServer4 的 Cookie
  6. await HttpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme);
  7. }

通过设置 Idp 项目 Quickstart\Account\AccountOptions.cs 的 AutomaticRedirectAfterSignOut = true; 可以在登出后自动跳转回客户端。