zoukankan      html  css  js  c++  java
  • ASP.NET Core3.1使用IdentityServer4中间件系列随笔(五):创建使用[Code-授权码]授权模式的客户端

    配套源码:https://gitee.com/jardeng/IdentitySolution

    本篇将创建使用[Code-授权码]授权模式的客户端,来对受保护的API资源进行访问。

    1、接上一篇项目,因为之前创建IdentityServer认证服务器没有使用IdentityServer4提供的模板,在Code授权码模式就没有进行登录、授权的界面,所以重新创建一下IdentityServer项目。

    重新使用IdentityServer4模板 - is4inmem创建项目。

    将之前IdentityServer认证服务器Config.cs复制到新建的IdentityServer服务器即可,最后的IdentityServer认证服务器项目结构为:

    然后在IdentityServer项目Config.cs中添加一个返回身份资源的方法

    然后在IdentityServer项目Config.cs中添加一个客户端

    注意:localhost:6001指的是我们将要创建的MVC客户端的项目地址,并非IdentityServer认证服务器的地址

    /// 授权码模式(Code)
    ///     适用于保密客户端(Confidential Client),比如ASP.NET MVC等服务器端渲染的Web应用
    new Client
    {
        ClientId = "mvc client",
        ClientName = "ASP.NET Core MVC Client",
    
        AllowedGrantTypes = GrantTypes.Code,
        ClientSecrets = { new Secret("mvc secret".Sha256()) },
    
        RedirectUris = { "http://localhost:6001/signin-oidc" },
        FrontChannelLogoutUri = "http://localhost:6001/signout-oidc",
        PostLogoutRedirectUris = { "http://localhost:6001/signout-callback-oidc" },
    
        AlwaysIncludeUserClaimsInIdToken = true,
        AllowOfflineAccess = true,
        AllowedScopes =
        {
            "api1",
            IdentityServerConstants.StandardScopes.OpenId,
            IdentityServerConstants.StandardScopes.Profile,
            IdentityServerConstants.StandardScopes.Email,
            IdentityServerConstants.StandardScopes.Address,
            IdentityServerConstants.StandardScopes.Phone
        }
    }
    View Code

    其中,RedirectUris的signin-oidc / FrontChannelLogoutUri的signout-oidc / PostLogoutRedirectUris的signout-callback-oidc,都是固定的地址写法。

    完整的Config.cs代码:

    using IdentityModel;
    using IdentityServer4;
    using IdentityServer4.Models;
    using IdentityServer4.Test;
    using System.Collections.Generic;
    using System.Security.Claims;
    
    namespace IdentityServer
    {
        /// <summary>
        /// IdentityServer资源和客户端配置文件
        /// </summary>
        public static class Config
        {
            /// <summary>
            /// 身份资源集合
            /// </summary>
            public static IEnumerable<IdentityResource> Ids =>
                new IdentityResource[]
                {
                    new IdentityResources.OpenId(),
                    new IdentityResources.Profile(),
                    new IdentityResources.Email(),
                    new IdentityResources.Address(),
                    new IdentityResources.Phone()
                };
    
            /// <summary>
            /// API资源集合
            ///     如果您将在生产环境中使用此功能,那么给您的API取一个逻辑名称就很重要。
            ///     开发人员将使用它通过身份服务器连接到您的api。
            ///     它应该以简单的方式向开发人员和用户描述您的api。
            /// </summary>
            public static IEnumerable<ApiResource> Apis => new List<ApiResource> { new ApiResource("api1", "My API") };
    
            /// <summary>
            /// 客户端集合
            /// </summary>
            public static IEnumerable<Client> Clients =>
                new Client[]
                {
                    /// 客户端模式(Client Credentials)
                    ///     可以将ClientId和ClientSecret视为应用程序本身的登录名和密码。
                    ///     它将您的应用程序标识到身份服务器,以便它知道哪个应用程序正在尝试与其连接。
                    new Client
                    { 
                        //客户端标识
                        ClientId = "client",
                        //没有交互用户,使用clientid/secret进行身份验证,适用于和用户无关,机器与机器之间直接交互访问资源的场景。
                        AllowedGrantTypes = GrantTypes.ClientCredentials,
                        //认证密钥
                        ClientSecrets = { new Secret("secret".Sha256()) },
                        //客户端有权访问的作用域
                        AllowedScopes = { "api1" }
                    },
                    /// 资源所有者密码凭证(ResourceOwnerPassword)
                    ///     Resource Owner其实就是User,所以可以直译为用户名密码模式。
                    ///     密码模式相较于客户端凭证模式,多了一个参与者,就是User。
                    ///     通过User的用户名和密码向Identity Server申请访问令牌。
                    new Client
                    {
                        ClientId = "client1",
                        AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
                        ClientSecrets = { new Secret("secret".Sha256()) },
                        AllowedScopes = { "api1" }
                    },           
                    /// 授权码模式(Code)
                    ///     适用于保密客户端(Confidential Client),比如ASP.NET MVC等服务器端渲染的Web应用
                    new Client
                    {
                        ClientId = "mvc client",
                        ClientName = "ASP.NET Core MVC Client",
    
                        AllowedGrantTypes = GrantTypes.Code,
                        ClientSecrets = { new Secret("mvc secret".Sha256()) },
    
                        RedirectUris = { "http://localhost:6001/signin-oidc" },
                        FrontChannelLogoutUri = "http://localhost:6001/signout-oidc",
                        PostLogoutRedirectUris = { "http://localhost:6001/signout-callback-oidc" },
    
                        AlwaysIncludeUserClaimsInIdToken = true,
                        AllowOfflineAccess = true,
                        AllowedScopes =
                        {
                            "api1",
                            IdentityServerConstants.StandardScopes.OpenId,
                            IdentityServerConstants.StandardScopes.Profile,
                            IdentityServerConstants.StandardScopes.Email,
                            IdentityServerConstants.StandardScopes.Address,
                            IdentityServerConstants.StandardScopes.Phone
                        }
                    }
                };
    
            /// <summary>
            /// 用户集合
            /// </summary>
            public static List<TestUser> Users =>
                new List<TestUser>
                {
                    new TestUser{SubjectId = "818727", Username = "alice", Password = "alice",
                        Claims =
                        {
                            new Claim(JwtClaimTypes.Name, "Alice Smith"),
                            new Claim(JwtClaimTypes.GivenName, "Alice"),
                            new Claim(JwtClaimTypes.FamilyName, "Smith"),
                            new Claim(JwtClaimTypes.Email, "AliceSmith@email.com"),
                            new Claim(JwtClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean),
                            new Claim(JwtClaimTypes.WebSite, "http://alice.com"),
                            new Claim(JwtClaimTypes.Address, @"{ 'street_address': 'One Hacker Way', 'locality': 'Heidelberg', 'postal_code': 69118, 'country': 'Germany' }", IdentityServer4.IdentityServerConstants.ClaimValueTypes.Json)
                        }
                    },
                    new TestUser{SubjectId = "88421113", Username = "bob", Password = "bob",
                        Claims =
                        {
                            new Claim(JwtClaimTypes.Name, "Bob Smith"),
                            new Claim(JwtClaimTypes.GivenName, "Bob"),
                            new Claim(JwtClaimTypes.FamilyName, "Smith"),
                            new Claim(JwtClaimTypes.Email, "BobSmith@email.com"),
                            new Claim(JwtClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean),
                            new Claim(JwtClaimTypes.WebSite, "http://bob.com"),
                            new Claim(JwtClaimTypes.Address, @"{ 'street_address': 'One Hacker Way', 'locality': 'Heidelberg', 'postal_code': 69118, 'country': 'Germany' }", IdentityServer4.IdentityServerConstants.ClaimValueTypes.Json),
                            new Claim("location", "somewhere")
                        }
                    }
                };
        }
    }
    View Code

    2、创建一个名为 CodeMvcApp 的ASP.NET Core MVC客户端应用。

     选择Web 应用程序(模型视图控制器)模板

     

     创建完成后的项目截图

    3、添加nuget包:IdentityServer4、IdentityModel、System.IdentityModel.Tokens.Jwt

    4、配置MVC客户端

    > Config.cs的ConfigureServices方法:

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllersWithViews();
    
        JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
    
        services.AddAuthentication(options =>
        {
            options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
        }).AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
        .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
        {
            options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            options.Authority = "http://localhost:5000";
            options.RequireHttpsMetadata = false;
            options.ClientId = "mvc client";
            options.ClientSecret = "mvc secret";
            options.SaveTokens = true;
            options.ResponseType = "code";
    
            options.Scope.Clear();
            options.Scope.Add("api1");
            options.Scope.Add(OidcConstants.StandardScopes.OpenId);
            options.Scope.Add(OidcConstants.StandardScopes.Profile);
            options.Scope.Add(OidcConstants.StandardScopes.Email);
            options.Scope.Add(OidcConstants.StandardScopes.Phone);
            options.Scope.Add(OidcConstants.StandardScopes.Address);
            options.Scope.Add(OidcConstants.StandardScopes.OfflineAccess);
        });
    }

    > Config.cs的Configure方法:

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        else
        {
            app.UseExceptionHandler("/Home/Error");
        }
        app.UseStaticFiles();
    
        app.UseRouting();
    
        app.UseAuthentication();
        app.UseAuthorization();
    
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllerRoute(
                name: "default",
                pattern: "{controller=Home}/{action=Index}/{id?}");
        });
    }

    给HomeController控制器加上[Authorize]特性

    IdentityServer认证服务器需要在开发环境才能出现首页,所以另外打开这个项目并启动。

     再启动CodeMvcApp项目

    我们看到MVC客户端默认跳转到了localhost:5000(IdentityServer认证服务器)的登录页(Account/Login),因为MVC客户端默认启动的是Home/Index,且Home控制器已被标记Authorize特性,需要登录才能访问

    使用 alice / alice 进行登录,进入到了IdentityServer认证服务器的授权页面(consent),点击Yes, Allow

    进入到了MVC客户端首页

    我们打开IdentityServer认证服务器地址:http://localhost:5000

    可以看到IdentityServer认证服务器显示了当前的登录用户,此时点击用户名可以显示出Logout登出按钮,点击登出即可完成注销登录

    5、获取accecc_token并访问受保护API资源,修改HomeController的Index方法

    using System;
    using System.Diagnostics;
    using System.Net.Http;
    using System.Threading.Tasks;
    
    using CodeMvcApp.Models;
    
    using IdentityModel.Client;
    
    using Microsoft.AspNetCore.Authentication;
    using Microsoft.AspNetCore.Authorization;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.Extensions.Logging;
    using Microsoft.IdentityModel.Protocols.OpenIdConnect;
    
    public async Task<IActionResult> Index()
    {
        HttpClient client = new HttpClient();
        DiscoveryDocumentResponse disco = await client.GetDiscoveryDocumentAsync("http://localhost:5000/");
        if (disco.IsError)
        {
            throw new Exception(disco.Error);
        }
    
        string accessToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
        client.SetBearerToken(accessToken);
    
        HttpResponseMessage response = await client.GetAsync("http://localhost:6000/WeatherForecast");
        if (!response.IsSuccessStatusCode)
        {
            throw new Exception(response.ReasonPhrase);
        }
    
        string content = await response.Content.ReadAsStringAsync();
        return View("Index", content);
    }

    修改Index.cshtml来显示访问API的结果

    @{
        ViewData["Title"] = "Home Page";
    }
    @model string
    
    <div class="text-center">
        <h1 class="display-4">Welcome</h1>
        <p>Learn about <a href="https://docs.microsoft.com/aspnet/core">building Web apps with ASP.NET Core</a>.</p>
    </div>
    
    <h2>Api Resource Result:</h2>
    <p>@Model</p>

    修改一下HomeController的Privacy方法

    public async Task<IActionResult> Privacy()
    {
        string accessToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
        string idToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.IdToken);
    
        string refreshToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.RefreshToken);
        //code只能使用一次,所以获取不到
        //string code = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.Code);
    
        ViewData["accessToken"] = accessToken;
        ViewData["idToken"] = idToken;
        ViewData["refreshToken"] = refreshToken;
    
        return View();
    }

    修改Privacy.cshtml来显示token相关信息和用户身份声明相关信息

    @{
        ViewData["Title"] = "Privacy Policy";
    }
    <h1>@ViewData["Title"]</h1>
    
    <h2>Access Token:</h2>
    <p>@ViewData["accessToken"]</p>
    
    <h2>Id Token:</h2>
    <p>@ViewData["idToken"]</p>
    
    <h2>Refresh Token:</h2>
    <p>@ViewData["refreshToken"]</p>
    
    <h2>Claims:</h2>
    <dl>
        @foreach (var claim in User.Claims)
        {
            <dt>@claim.Type</dt>
            <dd>@claim.Value</dd>
        }
    </dl>

    重新启动MVC客户端,成功获取access_token,并使用access_tokem访问受保护的API资源

    > Home/Index

    > Home/Policy

    6、显示登录的用户,并实现登出

    修改Views/Shared/_Layout.cshtml,增加当前登录用户名称和登出按钮的显示

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>@ViewData["Title"] - CodeMvcApp</title>
        <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
        <link rel="stylesheet" href="~/css/site.css" />
    </head>
    <body>
        <header>
            <nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
                <div class="container">
                    <a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">CodeMvcApp</a>
                    <button class="navbar-toggler" type="button" data-toggle="collapse" data-target=".navbar-collapse" aria-controls="navbarSupportedContent"
                            aria-expanded="false" aria-label="Toggle navigation">
                        <span class="navbar-toggler-icon"></span>
                    </button>
                    <div class="navbar-collapse collapse d-sm-inline-flex flex-sm-row-reverse">
                        <ul class="navbar-nav flex-grow-1" style="position: relative;">
                            <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>
                            @if (User.Identity.IsAuthenticated)
                            {
                                <li class="nav-item" style="position: absolute; right: 0;">
                                    <span>Welcome,@User.Claims.FirstOrDefault(x => x.Type.Equals("given_name")).Value</span>
                                    <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Logout" style="display: inline-block;">Logout</a>
                                </li>
                            }
                        </ul>
                    </div>
                </div>
            </nav>
        </header>
        <div class="container">
            <main role="main" class="pb-3">
                @RenderBody()
            </main>
        </div>
    
        <footer class="border-top footer text-muted">
            <div class="container">
                &copy; 2020 - CodeMvcApp - <a asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
            </div>
        </footer>
        <script src="~/lib/jquery/dist/jquery.min.js"></script>
        <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
        <script src="~/js/site.js" asp-append-version="true"></script>
        @RenderSection("Scripts", required: false)
    </body>
    </html>
    View Code

    修改HomeController,增加Logout方法

    using Microsoft.AspNetCore.Authentication.Cookies;
    using Microsoft.AspNetCore.Authentication.OpenIdConnect;
    
    public async Task Logout()
    {
        await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        await HttpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme);
    }
    View Code

    重新运行项目,导航栏右侧就显示了当前用户名和登出按钮

     点击Logout登出,跳转到了IdentityServer认证服务器的登出页面(Account/Logout),此时已经登出了,但是界面停在了IdentityServer的注销成功页面

    点击“here”,可以跳转到MVC客户端,但是不是很友好

     此时我们打开IdentityServer认证服务器地址:http://localhost:5000,看到IdentityServer认证服务器的用户已经显示被注销

    然后来解决上面不友好的问题,修改IdentityServer服务器,打开Quickstart/Account/AccountOptions.cs,将AutomaticRedirectAfterSignOut设置为true,即登出后自动跳转

    修改完成后重启IdentityServer认证服务器,再重启MVC客户端即可解决。

    7、为MVC客户端刷新Token

    在IdentityServer认证服务器Config.cs中MVC客户端做下修改,加上访问令牌的过期时间(或者叫生存期)

    在API项目WebApplication1的Startup.cs/ConfigureServices/AddJwtBearer的options中添加两个参数

    因为Jwt验证token时间偏移默认为5分钟,会出现token过期了还能访问Api资源的问题,只有到了验证token的时间偏移,才会禁止访问Api

    所以我们设置为1分钟偏移,并启用必须设置token的过期时间选项,以防止token过期了还能对Api进行访问

    但是还是会存在一个时间差,比如token已经过期了,API验证token时间还没有到,这个没有什么办法,API可以把验证token的时间设置更短一些,但是也会消耗过多的资源,所以根据实际情况来设置

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers();
    
        //将身份验证服务添加到DI并配置Bearer为默认方案。
        services.AddAuthentication("Bearer")
            .AddJwtBearer("Bearer", options =>
            {
                //指定授权地址
                options.Authority = "http://localhost:5000";
                //获取或设置元数据地址或权限是否需要HTTPS。默认值为true。这应该只在开发环境中禁用。
                options.RequireHttpsMetadata = false;
                //获取或设置任何接收到的OpenIdConnect令牌的访问群体。
                options.Audience = "api1";
    
                //设置验证时间时要应用的时钟偏移,即token多久验证一次,默认为5分钟
                options.TokenValidationParameters.ClockSkew = TimeSpan.FromMinutes(1);
                //指示令牌是否必须具有“过期”值
                options.TokenValidationParameters.RequireExpirationTime = true;
            });
    }
    View Code

    然后重新启动IdentityServer认证服务器、Api资源项目WebApplication1、MVC客户端,进入到MVC客户端首页后,等1分钟之后再刷新

    出现了错误,401 Unauthorized未授权,原因是access_token已过期

    接下来实现刷新token,在HomeController定义一个方法RenewTokenAsync,用于刷新访问令牌

    /// <summary>
    /// 更新/刷新令牌
    /// </summary>
    /// <returns>访问令牌</returns>
    public async Task<string> RenewTokenAsync()
    {
        HttpClient client = new HttpClient();
        DiscoveryDocumentResponse disco = await client.GetDiscoveryDocumentAsync("http://localhost:5000/");
        if (disco.IsError)
        {
            throw new Exception(disco.Error);
        }
    
        //获取刷新令牌
        string refreshToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.RefreshToken);
    
        //根据刷新令牌重新获取访问令牌
        TokenResponse tokenResponse = await client.RequestRefreshTokenAsync(new RefreshTokenRequest
        {
            Address = disco.TokenEndpoint,
            ClientId = "mvc client",
            ClientSecret = "mvc secret",
            Scope = "api1 openid profile email phone address",
            GrantType = OpenIdConnectGrantTypes.RefreshToken,
            RefreshToken = refreshToken
        });
        if (tokenResponse.IsError)
        {
            throw new Exception(tokenResponse.Error);
        }
        else
        {
            //重新计算过期时间(当前时间+token的有效期秒)
            var expiresAt = DateTime.UtcNow + TimeSpan.FromSeconds(tokenResponse.ExpiresIn);
            //定义令牌集合,用于重新更新令牌
            var tokens = new[]
            {
                //重新设置身份令牌
                new AuthenticationToken
                {
                    Name = OpenIdConnectParameterNames.IdToken,
                    Value = tokenResponse.IdentityToken
                },
                //重新设置访问令牌
                new AuthenticationToken
                {
                    Name = OpenIdConnectParameterNames.AccessToken,
                    Value = tokenResponse.AccessToken
                },
                //重新设置刷新令牌
                new AuthenticationToken
                {
                    Name = OpenIdConnectParameterNames.RefreshToken,
                    Value = tokenResponse.RefreshToken
                },
                //重新设置过期时间
                new AuthenticationToken
                {
                    Name = "expires_at",
                    Value = expiresAt.ToString("o", CultureInfo.InvariantCulture)
                }
            };
    
            //获取身份认证的结果,包含当前的用户标识声明主体(Principal)+会话的其他状态值(Properties)
            var currentAuthenticateResult =
                await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
            //将存储的token都重新更新一遍(将新的tokens存起来)
            currentAuthenticateResult.Properties.StoreTokens(tokens);
    
            //将当前身份认证结果(用户标识声明主体+会话的其他状态值)代入,重新执行登录动作
            await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
                currentAuthenticateResult.Principal,//身份验证的用户标识的声明主体
                currentAuthenticateResult.Properties//身份验证会话的其他状态值
            );
    
            return tokenResponse.AccessToken;
        }
    }

    在HomeController/Index方法中,增加判断条件,调用API资源不成功时,判断如果响应的结果状态码是401 Unauthorized未授权,则重新刷新令牌并重定向到当前Action,即Home/Index

    重新运行MVC客户端,即可查验效果

    当token过期,会重新获取access_token并更新存储的tokens,重定向到当前Action,即刷新,刷新时就重新调用了API资源,此时token是刷新后的token,就能正常的访问API资源了

    Over, Thanks!!!

    【参考资料】

    微软MVP杨旭老师的IdentityServer4哔哩哔哩教学视频:Identity Server 4 原理和实战

  • 相关阅读:
    每日日报
    每日日报
    每日日报
    每日日报
    每日日报
    动手动脑2
    动手动脑3
    每日日报
    每周总结
    Java学习
  • 原文地址:https://www.cnblogs.com/jardeng/p/12814477.html
Copyright © 2011-2022 走看看