【ASP.NET Core 认证】JwtBearer认证

HTTP标准身份验证框架

HTTP提供了一套标准的身份验证框架:服务器可以用来针对客户端的请求发送质询(challenge),客户端根据质询提供身份验证凭证。质询与应答的工作流程如下:服务器端向客户端返回401(Unauthorized,未授权)状态码,并在WWW-Authenticate头中添加如何进行验证的信息,其中至少包含有一种质询方式。然后客户端可以在请求中添加Authorization头进行验证,其Value为身份验证的凭证信息。

在HTTP标准验证方案中,我们比较熟悉的是"Basic"和"Digest",前者将用户名密码使用BASE64编码后作为验证凭证,后者是Basic的升级版,更加安全,因为Basic是明文传输密码信息,而Digest是加密后传输。在前文介绍的Cookie认证属于Form认证,并不属于HTTP标准验证。

使用HTTP标准身份验证框架有什么好处呢?

跨站: 传统的cookie只能实现跨域,而不能实现跨站(如my.abc.com和you.xyz.com)
对移动端友好: 当你在一个原生平台(iOS, Android, WindowsPhone等)时,使用Cookie验证并不是一个好主意,因为你得和Cookie容器打交道,而使用标准身份验证框架则简单的多。
CSRF: 因为标准身份验证框架不依赖于cookies, 也就避免了跨站请求攻击。
标准:在Cookie认证中,用户未登录时,返回一个302到登录页面,这在非浏览器情况下很难处理,而标准身份验证框架则返回的是标准的401 challenge。

Bearer认证

本文要介绍的Bearer验证也属于HTTP协议标准验证,它随着OAuth协议而开始流行
Bearer验证中的凭证称为BEARER_TOKEN,或者是access_token,它的颁发和验证完全由我们自己的应用程序来控制,而不依赖于系统和Web服务器,Bearer验证的标准请求方式如下:

Authorization: Bearer [BEARER_TOKEN] 

JWT(JSON WEB TOKEN)

上面介绍的Bearer认证,其核心便是BEARER_TOKEN,而最流行的Token编码方式便是:JWT。

开始

Nuget安装以下三个包:

Install-Package IdentityModel
Install-Package System.IdentityModel.Tokens.Jwt
Install-Package Microsoft.AspNetCore.Authentication.JwtBearer

接着,通过AddJwtBearer扩展方法添加JwtBearer认证方案:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
            {
                // 在这里对该方案进行详细配置
            });
    }
}

JwtBearerDefaults:

public static class JwtBearerDefaults
{
    public const string AuthenticationScheme = "Bearer";
}

JwtBearerOptions针对Jwt的验证参数、验证处理器、事件回调等进行详细配置。
在开始之前,先自定义一个选项类JwtOptions,将常用参数配置化:

public class JwtOptions
{
    public const string Name = "Jwt";
    public readonly static Encoding DefaultEncoding = Encoding.UTF8;
    public readonly static double DefaultExpiresMinutes = 30d;

    public string Audience { get; set; }

    public string Issuer { get; set; }
    
    public double ExpiresMinutes { get; set; } = DefaultExpiresMinutes;

    public Encoding Encoding { get; set; } = DefaultEncoding;

    public string SymmetricSecurityKeyString { get; set; }

    public SymmetricSecurityKey SymmetricSecurityKey => new(Encoding.GetBytes(SymmetricSecurityKeyString));
}

配置:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.Configure<JwtOptions>(Configuration.GetSection(JwtOptions.Name));
        
        var jwtOptions = Configuration.GetSection(JwtOptions.Name).Get<JwtOptions>();
    
        services.AddSingleton(sp => new SigningCredentials(jwtOptions.SymmetricSecurityKey, SecurityAlgorithms.HmacSha256Signature));
    
        services.AddScoped<AppJwtBearerEvents>();
    
        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256, SecurityAlgorithms.RsaSha256 },
                    ValidTypes = new[] { JwtConstants.HeaderType },

                    ValidIssuer = jwtOptions.Issuer,
                    ValidateIssuer = true,

                    ValidAudience = jwtOptions.Audience,
                    ValidateAudience = true,

                    IssuerSigningKey = jwtOptions.SymmetricSecurityKey,
                    ValidateIssuerSigningKey = true,
                    
                    ValidateLifetime = true,
                    
                    RequireSignedTokens = true,
                    RequireExpirationTime = true,
                
                    NameClaimType = JwtClaimTypes.Name,
                    RoleClaimType = JwtClaimTypes.Role,

                    ClockSkew = TimeSpan.Zero,
                };

                options.SaveToken = true;

                options.SecurityTokenValidators.Clear();
                options.SecurityTokenValidators.Add(new JwtSecurityTokenHandler());

                options.EventsType = typeof(AppJwtBearerEvents);
            });
    }
}

