使用 IdentityServer4 授权码(Authorization Code)保护 ASP.NET Core 客户端并访问被保护资源

前言:

  • 适用于保密客户端(Confidential Client)比如ASP. NET MVC等服务器端渲染的Web应用


一、创建项目

创建项目时用的命令:

$ mkdir Tutorial-Plus
$ cd Tutorial-Plus
$ mkdir src
$ cd src
$ dotnet new mvc -n MvcClient --no-https
$ dotnet new api -n Api --no-https
$ dotnet new is4inmem -n IdentityServer
$ cd ..
$ dotnet new sln -n Tutorial-Plus
$ dotnet sln add ./src/MvcClient/MvcClient.csproj
$ dotnet sln add ./src/Api/Api.csproj
$ dotnet sln add ./src/IdentityServer/IdentityServer.csproj

此时创建好了名为Tutorial-Plus的解决方案和其下的MvcClientApiIdentityServer三个项目。

二、Api 项目

修改 Api 项目的启动端口为 5001

1) 配置 Startup.cs

将 Api 项目的 Startup.cs 修改为如下。

    public class Startup
    {
    
    
        public Startup(IConfiguration configuration)
        {
    
    
            Configuration = configuration;
        }

        public IConfiguration Configuration {
    
     get; }

        public void ConfigureServices(IServiceCollection services)
        {
    
    
            services.AddMvcCore().AddAuthorization().AddJsonFormatters();
            services.AddAuthentication("Bearer")
                .AddJwtBearer("Bearer", options =>
                {
    
    
                    options.Authority = "http://localhost:5000"; // IdentityServer的地址
                    options.RequireHttpsMetadata = false; // 不需要Https

                    options.Audience = "api1"; // 和资源名称相对应
                });
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
    
    
            app.UseAuthentication();
            app.UseMvc();
        }
    }

2) IdentityController.cs 文件

将 Controllers 文件夹中的 ValuesController.cs 改名为 IdentityController.cs
并将其中代码修改为如下:

    [Route("[controller]")]
    [ApiController]
    [Authorize]
    public class IdentityController : ControllerBase
    {
    
    
        [HttpGet]
        public IActionResult Get()
        {
    
    
            return new JsonResult(from c in User.Claims select new {
    
     c.Type, c.Value });
        }
    }

三、IdentityServer 项目

修改 IdentityServer 项目启动端口为 5000

1) 将 json config 修改为 code config

在 IdentityServer 项目的 Startup.cs 文件的 ConfigureServices 方法中,
找到以下代码:

    // in-memory, code config
    //builder.AddInMemoryIdentityResources(Config.GetIdentityResources());
    //builder.AddInMemoryApiResources(Config.GetApis());
    //builder.AddInMemoryClients(Config.GetClients());
    
    // in-memory, json config
    builder.AddInMemoryIdentityResources(Configuration.GetSection("IdentityResources"));
    builder.AddInMemoryApiResources(Configuration.GetSection("ApiResources"));
    builder.AddInMemoryClients(Configuration.GetSection("clients"));

将其修改为

    // in-memory, code config
    builder.AddInMemoryIdentityResources(Config.GetIdentityResources());
    builder.AddInMemoryApiResources(Config.GetApis());
    builder.AddInMemoryClients(Config.GetClients());
    
    // in-memory, json config
    //builder.AddInMemoryIdentityResources(Configuration.GetSection("IdentityResources"));
    //builder.AddInMemoryApiResources(Configuration.GetSection("ApiResources"));
    //builder.AddInMemoryClients(Configuration.GetSection("clients"));

以上修改的内容为将原来写在配置文件中的配置,改为代码配置。

2) 修改 Config.cs 文件

将 Config.cs 文件的 GetIdentityResources() 方法修改为如下:

    // 被保护的 IdentityResource
    public static IEnumerable<IdentityResource> GetIdentityResources()
    {
    
    
        return new IdentityResource[]
        {
    
    
            // 如果要请求 OIDC 预设的 scope 就必须要加上 OpenId(),
            // 加上他表示这个是一个 OIDC 协议的请求
            // Profile Address Phone Email 全部是属于 OIDC 预设的 scope
            new IdentityResources.OpenId(),
            new IdentityResources.Profile(),
            new IdentityResources.Address(),
            new IdentityResources.Phone(),
            new IdentityResources.Email()
        };
    }

