使用 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);
    }

以上代碼沒有做很好的邏輯判斷,需要自行判斷。


參考文檔

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章