65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIconstarIcon

5.00/5 (7投票s)

2017年8月31日

CPOL

6分钟阅读

viewsIcon

42621

downloadIcon

2193

了解如何创建 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 身份验证的信息,请观看视频教程

登录过程归结为

  1. 收集凭据(电子邮件和密码)。
  2. 从颁发者获取 JWT。
  3. 从 JWT 创建用户和声明。
  4. 将 JWT 添加到用户声明。
  5. 使用用户和 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...

并修复启动设置

根据您的需求选择 WebAppJwt.ConsoleDemo

结论

JWT 与授权 Cookie 结合使用可保护 Web 应用程序和 REST 服务,并仍然为用户提供 SSO(单点登录)。JWT 是自包含、可扩展且平台独立的。

上一篇:JWT 安全性第 2 部分 - 保护 REST 服务

延伸阅读

版本

  • 2017 年 8 月 31 日:1.0 - 初始发布
  • 2017 年 9 月 5 日:1.1 - 源代码升级到 .NET Core 2.0
© . All rights reserved.