其中,TokenValidationParameters是和token验证有关的参数配置,进行token验证时需要用到,下面看详细说明:

  • TokenValidationParameters.ValidAlgorithms:有效的签名算法列表,即验证Jwt的Header部分的alg。默认为null,即所有算法均可。
  • TokenValidationParameters.ValidTypes:有效的token类型列表,即验证Jwt的Header部分的typ。默认为null,即算有算法均可。
  • TokenValidationParameters.ValidIssuer:有效的签发者,即验证Jwt的Payload部分的iss。默认为null。
  • TokenValidationParameters.ValidIssuers:有效的签发者列表,可以指定多个签发者。
  • TokenValidationParameters.ValidateIssuer:是否验证签发者。默认为true。注意,如果设置了TokenValidationParameters.IssuerValidator,则该参数无论是何值,都会执行。
  • TokenValidationParameters.ValidAudience:有效的受众,即验证Jwt的Payload部分的aud。默认为null。
  • TokenValidationParameters.ValidAudiences:有效的受众列表,可以指定多个受众。
  • TokenValidationParameters.ValidateAudience:是否验证受众。默认为true。注意,如果设置了TokenValidationParameters.AudienceValidator,则该参数无论是何值,都会执行。
  • TokenValidationParameters.IssuerSigningKey:用于验证Jwt签名的密钥。对于对称加密来说,加签和验签都是使用的同一个密钥;对于非对称加密来说,使用私钥加签,然后使用公钥验签。
  • TokenValidationParameters.ValidateIssuerSigningKey:是否使用验证密钥验证签名。默认为false。注意,如果设置了TokenValidationParameters.IssuerSigningKeyValidator,则该参数无论是何值,都会执行。
  • TokenValidationParameters.ValidateLifetime:是否验证token是否在有效期内,即验证Jwt的Payload部分的nbf和exp。
  • `TokenValidationParameters.RequireSignedTokens: 是否要求token必须进行签名。默认为true,即token必须签名才可能有效。
  • TokenValidationParameters.RequireExpirationTime:是否要求token必须包含过期时间。默认为true,即Jwt的Payload部分必须包含exp且具有有效值。
  • TokenValidationParameters.NameClaimType:设置 HttpContext.User.Identity.NameClaimType,便于 HttpContext.User.Identity.Name 取到正确的值
  • TokenValidationParameters.RoleClaimType:设置 HttpContext.User.Identity.RoleClaimType,便于 HttpContext.User.Identity.IsInRole(xxx) 取到正确的值
  • TokenValidationParameters.ClockSkew:设置时钟漂移,可以在验证token有效期时,允许一定的时间误差(如时间刚达到token中exp,但是允许未来5分钟内该token仍然有效)。默认为300s,即5min。本例jwt的签发和验证均是同一台服务器,所以这里就不需要设置时钟漂移了。
  • SaveToken:当token验证通过后,是否保存到 Microsoft.AspNetCore.Authentication.AuthenticationProperties,默认true。该操作发生在执行完JwtBearerEvents.TokenValidated之后。
  • SecurityTokenValidators:token验证器列表,可以指定验证token的处理器。默认含有1个JwtSecurityTokenHandler。
  • EventsType:这里我重写了JwtBearerEvents。
    下面来看事件回调:
public class AppJwtBearerEvents : JwtBearerEvents
{
    public override Task MessageReceived(MessageReceivedContext context)
    {
        // 从 Http Request Header 中获取 Authorization
        string authorization = context.Request.Headers[HeaderNames.Authorization];
        if (string.IsNullOrEmpty(authorization))
        {
            context.NoResult();
            return Task.CompletedTask;
        }

        // 必须为 Bearer 认证方案
        if (authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
        {
            // 赋值token
            context.Token = authorization["Bearer ".Length..].Trim();
        }

        if (string.IsNullOrEmpty(context.Token))
        {
            context.NoResult();
            return Task.CompletedTask;
        }
        
        return Task.CompletedTask;
    }

    public override Task TokenValidated(TokenValidatedContext context)
    {
        return Task.CompletedTask;
    }

    public override Task AuthenticationFailed(AuthenticationFailedContext context)
    {
        Console.WriteLine($"Exception: {context.Exception}");

        return Task.CompletedTask;
    }

    public override Task Challenge(JwtBearerChallengeContext context)
    {
        Console.WriteLine($"Authenticate Failure: {context.AuthenticateFailure}");
        Console.WriteLine($"Error: {context.Error}");
        Console.WriteLine($"Error Description: {context.ErrorDescription}");
        Console.WriteLine($"Error Uri: {context.ErrorUri}");

        return Task.CompletedTask;
    }

    public override Task Forbidden(ForbiddenContext context)
    {
        return Task.CompletedTask;
    }
}

MessageReceived:当收到请求时回调,注意,此时还未获取到token。我们可以在该方法内自定义token的获取方式,然后将获取到的token赋值到context.Token(不包含Scheme)。只要我们取到的token既非Null也非Empty,那后续验证就会使用该token
TokenValidated:token验证通过后回调。
AuthenticationFailed:由于认证过程中抛出异常,导致身份认证失败后回调。
Challenge:质询时回调。
Forbidden:当出现403(Forbidden,禁止)时回调。
其中,在MessageReceived中,针对默认获取token的逻辑进行了模拟。

用户登录

现在,我们来实现用户登录功能,当登录成功时,向客户端签发一个token。

[Route("api/[controller]")]
[ApiController]
public class AccountController : ControllerBase
{
    private readonly JwtBearerOptions _jwtBearerOptions;
    private readonly JwtOptions _jwtOptions;
    private readonly SigningCredentials _signingCredentials;

    public AccountController(
        IOptionsSnapshot<JwtBearerOptions> jwtBearerOptions,
        IOptionsSnapshot<JwtOptions> jwtOptions,
        SigningCredentials signingCredentials)
    {
        _jwtBearerOptions = jwtBearerOptions.Get(JwtBearerDefaults.AuthenticationScheme);
        _jwtOptions = jwtOptions.Value;
        _signingCredentials = signingCredentials;
    }

    [AllowAnonymous]
    [HttpPost("login")]
    public IActionResult Login([FromBody] LoginDto dto)
    {
        if (dto.UserName != dto.Password)
        {
            return Unauthorized();
        }

        var user = new UserDto()
        {
            Id = Guid.NewGuid().ToString("N"),
            UserName = dto.UserName
        };

        var token = CreateJwtToken(user);

        return Ok(new { token });
    }

    [NonAction]
    private string CreateJwtToken(UserDto user)
    {
        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Subject = new ClaimsIdentity(new List<Claim>
            {
                new Claim(JwtClaimTypes.Id, user.Id),
                new Claim(JwtClaimTypes.Name, user.UserName)
            }),
            Issuer = _jwtOptions.Issuer,
            Audience = _jwtOptions.Audience,
            Expires = DateTime.UtcNow.AddMinutes(_jwtOptions.ExpiresMinutes),
            SigningCredentials = _signingCredentials
        };

        var handler = _jwtBearerOptions.SecurityTokenValidators.OfType<JwtSecurityTokenHandler>().FirstOrDefault()
            ?? new JwtSecurityTokenHandler();
        var securityToken = handler.CreateJwtSecurityToken(tokenDescriptor);
        var token = handler.WriteToken(securityToken);

        return token;
    }
}

我们目光直接来到CreateJwtToken方法,可以看到熟悉的Subject、Issuer、Audience、Expires等。其中,Subject可以装载多个自定义声明,在生成token时,会将装载的所有声明展开平铺。而另一个需要注意的就是Expires,必须使用基于UTC的时间,默认有效期为1个小时。

用户注销

当使用JwtBearer认证方案时,由于Jwt的“一次性”和“无状态”特征,用户注销一般是不会在服务端实现的,而是通过客户端来实现,比如客户端从localstorage中删除该token

改用非对称加密进行Jwt签名和验签

在前面的示例中,我们使用的对称加密算法HmacSha256计算的签名。试想一下,公司内的多个业务项目都会使用该token,因此,为了让每个项目都可以进行身份认证,就需要将密钥分发给所有项目,这就产生了较大的风险。因此,使用非对称加密来计算签名,是一个更加合理地选择:我们使用私钥进行签名,然后只需要将公钥暴露出去用于验签,即可验证token是有效的(没有被篡改)。下面,我们就以RsaSha256为例改进我们的程序。

首先,我们先生成Rsa的密钥对,参考以下示例代码(可在源码AccountController中找到):

public void GenerateRsaKeyParies(IWebHostEnvironment env)
{
    RSAParameters privateKey, publicKey;

    // >= 2048 否则长度太短不安全
    using (var rsa = new RSACryptoServiceProvider(2048))
    {
        try
        {
            privateKey = rsa.ExportParameters(true);
            publicKey = rsa.ExportParameters(false);
        }
        finally
        {
            rsa.PersistKeyInCsp = false;
        }
    }

    var dir = Path.Combine(env.ContentRootPath, "Rsa");
    if (!Directory.Exists(dir))
    {
        Directory.CreateDirectory(dir);
    }

    System.IO.File.WriteAllText(Path.Combine(dir, "key.private.json"), JsonConvert.SerializeObject(privateKey));
    System.IO.File.WriteAllText(Path.Combine(dir, "key.public.json"), JsonConvert.SerializeObject(publicKey));
}

具体细节不必多说,然后就来改进我们的JwtOptions:

public class JwtOptions
{
    public const string Name = "Jwt";
    public readonly static double DefaultExpiresMinutes = 30d;

    public string Audience { get; set; }

    public string Issuer { get; set; }
    
    public double ExpiresMinutes { get; set; } = DefaultExpiresMinutes;
}

由于RSA签名算法的私钥和公钥都保存在另外一个文件中,而且一般这个也不会轻易更改,所以就不把它们加入到选项中了。

接着,修改我们的签名算法和验签算法:

public class Startup
{
    public Startup(IConfiguration configuration, IWebHostEnvironment env)
    {
        Configuration = configuration;
        Env = env;
    }

    public IConfiguration Configuration { get; }

    public IWebHostEnvironment Env { get; set; }

    public void ConfigureServices(IServiceCollection services)
    {
        services.Configure<JwtOptions>(Configuration.GetSection(JwtOptions.Name));

        var jwtOptions = Configuration.GetSection(JwtOptions.Name).Get<JwtOptions>();
        
        var rsaSecurityPrivateKeyString = File.ReadAllText(Path.Combine(Env.ContentRootPath, "Rsa", "key.private.json"));
        var rsaSecurityPublicKeyString = File.ReadAllText(Path.Combine(Env.ContentRootPath, "Rsa", "key.public.json"));
        RsaSecurityKey rsaSecurityPrivateKey = new(JsonConvert.DeserializeObject<RSAParameters>(rsaSecurityPrivateKeyString));
        RsaSecurityKey rsaSecurityPublicKey = new(JsonConvert.DeserializeObject<RSAParameters>(rsaSecurityPublicKeyString));
        
        // 使用私钥加签
        services.AddSingleton(sp => new SigningCredentials(rsaSecurityPrivateKey, SecurityAlgorithms.RsaSha256Signature));

        services.AddScoped<AppJwtBearerEvents>();
        
        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    // ...
                    
                    // 使用公钥验签
                    IssuerSigningKey = rsaSecurityPublicKey,
                }
            }
    }
}

至此,就OK了,其他全部都不需要改,以下是一个签发的Jwt示例,缺点是签名部分会比对称加密的长很多(毕竟安全嘛,我们可以忍受O(∩_∩)O哈哈~):

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Ijk4NTUxMDE3YjBjYTRjOTU5NzNmMTM3Mjk2MWZlZWM2IiwibmFtZSI6InN0cmluZyIsIm5iZiI6MTY0MzIwOTIwNiwiZXhwIjoxNjQzMjA5ODA2LCJpYXQiOjE2NDMyMDkyMDYsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTAwMCIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTAwMCJ9.GUCYTBytxv5yqGQFB6B6rlARF3F37CJh27e-qBCKApJShSr8vq-RkPu_o0dtCONKx0y1mb2Aq5hddFQYRFaMICQMeUeCJfaVoi96chsvwahnvx1_Snz4vvaiHSmTGCXm-WAkMJdpFny0zsicegLOrJJyHFecHGENGfWee28xYSi9R70bFJjVLxR965UJzOisi5pIXjemdlipaRhdITAWz-B4iKH_2-sv6j_drkJv2CNsEjOdHxHITN6oVUpP3i4i4PmXhRM7x4O0lKeKGQE9ezZIBtXa16nUCJo0VWDD2QAwWr1akzu99wtOSoJf2MoRETwK7vOOKIbTrNQOQ1WYUQ

JWE

JWE(JSON Web Encryption)是在JWT的基础上增加了加密功能。JWE允许对JWT的载荷进行加密,以保护其中的敏感信息。
我们知道,Jwt中的Header和Payload都是明文,特别是Payload中我们务必不要放置敏感信息。如果你觉得Jwt明文不妥,那你可以选择针对它加一层加密,也就是Jwt标准的另一种实现Jwe。

下面是部分代码实现:

private string CreateJwtToken(UserDto user)
{
    var tokenDescriptor = new SecurityTokenDescriptor
    {
        // ...
        
        EncryptingCredentials = new EncryptingCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes("Total Bytes Length At Least 256!")), JwtConstants.DirectKeyUseAlg, SecurityAlgorithms.Aes128CbcHmacSha256)
    };

    // ...
}

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // ...
        
        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    // ...
                    
                    // 如果设置了 ValidAlgorithms,则加上 Aes128CbcHmacSha256
                    ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256, SecurityAlgorithms.RsaSha256, SecurityAlgorithms.Aes128CbcHmacSha256 },
                    
                    // token解密密钥
                    TokenDecryptionKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("Total Bytes Length At Least 256!"))
                }
            }
    }
}

下方是一个Jwe示例:

eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwidHlwIjoiSldUIn0..KsIPh-Wx8TOpgNBZ5xINSA.zgqErSkpnTaWJ1TsPoIKrgpP_2uR-Orjbn54Wo4FeGmIPczk2X8N8qx4zWe9CGztrFLxeoWvYLlfRwclfglmKE9372delByVwK_C-u7cFN2TaZ183JTWYTyJVPANTC1WtuEzSe3NEKjfRoC9QN7SN4z9cJ-CtIPb1t17XB0gG0fc7T9UARZ1eIUIfnCXROAyX96qB6ABJ5Xy8wrrYkA2m5OqqLyAd8FbZfcK_rii_lbXNZsbcfgNPBQGEO6lOdBg4I3nQv9A6cqGj9qTnsIH89Dx7mBnkx0W7C9UHtZQsNTG71VSzG8g_KVifC-oO62wrOYeh48y5l4czeIWlAl4GCZpnUQmq4Y_2cw2brgG4WV7FRYPch4RMeTB6y9qrm6Rj8TvZbf_hZ51yvDYvPPVUjMiM1xo5_KLXVZa3w5aEGB4jGynVXwuGDV8XwS8sTjEkziFfA85TWPq_N-ENm4R9K_HUzwfgpGYzM-Nrf54GV8BXpnpapTc-jWij3MOpsjeyzqXdG5t-JB9_Xt7-BadjMakiU1WihiigiYMGQBmkG30r8e6bGcoL58Ytb6PQZ3NfHGCakV5LRGWFOjRUSP7X_xC0xWhrH2R6LhD1QESoE8GsTU-YS9JUREECcD2b9gXx0JxYp2mGdCkKRspajhEj4b04PV-hpr0bNSf59GkSMu_KhHuF5AcWfLSqwzACMvsvW6QvIQTzm6gXy8Ui2N80JCGkp_LzW23RFwCPSlQQ7c7S3A-Ltd_AaDQJ9C5B-To_PHESy9bUKhU-MV2tbfSST-vBeJkSn4kz4feEWcG59A.KULA_w3_XEIIKhAHKuFpsw

它的头部就是:

{
  "alg": "dir",
  "enc": "A128CBC-HS256",
  "typ": "JWT"
}

参考:
https://www.cnblogs.com/RainingNight/p/jwtbearer-authentication-in-asp-net-core.html
https://www.cnblogs.com/xiaoxiaotank/p/15882735.html

posted @ 2020-03-26 15:07  .Neterr  阅读(1313)  评论(0编辑  收藏  举报