目錄
前言:
- 適用於保密客戶端(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的解決方案和其下的MvcClient、Api、IdentityServer三個項目。
二、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 登出
}
上面代碼的 Cookies
和 oidc
兩個字符串是有來源的,他們都是在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);
}
以上代碼沒有做很好的邏輯判斷,需要自行判斷。