将 Config.cs 文件的 GetClients() 方法修改为如下:

    public static IEnumerable<Client> GetClients()
    {
    
    
        return new[] 
        {
    
    
            // client credentials flow client
            new Client
            {
    
    
                ClientId = "mvc client",
                ClientName = "Client Credentials Client",
            
                AllowedGrantTypes = GrantTypes.Code,
                ClientSecrets = {
    
     new Secret("511536EF-F270-4058-80CA-1C89C192F69A".Sha256()) },
            
                RedirectUris = {
    
     "http://localhost:5002/signin-oidc" },
            
                FrontChannelLogoutUri = "http://localhost:5002/signout-oidc",
            
                PostLogoutRedirectUris = {
    
     "http://localhost:5002/signout-callback-oidc" },
            
                // 设置UserClaims添加到idToken中,而不是client需要重新使用用户端点去请求
                AlwaysIncludeUserClaimsInIdToken = true,
            
                // 允许离线访问,指是否可以申请 offline_access,刷新用的 token
                AllowOfflineAccess = true,
            
                AllowedScopes = new List<string>
                {
    
    
                    "api1",
                    IdentityServerConstants.StandardScopes.OpenId,
                    IdentityServerConstants.StandardScopes.Profile,
                    IdentityServerConstants.StandardScopes.Address,
                    IdentityServerConstants.StandardScopes.Phone,
                    IdentityServerConstants.StandardScopes.Email,
                }
            }
        };
    }

值得注意的是在配置 Client 时,AlwaysIncludeUserClaimsInIdToken属性是一个不太重要的属性,
但是在有的环境中却非常有用,
譬如在MVC客户端中,如果我在IdentityServer的Client设置该属性为 true,
我可以直接通过 User.Claims 获得用户的信息:

	// 这是 mvcClient 的代码
	<dl>
    	@foreach (var claim in User.Claims)
    	{
        	<dt>@claim.Type</dt>
        	<dd>@claim.Value</dd>
    	}
	</dl>

如果AlwaysIncludeUserClaimsInIdToken属性设置为 false,
我们就需要自己去IdentityServer的用户端点获取UserClaims:

	// 这是 mvcClient 的代码
    var client = new HttpClient();
    var disco = await client.GetDiscoveryDocumentAsync("http://localhost:5000");
    var accessToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
    UserInfoResponse response = await client.GetUserInfoAsync(new UserInfoRequest
    {
    
    
        Address = disco.UserInfoEndpoint, // 用户端点
        Token = accessToken
    });
    var UserClaims = response.Claims;

四、mvcClient 项目

修改 mvcClient 项目启动端口为 5002
添加 NuGet 包 IdentityModel

1) 修改 Startup.cs 文件

