目录
前言:
- 适用于保密客户端(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);
}
以上代码没有做很好的逻辑判断,需要自行判断。