【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