将 Startup.cs 修改为如下:

    public class Startup
    {
    
    
        public Startup(IConfiguration configuration)
        {
    
    
            Configuration = configuration;
        }

        public IConfiguration Configuration {
    
     get; }

        public void ConfigureServices(IServiceCollection services)
        {
    
    
            services.Configure<CookiePolicyOptions>(options =>
            {
    
    
                options.CheckConsentNeeded = context => true;
                options.MinimumSameSitePolicy = SameSiteMode.None;
            });
            
            services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

            // 关闭Jwt的Claim类型映射,以便允许 well-known claims (e.g. ‘sub’ and ‘idp’) 
            // 如果不关闭就会修改从授权服务器返回的 Claim
            JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();

            // 2) 将身份验证服务添加到DI
            services.AddAuthentication(options =>
            {
    
    
                // 使用cookie来本地登录用户(通过DefaultScheme = "Cookies")
                options.DefaultScheme = "Cookies";
                // 设置 DefaultChallengeScheme = "oidc" 时,表示我们使用 OIDC 协议
                options.DefaultChallengeScheme = "oidc";
            })
            // 我们使用添加可处理cookie的处理程序
            .AddCookie("Cookies")
            // 配置执行OpenID Connect协议的处理程序
            .AddOpenIdConnect("oidc", options =>
            {
    
    
                // 
                options.SignInScheme = "Cookies";
                // 表明我们信任IdentityServer客户端
                options.Authority = "http://localhost:5000";
                // 表示我们不需要 Https
                options.RequireHttpsMetadata = false;
                // 用于在cookie中保留来自IdentityServer的 token,因为以后可能会用
                options.SaveTokens = true;

                options.ClientId = "mvc client";
                options.ClientSecret = "511536EF-F270-4058-80CA-1C89C192F69A"; 
                options.ResponseType = "code"; // Authorization Code

                options.Scope.Clear();
                options.Scope.Add("api1");
                options.Scope.Add("openid");
                options.Scope.Add("profile");
                options.Scope.Add("address");
                options.Scope.Add("phone");
                options.Scope.Add("email");
                // Scope中添加了OfflineAccess后,就可以在 Action 中获得 refreshToken
                options.Scope.Add(StandardScopes.OfflineAccess);
            });

        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
    
    
            if (env.IsDevelopment())
                app.UseDeveloperExceptionPage();
            
        	// 管道中加入身份验证功能
            app.UseAuthentication();
            app.UseStaticFiles();
            app.UseCookiePolicy();
            app.UseMvcWithDefaultRoute();
        }
    }

上面代码注释比较完全。

2) 查看 Token

我们将 HomeController 中的 Privacy() 方法的代码修改为如下:

	[Authorize]
    public async Task<IActionResult> Privacy()
    {
    
    
        var idToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.IdToken);
        var accessToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
        
        // 想要获得 refreshToken 必须在MVC客户端的 Scope 单独添加 OfflineAccess
        var refreshToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.RefreshToken);
    
        ViewData["accessToken"] = accessToken;
        ViewData["idToken"] = idToken;
        ViewData["refreshToken"] = refreshToken;
    
        return View();
    }

将对应的 视图页 修改为如下:

    @{
        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>
    <dl>
        @foreach (var claim in User.Claims)
        {
            <dt>@claim.Type</dt>
            <dd>@claim.Value</dd>
        }
    </dl>

在上文说过,如果我们在 IdentityServer 中设置 Clien t时,
如果将AlwaysIncludeUserClaimsInIdToken设置为true,
那么我们在这里遍历 User.Claims 时就可以将用户的 Claim 遍历出来,
如果设置为false,这个时候 User.Claims 只有基本的信息, Id 等。
这个时候我们要获取 用户的 Claims 就需要手动去请求了,代码在上文已经展示过了。

3) 访问被保护的Api

我们将 HomeController 中的 Index() 方法的代码修改为如下:

	[Authorize]
    public async Task<IActionResult> Index()
    {
    
    
        var client = new HttpClient();
        var disco = await client.GetDiscoveryDocumentAsync("http://localhost:5000/");
        if (disco.IsError)
            throw new Exception(disco.Error);
    
        var accessToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
    
        client.SetBearerToken(accessToken);
    
        var response = await client.GetAsync("Http://localhost:5001/identity");
        if (!response.IsSuccessStatusCode)
            throw new Exception(response.ReasonPhrase);
    
        ViewData["content"] = await response.Content.ReadAsStringAsync();
    
        return View();
    }

将对应的 视图页 修改为如下:

    @{
        ViewData["Title"] = "Home Page";
    }

    <div class="text-center">
        <p>@ViewData["content"]</p>
    </div>

以上代码展示了如何访问被保护的API

4) 登出

登出由两部分组成,第一是 MVC网站用户登出,第二是IdentityServer用户登出。

首先我们在HomeController控制器里面新建一个Action:

	public async Task Logout()
	{
    
    
		await HttpContext.SignOutAsync("Cookies"); // MVC 登出
		await HttpContext.SignOutAsync("oidc"); // IdentityServer4 登出
	}

上面代码的 Cookiesoidc 两个字符串是有来源的,他们都是在mvcClient项目的 Startup.cs 的 ConfigureServices() 方法中定义的。

