一、前言
先交代一下整個Demo專案結構:
- 一個認證服務(埠5000)
IdentityServer4.Authentication
- 五個授權模式(兩個控制檯程式,三個MVC專案埠5001)資料夾
GrantClient
- 兩個資源服務(WebApi:UserApiResource埠8000,ProductApiResource埠9000)資料夾
ApiResource
二、準備認證服務 + 資源服務
1、認證服務
(1)新建一個MVC專案,安裝 IdentityServer4 ,註冊五種授權模式客戶端,程式碼如下
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
services.AddIdentityServer()
.AddDeveloperSigningCredential() //臨時證照
.AddInMemoryClients(InMemoryConfig.GetClients()) //客戶端模式,InMemory記憶體資料
.AddInMemoryApiScopes(InMemoryConfig.GetApiScopes()) //作用域
.AddInMemoryApiResources(InMemoryConfig.GetApiResources()) //資源
.AddTestUsers(InMemoryConfig.GetTestUser()) //使用者
.AddInMemoryIdentityResources(InMemoryConfig.IdentityResources);
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
}
app.UseIdentityServer(); //使用IdentityServer4
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}
}
public class InMemoryConfig
{
public static IEnumerable<IdentityResource> IdentityResources =>
new IdentityResource[]
{
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
//new IdentityResources.Email(),
//new IdentityResources.Address(),
//new IdentityResources.Phone()
};
/// <summary>
/// ApiResource 資源列表
/// </summary>
public static IEnumerable<ApiResource> GetApiResources()
{
return new[]
{
new ApiResource("UserApiResource", "獲取使用者資訊API")
{
Scopes={ "UserScope" }
},
new ApiResource("ProductApiResource", "獲取商品資訊API")
{
Scopes={ "ProductScope" }
}
};
}
/// <summary>
/// ApiScopes 作用域
/// </summary>
public static IEnumerable<ApiScope> GetApiScopes()
{
return new ApiScope[]
{
new ApiScope("UserScope"),
new ApiScope("ProductScope")
};
}
/// <summary>
/// Client 客戶端
/// </summary>
public static IEnumerable<Client> GetClients()
{
return new[]
{
//客戶端模式
new Client
{
ClientId = "ClientCredentials",
ClientName = "ClientCredentials",
ClientSecrets = new [] { new Secret("ClientCredentials".Sha256()) },
AllowedGrantTypes = GrantTypes.ClientCredentials,
AllowedScopes = new [] { "UserScope" }
},
//密碼模式
new Client
{
ClientId = "ResourceOwnerPasswordCredentials",
ClientName = "ResourceOwnerPasswordCredentials",
ClientSecrets = new [] { new Secret("ResourceOwnerPasswordCredentials".Sha256()) },
AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
AllowedScopes = new []
{
"ProductScope",
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
}
},
//簡化模式
new Client
{
ClientId = "Implicit",
ClientName = "Implicit",
AllowedGrantTypes = GrantTypes.Implicit,
RedirectUris = { "https://localhost:5001/signin-oidc" },
PostLogoutRedirectUris = { "https://localhost:5001/signout-callback-oidc" },
RequireConsent = true,
AllowedScopes = new []{
"UserScope",
"ProductScope",
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
}
},
//授權碼模式
new Client
{
ClientId = "AuthorizationCode",
ClientName = "AuthorizationCode",
ClientSecrets = new [] { new Secret("AuthorizationCode".Sha256()) },
AllowedGrantTypes = GrantTypes.Code,
RedirectUris = { "https://localhost:5001/signin-oidc" },
PostLogoutRedirectUris = { "https://localhost:5001/signout-callback-oidc" },
RequireConsent = true,
AllowedScopes = new []{
"UserScope",
"ProductScope",
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
}
},
//混合模式
new Client
{
ClientId = "Hybrid",
ClientName = "Hybrid",
ClientSecrets = new [] { new Secret("Hybrid".Sha256()) },
AllowedGrantTypes = GrantTypes.Hybrid,
RedirectUris = { "https://localhost:5001/signin-oidc" },
PostLogoutRedirectUris = { "https://localhost:5001/signout-callback-oidc" },
RequireConsent = true,
RequirePkce = false,
AllowedScopes = new []{
"UserScope",
"ProductScope",
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
//IdentityServerConstants.StandardScopes.Email,
//IdentityServerConstants.StandardScopes.Address,
//IdentityServerConstants.StandardScopes.Phone
}
},
};
}
public static List<TestUser> GetTestUser()
{
return new List<TestUser>(){
new TestUser
{
SubjectId = "1",
Username = "WinterSir",
Password = "WinterSir",
Claims =
{
new Claim(JwtClaimTypes.Name,"WinterSir"),
new Claim(JwtClaimTypes.GivenName,"WinterSir"),
new Claim(JwtClaimTypes.FamilyName,"WinterSir-FamilyName"),
new Claim(JwtClaimTypes.Email,"641187567@qq.com"),
new Claim(JwtClaimTypes.EmailVerified,"true", ClaimValueTypes.Boolean),
new Claim(JwtClaimTypes.WebSite,"http://WinterSir.com"),
new Claim(JwtClaimTypes.Address,@" [ 'street_address': 'Chang Ping', 'locality': 'BeiJing' ,'postal_code’: 102206,'country': 'China'}",
IdentityServerConstants.ClaimValueTypes.Json)
}
}
};
}
}
(2)cmddotnet new is4ui
安裝Quickstart UI
模板,刪除原來 Controllers 中 HomeController 防止衝突,設定5000埠啟動
2、資源服務
新建兩個WebApi專案,安裝IdentityServer4.AccessTokenValidation
,分別修改Startup、Controller,設定8000、9000埠啟動
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "ProductApiResource", Version = "v1" });
});
//整合埠為5000的認證服務
services.AddAuthentication("Bearer")
.AddIdentityServerAuthentication(options =>
{
options.Authority = "https://localhost:5000";
options.ApiName = "ProductApiResource";
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "ProductApiResource v1"));
}
app.UseRouting();
app.UseAuthentication();//鑑權
app.UseAuthorization();//授權
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
}
三、授權模式
1、客戶端授權模式
客戶端模式(Client Credentials)指客戶端以自己的名義,而不是以使用者的名義,向"認證服務"進行認證。如果是提前約束好的客戶端,直接給你頒發令牌 token
安裝IdentityModel
class Program
{
/// <summary>
/// 客戶端模式(Client Credentials)
/// </summary>
/// <param name="args"></param>
static void Main(string[] args)
{
Console.WriteLine("***************** 客戶端模式(Client Credentials)*****************");
var client = new HttpClient();
var disco = client.GetDiscoveryDocumentAsync("https://localhost:5000/").Result;
if (disco.IsError)
{
Console.WriteLine(disco.Error);
return;
}
var tokenResponse = client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
{
Address = disco.TokenEndpoint,
ClientId = "ClientCredentials",
ClientSecret = "ClientCredentials",
Scope = "UserScope"
}).Result;
if (tokenResponse.IsError)
{
Console.WriteLine(tokenResponse.Error);
return;
}
Console.WriteLine("\nToken: " + tokenResponse.AccessToken);
var apiClient = new HttpClient();
apiClient.SetBearerToken(tokenResponse.AccessToken);
var response = apiClient.GetAsync("https://localhost:8000/User/Get").Result;
if (!response.IsSuccessStatusCode)
{
Console.WriteLine(response.StatusCode);
}
else
{
var content = response.Content.ReadAsStringAsync().Result;
Console.WriteLine("\n結果: " + content);
}
Console.ReadLine();
}
}
2、密碼模式
密碼模式(Resource Owner Password Credentials)中客戶端使用使用者提供的使用者名稱和密碼,向"認證服務"進行認證,有較高風險,通常只有在其他授權模式無法執行的情況下,才能考慮使用這種模式。相較於客戶端多了一個使用者角色。
安裝IdentityModel
static void Main(string[] args)
{
Console.WriteLine("***************** 密碼模式(Resource Owner Password credentials)***************** ");
var client = new HttpClient();
var disco = client.GetDiscoveryDocumentAsync("https://localhost:5000/").Result;
if (disco.IsError)
{
Console.WriteLine(disco.Error);
return;
}
var tokenResponse = client.RequestPasswordTokenAsync(new PasswordTokenRequest()
{
Address = disco.TokenEndpoint,
ClientId = "ResourceOwnerPasswordCredentials",
ClientSecret = "ResourceOwnerPasswordCredentials",
UserName = "WinterSir",
Password = "WinterSir",
Scope = "ProductScope",
}).Result;
if (tokenResponse.IsError)
{
Console.WriteLine(tokenResponse.Error);
return;
}
Console.WriteLine("\nToken: " + tokenResponse.AccessToken);
var apiClient = new HttpClient();
apiClient.SetBearerToken(tokenResponse.AccessToken);
var response = apiClient.GetAsync("https://localhost:9000/Product/Get").Result;
if (!response.IsSuccessStatusCode)
{
Console.WriteLine(response.StatusCode);
}
else
{
var content = response.Content.ReadAsStringAsync().Result;
Console.WriteLine("\n結果: " + content);
}
Console.ReadLine();
}
3、簡化模式
簡化模式(Implicit)比授權碼模式少了code環節,所有步驟在瀏覽器中完成,令牌對訪問者是可見的,且客戶端不需要認證,該模式是很不安全的,且不支援refresh token,適用於 Web 安全要求不高的場景,設定較短時效的 token。
(1)安裝IdentityServer4.AccessTokenValidation、Microsoft.AspNetCore.Authentication.OpenIdConnect
,修改Startup
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
//關閉Jwt對映
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
//註冊授權
services.AddAuthentication(options =>
{
options.DefaultScheme = "Cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies")
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://localhost:5000"; //認證服務
options.RequireHttpsMetadata = true; //必須使用Https,否則使用者無法登入
options.ClientId = "Implicit";
options.ClientSecret = "Implicit";
options.SaveTokens = true; //表示Token要儲存
});
services.AddControllersWithViews().AddRazorRuntimeCompilation();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
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.UseHttpsRedirection();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}
}
(2)_Layout.cshtml新增 登出按鈕
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<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>
</ul>
<a class="nav-link text-dark float-right" asp-area="" asp-controller="Home" asp-action="Logout">Logout</a>
</div>
(3)HomeController新增對應功能,需要認證的方法加上特性[Authorize]
[Authorize]
public IActionResult Privacy()
{
return View();
}
//登出
public IActionResult Logout()
{
return SignOut("Cookies", "oidc");
}
(4)修改Privacy.cshtml
@{
ViewData["Title"] = "Privacy Policy";
}
<h1>@ViewData["Title"]</h1>
@using Microsoft.AspNetCore.Authentication
<h2>Claims</h2>
<dl>
@foreach (var claim in User.Claims)
{
<dt>@claim.Type</dt>
<dd>@claim.Value</dd>
}
</dl>
<h2>Properties</h2>
<dl>
@foreach (var prop in (await Context.AuthenticateAsync()).Properties.Items)
{
<dt>@prop.Key</dt>
<dd>@prop.Value</dd>
}
</dl>
(5)效果圖
4、授權碼模式
授權碼模式(Authorization Code)不同於簡化模式直接返回token,而是先返回一個授權碼,再用授權碼去請求token,然後攜帶訪問Api資源。授權碼模式是功能最完整、流程最嚴密的授權模式。
(1)安裝IdentityServer4.AccessTokenValidation、Microsoft.AspNetCore.Authentication.OpenIdConnect
,修改Startup
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
//關閉Jwt對映
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
//註冊授權
services.AddAuthentication(options =>
{
options.DefaultScheme = "Cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies")
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://localhost:5000"; //認證服務
options.RequireHttpsMetadata = true; //必須使用Https,否則使用者無法登入
options.ClientId = "AuthorizationCode";
options.ClientSecret = "AuthorizationCode";
options.ResponseType = "code";
options.Scope.Clear();
options.Scope.Add("UserScope");
options.Scope.Add("ProductScope");
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.SaveTokens = true; //表示Token要儲存
});
services.AddControllersWithViews().AddRazorRuntimeCompilation();
services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.Encoder = JavaScriptEncoder.Create(UnicodeRanges.All);
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
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.UseHttpsRedirection();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}
}
(2)_Layout.cshtml新增 獲取使用者按鈕、登出按鈕
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<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>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="User">UserApi</a>
</li>
</ul>
<a class="nav-link text-dark float-right" asp-area="" asp-controller="Home" asp-action="Logout">Logout</a>
</div>
(3)HomeController新增對應功能,需要認證的方法加上特性[Authorize]
[Authorize]
public IActionResult Privacy()
{
return View();
}
[Authorize]
public async Task<IActionResult> User()
{
var client = new HttpClient();
var accessToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
if (string.IsNullOrEmpty(accessToken))
{
return Json(new { msg = "accesstoken 獲取失敗" });
}
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var httpResponse = await client.GetAsync("https://localhost:8000/User/Get");
var result = await httpResponse.Content.ReadAsStringAsync();
if (!httpResponse.IsSuccessStatusCode)
{
ViewBag.Result = new { msg = "請求 User/Get 失敗", error = result };
}
ViewBag.Result = new { msg = "成功", data = result };
return View();
}
//登出
public IActionResult Logout()
{
return SignOut("Cookies", "oidc");
}
(4)修改Privacy.cshtml
@{
ViewData["Title"] = "Privacy Policy";
}
<h1>@ViewData["Title"]</h1>
@using Microsoft.AspNetCore.Authentication
<h2>Claims</h2>
<dl>
@foreach (var claim in User.Claims)
{
<dt>@claim.Type</dt>
<dd>@claim.Value</dd>
}
</dl>
<h2>Properties</h2>
<dl>
@foreach (var prop in (await Context.AuthenticateAsync()).Properties.Items)
{
<dt>@prop.Key</dt>
<dd>@prop.Value</dd>
}
</dl>
(5)效果圖
5、混合模式
混合模式(Hybrid Flow)
它為我們提供了兩全其美的優勢,身份令牌通過瀏覽器傳輸,因此客戶端可以在進行任何更多工作之前對其進行驗證。如果驗證成功,客戶端會通過令牌服務的以獲取訪問令牌
(1)安裝IdentityServer4.AccessTokenValidation、Microsoft.AspNetCore.Authentication.OpenIdConnect
,修改Startup
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
//關閉Jwt對映
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
//註冊授權
services.AddAuthentication(options =>
{
options.DefaultScheme = "Cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies")
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://localhost:5000"; //認證服務
options.RequireHttpsMetadata = true; //必須使用Https,否則使用者無法登入
options.ClientId = "Hybrid";
options.ClientSecret = "Hybrid";
options.ResponseType = "code id_token";
options.Scope.Clear();
options.Scope.Add("UserScope");
options.Scope.Add("ProductScope");
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.0fflineAccess);//獲取到重新整理Token
options.SaveTokens = true; //表示Token要儲存
});
services.AddControllersWithViews().AddRazorRuntimeCompilation();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
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.UseHttpsRedirection();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}
}
(2)_Layout.cshtml新增 獲取產品、登出按鈕
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<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>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Product">ProductApi</a>
</li>
</ul>
<a class="nav-link text-dark float-right" asp-area="" asp-controller="Home" asp-action="Logout">Logout</a>
</div>
(3)HomeController新增對應功能,需要認證的方法加上特性[Authorize]
[Authorize]
public IActionResult Privacy()
{
return View();
}
[Authorize]
public async Task<IActionResult> Product()
{
var client = new HttpClient();
var accessToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
if (string.IsNullOrEmpty(accessToken))
{
return Json(new { msg = "accesstoken 獲取失敗" });
}
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var httpResponse = await client.GetAsync("https://localhost:9000/Product/Get");
var result = await httpResponse.Content.ReadAsStringAsync();
if (!httpResponse.IsSuccessStatusCode)
{
ViewBag.Result = new { msg = "請求 User/Get 失敗", error = result };
}
ViewBag.Result = new { msg = "成功", data = result };
return View();
}
//登出
public IActionResult Logout()
{
return SignOut("Cookies", "oidc");
}
(4)修改Privacy.cshtml
@{
ViewData["Title"] = "Privacy Policy";
}
<h1>@ViewData["Title"]</h1>
@using Microsoft.AspNetCore.Authentication
<h2>Claims</h2>
<dl>
@foreach (var claim in User.Claims)
{
<dt>@claim.Type</dt>
<dd>@claim.Value</dd>
}
</dl>
<h2>Properties</h2>
<dl>
@foreach (var prop in (await Context.AuthenticateAsync()).Properties.Items)
{
<dt>@prop.Key</dt>
<dd>@prop.Value</dd>
}
</dl>
(5)效果圖
四、問題踩坑
1、Https
Demo全部用的Https,Mvc客戶端配置RequireHttpsMetadata = true
如果使用http遇到認證服無法務登入問題,可參考以下地址
https://www.cnblogs.com/i3yuan/p/14033016.html#autoid-20-0-0
2、ResponseType
授權碼模式、混合模式需要修改客戶端配置ResponseType,ResponseType = "code" 、 ResponseType = "code id_token"
3、RequirePkce
混合模式需要修改對應服務端註冊客戶端時配置RequirePkce = false
,這樣不需要客戶端提供code challeng
4、其他Error
出現錯誤大概率是客戶端、服務端配置項問題,仔細對比一下就OK了
五、前人栽樹,後人乘涼
https://www.cnblogs.com/i3yuan/category/1777690.html