JWT 安全 第三部分 - 保护 MVC 应用程序





5.00/5 (7投票s)
了解如何创建 JWT 并将其与使用 .NET Core 构建的 WebApi、REST 和 MVC 一起使用
引言
这是关于 JWT(JSON Web Token)的第三篇也是最后一篇博客。在第一篇博客中,我解释了如何创建 JWT;在第二篇中,我们保护了 REST 服务。在这最后一篇博客中,我们使用 JWT 保护 Web 应用程序,并涵盖以下主题
- 设置和配置 JWT
- 设置身份验证 Cookie
- 连接到 JWT 颁发者
- JWT 存储
- 身份验证 Cookie
ClaimPrincipalManager
- 滑动过期
- Razor 视图中的策略
- 访问 REST 服务
JWT 安全性概述
在我们深入细节之前,首先回顾一下第一部分和第二部分。JWT 颁发者和 REST 服务已启动并运行。
设置和配置 JWT
网站和 REST 服务的 JWT 设置和配置是相同的。首先是添加 Microsoft.AspNetCore.Authentication.JwtBearer
包。JWT 包需要在 startup.cs 中配置。首先,我们在 appsettings.json 中设置参数。
...
"JwtTokenValidationSettings": {
"ValidIssuer": "JwtServer",
"ValidateIssuer": true,
"SecretKey": "@everone:KeepitSecret!"
},
...
SecretKey
值必须与 JWT 颁发者服务器中的密钥匹配,否则用户将保持未认证状态并被拒绝访问。DI(依赖注入)提供对配置的访问
public void ConfigureServices(IServiceCollection services)
{
...
// setup JWT Token validation
services.Configure<JwtTokenValidationSettings>
(Configuration.GetSection(nameof(JwtTokenValidationSettings)));
services.AddSingleton<IJwtTokenValidationSettings,
JwtTokenValidationSettingsFactory>();
...
JwtTokenValidationSettingsFactory
实现了接口并具有 TokenValidationParameters
函数。
public class JwtTokenValidationSettings
{
public String ValidIssuer { get; set; }
public Boolean ValidateIssuer { get; set; }
public String ValidAudience { get; set; }
public Boolean ValidateAudience { get; set; }
public String SecretKey { get; set; }
}
public interface IJwtTokenValidationSettings
{
String ValidIssuer { get; }
Boolean ValidateIssuer { get; }
String ValidAudience { get; }
Boolean ValidateAudience { get; }
String SecretKey { get; }
TokenValidationParameters CreateTokenValidationParameters();
}
public class JwtTokenValidationSettingsFactory : IJwtTokenValidationSettings
{
private readonly JwtTokenValidationSettings settings;
public String ValidIssuer => settings.ValidIssuer;
public Boolean ValidateIssuer => settings.ValidateIssuer;
public String ValidAudience => settings.ValidAudience;
public Boolean ValidateAudience => settings.ValidateAudience;
public String SecretKey => settings.SecretKey;
public JwtTokenValidationSettingsFactory
(IOptions<JwtTokenValidationSettings> options)
{
settings = options.Value;
}
public TokenValidationParameters CreateTokenValidationParameters()
{
var result = new TokenValidationParameters
{
ValidateIssuer = ValidateIssuer,
ValidIssuer = ValidIssuer,
ValidateAudience = ValidateAudience,
ValidAudience = ValidAudience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(SecretKey)),
RequireExpirationTime = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
};
return result;
}
}
TokenValidationParameters
函数(顾名思义)返回 JWT 验证参数。这些参数在启动期间使用
public void Configure(IApplicationBuilder app, IHostingEnvironment env,
ILoggerFactory loggerFactory)
{
...
// Create TokenValidation factory with DI principle
var tokenValidationSettings =
app.ApplicationServices.GetService<IJwtTokenValidationSettings>();
// Setup JWT security
app.UseJwtBearerAuthentication(new JwtBearerOptions
{
AutomaticAuthenticate = true,
AutomaticChallenge = false, // not sure
TokenValidationParameters =
tokenValidationSettings.CreateTokenValidationParameters()
});
...
JWT 现在可以使用了。
从颁发者获取 JWT
我们需要连接到 JWT 颁发者才能获取令牌。连接参数在 appsettings.json 中设置。
// JWT Issuer
"JwtTokenIssuerSettings": {
"BaseAddress": "https://:49842/",
"Login": "/api/security/login/",
"RenewToken": "/api/security/renewtoken/"
},
Login
根据用户凭据提供 JWT,RenewToken
刷新有效票证的过期窗口。我稍后会更详细地解释这一点。应用程序借助 DI(依赖注入)读取配置。
public void ConfigureServices(IServiceCollection services)
{
...
// Setup JWT Issuer Settings
services.Configure<JwtTokenIssuerSettings>
(Configuration.GetSection(nameof(JwtTokenIssuerSettings)));
services.AddSingleton<IJwtTokenIssuerSettings, JwtTokenIssuerSettingsFactory>();
...
ClaimPrincipalManager
在 JWT 请求期间使用该设置。
JWT 存储
Web 应用程序天生是无状态的。Web 应用程序需要某种存储 JWT 的方式,否则每次页面请求都必须重新获取令牌。应用程序可以将 JWT 存储在
- HTML5 Web 存储,也称为本地存储
- Cookie
Stormpath 有一篇很棒的博客,其中详细解释了优缺点。Web 存储有一个很大的缺点,存储也对其他人开放,而 Web 应用程序将毫无知觉。这使得 Cookie 存储成为首选。
身份验证 Cookie
这可能看起来很奇怪,但是身份验证 Cookie 处理安全性,而不是 JWT。身份验证 Cookie 处理身份验证、授权并存储 JWT。身份验证 Cookie 托管在包“Microsoft.AspNetCore.Authentication.Cookies
”中。如果您想了解更多关于 Cookie 身份验证的信息,请观看视频教程。
登录过程归结为
- 收集凭据(电子邮件和密码)。
- 从颁发者获取 JWT。
- 从 JWT 创建用户和声明。
- 将 JWT 添加到用户声明。
- 使用用户和 Cookie 设置登录。
步骤 4,将原始令牌添加到用户声明不需要用于身份验证或授权目的,但提供了从用户中提取 JWT 的机会。提取的 JWT 用于访问 REST 服务和滑动过期。登录由 ClaimPrincipalManager
处理
public async Task<Boolean> LoginAsync(String email, String password)
{
// Fetch token from JWT issuer
var jwtToken = await FetchJwtToken(email, password);
return await Login(jwtToken);
}
private async Task<Boolean> Login(String jwtToken)
{
// No use if token is empty
if (jwtToken.IsNullOrEmpty())
return false;
// Logout first
await LogoutAsync();
// Setup handler for processing Jwt token
var tokenHandler = new JwtSecurityTokenHandler();
// Retrieve principal from Jwt token
var principal = tokenHandler.ValidateToken(jwtToken,
jwtTokenValidationSettings.CreateTokenValidationParameters(), out var validatedToken);
// Cast needed for accessing claims property
var identity = principal.Identity as ClaimsIdentity;
// parse jwt token to get all claims
var securityToken = tokenHandler.ReadToken(jwtToken) as JwtSecurityToken;
// Search for missed claims, for example claim 'sub'
var extraClaims = securityToken.Claims.Where
(c => !identity.Claims.Any(x => x.Type == c.Type)).ToList();
// Adding the original Jwt has 2 benefits:
// 1) Authenticate REST service calls with original Jwt
// 2) The original Jwt is available for renewing during sliding expiration
extraClaims.Add(new Claim("jwt", jwtToken));
// Merge claims
identity.AddClaims(extraClaims);
// Setup authenticates
// ExpiresUtc is used in sliding expiration
var authenticationProperties = new AuthenticationProperties()
{
IssuedUtc = identity.Claims.First
(c => c.Type == JwtRegisteredClaimNames.Iat)?.Value.ToInt64().ToUnixEpochDate(),
ExpiresUtc = identity.Claims.First
(c => c.Type == JwtRegisteredClaimNames.Exp)?.Value.ToInt64().ToUnixEpochDate(),
IsPersistent = false
};
// The actual Login
await httpContext.Authentication.SignInAsync
(authenticationSettings.AuthenticationScheme, principal, authenticationProperties);
return identity.IsAuthenticated;
}
ClaimPrincipalManager
ClaimPrincipalManager
提供对所有安全相关内容的轻松访问,并实现 IClaimPrincipalManager
接口。该接口在启动期间注册,并通过 DI(依赖注入)模式提供实例。ClaimPricipalManager
不属于任何包,它仅在 Web 应用程序中可用。
public interface IClaimPrincipalManager
{
String UserName { get; }
Boolean IsAuthenticated { get; }
ClaimsPrincipal User { get; }
Task<Boolean> LoginAsync(String email, String password);
Task LogoutAsync();
Task RenewTokenAsync(String jwtToken);
Task<Boolean> HasPolicy(String policyName);
}
滑动过期
JWT 过期是固定的,没有滑动功能。当 JWT 过期时,REST 服务调用将失败。Cookie 身份验证提供挂钩,我们可以注入自定义代码。算法很简单
- 在每个页面请求时检查 JWT 是否即将过期。
- 从颁发者获取续签的 JWT。
- 使用续签的 JWT 登录。
当过期过半或更多时,实现会刷新。使用续签的 JWT 登录也使 Cookie 身份验证过期滑动,并确保用户拥有最新的声明。
刷新挂钩在启动期间设置
public void Configure(IApplicationBuilder app, IHostingEnvironment env,
ILoggerFactory loggerFactory)
{
...
// Create TokenValidation factory with DI principle
var authenticationSettings =
app.ApplicationServices.GetService<IAuthenticationSettings>();
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationScheme = authenticationSettings.AuthenticationScheme,
LoginPath = authenticationSettings.LoginPath,
AccessDeniedPath = authenticationSettings.LoginPath,
AutomaticAuthenticate = true,
AutomaticChallenge = true,
// Set Refresh hook
Events = new CookieAuthenticationEvents
{
// Check if JWT needs refreshment
OnValidatePrincipal = RefreshTokenMonitor.ValidateAsync
}
});
...
新鲜挂钩只检查是否需要刷新
public static async Task ValidateAsync(CookieValidatePrincipalContext context)
{
// Find issued datetime
var issuedClaim = context.Principal.FindFirst
(c => c.Type == JwtRegisteredClaimNames.Iat)?.Value;
var issuedAt = issuedClaim.ToInt64().ToUnixEpochDate();
// Find expiration datetime
var expiresClaim = context.Principal.FindFirst
(c => c.Type == JwtRegisteredClaimNames.Exp)?.Value;
var expiresAt = expiresClaim.ToInt64().ToUnixEpochDate();
// Calculate how many minutes the token is valid
var validWindow = (expiresAt - issuedAt).TotalMinutes;
// Refresh token half way the expiration
var refreshDateTime = issuedAt.AddMinutes(0.5 * validWindow);
// Refresh JWT Token if needed
if (DateTime.UtcNow > refreshDateTime)
{
// Get original token from claims
var jwtToken = context.Principal.FindFirst("jwt")?.Value;
// Pull ClaimManager from Dependency Injection
var claimPrincipalManager =
context.HttpContext.RequestServices.GetService<IClaimPrincipalManager>();
// refresh token and claims and expire times
await claimPrincipalManager.RenewTokenAsync(jwtToken);
}
}
必要的日期时间从用户声明中获取,并在登录期间设置。ClaimPrincipalManager
处理实际的令牌续签
public async Task RenewTokenAsync(String jwtToken)
{
var apiUrl = jwtTokenIssuerSettings.RenewToken;
using (var httpClient = CreateClient())
{
using (var content = new FormUrlEncodedContent
(new Dictionary<String, String>() { { "", jwtToken } }))
{
using (var response = await httpClient.PostAsync(apiUrl, content))
{
var renewedToken = await response.Content.ReadAsStringAsync();
if (response.StatusCode == HttpStatusCode.OK)
await Login(renewedToken);
}
}
}
}
令牌续签仅在 JWT 尚未过期时有效。如果令牌已过期,则续签将失败。
Razor 视图中的策略
在实际应用程序中,用户界面取决于用户权限。在我们的 Web 应用程序中显示员工。只有 HR(人力资源)经理才允许删除员工。在我之前的文章中,我解释了一个策略需要一个或多个声明。该策略在启动期间注册。
public void ConfigureServices(IServiceCollection services)
{
...
// Setup Policies
services.AddAuthorization(options =>
{
options.AddPolicy("HR Only", policy => policy.RequireRole("HR-Worker"));
options.AddPolicy("HR-Manager Only",
policy => policy.RequireClaim("CeoApproval", "true"));
});
...
ClaimPrincipleManager
实现了 HasPolicy
函数
public async Task<Boolean> HasPolicy(String policyName)
{
return await authorizationService.AuthorizeAsync(this.User, null, policyName);
}
该函数依赖于接口 IAuthorizationService
。该接口在包“Microsoft.AspNetCore.Authorization
”中可用,并且无需在启动期间显式注册即可用于 DI。
Razor 视图中的 @inject
语法返回 IClaimPrincipalManager
实例,可用于 Razor 视图中的策略
@inject System.Security.Claims.IClaimPrincipalManager claimManager
@{
ViewBag.Title = "Employees";
}
...
<table id="table">
<thead>
<tr>
...
@if (claimManager.User.HasClaim("Department", "HR"))
{
// Salary only visible to HR department
<th data-field="Salary" data-sortable="true"
data-halign="right" data-align="right">Salary</th>
}
@if (await claimManager.HasPolicy("HR-Manager Only"))
{
// Delete button only available for HR managers
<th data-field="" data-formatter="delFormatter"
data-visible="true" data-halign="center" data-align="center">Delete</th>
}
...
</tr>
</thead>
</table>
...
访问 REST 服务
Web 应用程序从 REST 服务接收员工资源。使其工作模式变得熟悉
- 在 appsettings.json 中指定设置
- 设置类匹配 appsettings.json 中的字段名
- 为 REST 客户端创建接口
- 创建接口工厂
- 在启动期间注册设置、接口和工厂
- 在控制器中注入接口
REST 客户端设置
..
// REST client
"RestClientSettings": {
"BaseAddress": "https://:50249"
},
..
设置映射接口和接口工厂
namespace System.Config
{
public class RestClientSettings
{
public String BaseAddress { get; set; }
}
}
namespace System.Net.Http
{
public interface IRestClient
{
String BaseAddress { get; }
HttpClient CreateClient(ClaimsPrincipal principal);
}
public class RestClientFactory : IRestClient
{
private readonly RestClientSettings settings;
public String BaseAddress => settings.BaseAddress;
public RestClientFactory(IOptions<RestClientSettings> options) : base()
{
settings = options.Value;
}
public HttpClient CreateClient(ClaimsPrincipal principal)
{
// Prepare client
var result = new HttpClient() { BaseAddress = new Uri(BaseAddress) };
result.DefaultRequestHeaders.Accept.Clear();
result.DefaultRequestHeaders.Accept.Add
(new MediaTypeWithQualityHeaderValue("application/json"));
// Fetch JWT from user claims
var jwtToken = principal.FindFirst("jwt")?.Value;
// Add JWT to header for authentication and authorization
result.DefaultRequestHeaders.Add("Authorization", "Bearer " + jwtToken);
return result;
}
}
}
启动期间注册
public void ConfigureServices(IServiceCollection services)
{
...
// Setup REST client
services.Configure<RestClientSettings>(Configuration.GetSection
(nameof(RestClientSettings)));
services.AddTransient<IRestClient, RestClientFactory>();
...
在 EmployeeController
中注入 REST 客户端
public class EmployeeController : Controller
{
...
private readonly IRestClient restClient;
public EmployeeController(IRestClient client)
{
restClient = client;
...
并在控制器中使用客户端
[HttpPost]
[Authorize(Policy = "HR-Manager Only")]
public async Task<IActionResult> Delete(Int32 id)
{
String url = apiUrl + $"{id}";
using (var client = restClient.CreateClient(User))
{
using (var response = await client.DeleteAsync(url))
{
var responseDocument = await response.Content.ReadAsStringAsync();
// create only response if something off has happened
if (response.StatusCode != HttpStatusCode.OK)
{
var result = JsonConvert.DeserializeObject<ResourceResult<EmployeeResource>>
(responseDocument);
return StatusCode(response.StatusCode.ToInt32(), result);
}
return Content(null);
}
}
}
...
客户端如何工作
用户作为参数传递到 CreateClient(...)
中。用户将 JWT 作为私有声明可用,并将其添加到 DefaultRequestHeaders
中,REST 客户端现在可以在 REST 服务器上获得授权。
应用程序演示
感谢您坚持到现在。内容很多,现在是演示时间。
启动画面
点击“Employees”重定向到登录页面。使用(密码 = password)登录
- employee@xyz.com
- hrwoker@xyz.com
- hrmanager@xyz.com
使用 employee@xyz.com 登录
使用 hrmanager@xyz.com 登录
如您所见,用户界面取决于用户。
Visual Studio 启动项目
有时,Visual Studio 启动项目会丢失,导致无法运行应用程序。右键单击解决方案并选择“Set Startup Projects...”
并修复启动设置
根据您的需求选择 WebApp
或 Jwt.ConsoleDemo
。
结论
JWT 与授权 Cookie 结合使用可保护 Web 应用程序和 REST 服务,并仍然为用户提供 SSO(单点登录)。JWT 是自包含、可扩展且平台独立的。
上一篇:JWT 安全性第 2 部分 - 保护 REST 服务
延伸阅读
版本
- 2017 年 8 月 31 日:1.0 - 初始发布
- 2017 年 9 月 5 日:1.1 - 源代码升级到 .NET Core 2.0