然后在模板页 _Layout.cshtml 中写入登出的按钮,写入的位置在 nav 中:

    <div class="navbar-collapse collapse d-sm-inline-flex flex-sm-row-reverse">
        <ul class="navbar-nav flex-grow-1">
            <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">
                    <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Logout">Logout</a>
                </li>
            }
        </ul>
    </div>

如上所示 在Privacy按钮后面 如果登录以后就加入一个 Logout 按钮。
这样就完成了登出的功能。

五、其他

1) mvcClient退出后自动重定向

在我们点击 Logout 按钮登出以后,他会提示我们已经注销了,这个时候在IdentityServer的页面中,
需要我们手动点击才能返回 mvcClient 的页面,这样不太友好,只需改设置就可自动重定向
我们打开IdentityServer项目的 Quickstart/Account/AccountOptions.cs文件,
AutomaticRedirectAfterSignOut属性修改为true

	public static bool AutomaticRedirectAfterSignOut = true;

这样即可自动重定向了。

2) 使用RefreshToken刷新AccessToken

Access Token的生命周期默认为一个小时,我们为了测试效果将其改为60秒,
直接在IdentityServer项目的Config.cs的配置mvc client的配置中加入此代码:

    AccessTokenLifetime = 60, // 修改AccessToken生命周期为 60S

然后我们修改Api项目中的 DI 的 JwtBearer配置:

    services.AddAuthentication("Bearer")
        .AddJwtBearer("Bearer", options =>
        {
    
    
            options.Authority = "http://localhost:5000"; // IdentityServer的地址
            options.RequireHttpsMetadata = false; // 不需要Https
            
            options.Audience = "api1"; // 和资源名称相对应
            // 多长时间来验证以下 Token
            options.TokenValidationParameters.ClockSkew = TimeSpan.FromMinutes(1);
            // 我们要求 Token 需要有超时时间这个参数
            options.TokenValidationParameters.RequireExpirationTime = true;
        });

如上所示我们加入了两个配置,
一个是多长时间验证 Token,另一个是我们要求 Token 需要有超时时间这个参数。

现在我们开始进行刷新 Token 的操作
先再mvcClient项目中的HomeController控制器中加入如下方法:

    // 当token失效,请求新的token
    private async Task<string> RenewTokensAsync()
    {
    
    
        // 得到发现文档
        var client = new HttpClient();
        var disco = await client.GetDiscoveryDocumentAsync("http://localhost:5000/");
        if (disco.IsError)
            throw new Exception(disco.Error);
    
        // 得到 RefreshToken 
        var refreshToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.RefreshToken);
    
        //刷新 Access Token
        var tokenResponse = await client.RequestRefreshTokenAsync(new RefreshTokenRequest
        {
    
    
            Address = disco.TokenEndpoint,
            ClientId = "mvc client",
            ClientSecret = "511536EF-F270-4058-80CA-1C89C192F69A",
            Scope = $"api1 openid profile address email phone",
            GrantType = OpenIdConnectGrantTypes.RefreshToken,
            RefreshToken = refreshToken,
        });
    
        if (tokenResponse.IsError)
        {
    
    
            throw new Exception(tokenResponse.Error);
        }
        else
        {
    
    
            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)
                }
            };
            // 获取身份认证的结果,包含当前的pricipal和 properties
            var currentAuthenticateResult = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
    
            // 把新的tokens存起来
            currentAuthenticateResult.Properties.StoreTokens(tokens);
    
            // 登陆
            await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
                currentAuthenticateResult.Principal, currentAuthenticateResult.Properties);
    
            return tokenResponse.AccessToken;
        }
    }

以上代码还需要进行整理,有的地方没有进行判断

我们写好刷新Token的方法以后,
我们对 HomeController 中的 Index() 方法中的代码:

	if (!response.IsSuccessStatusCode)
		throw new Exception(response.ReasonPhrase);

将其修改为:

    if (!response.IsSuccessStatusCode)
    {
    
    
        if (response.StatusCode == HttpStatusCode.Unauthorized)
        {
    
    
            await RenewTokensAsync();
            return RedirectToAction();
        }
        throw new Exception(response.ReasonPhrase);
    }

以上代码没有做很好的逻辑判断,需要自行判断。


参考文档

猜你喜欢

转载自blog.csdn.net/Upgrader/article/details/90047685