ASP.NET Core使用JWT

有关JWT的基础

看这文章之前需要先对JWT有了解,不过这部分已经有很多很好的文章,这边我就不再叙述,你可以看如 ASP.NET Core 认证与授权4:JwtBearer认证 前面的介绍。

ASP.NET Core使用JWT

这里我按照正常项目那样,分成两个项目,JWT.Server项目负责生成JWT,JWT.DemoApi则负责提供Api接口服务对于受限接口会验证JWT。

JWT.Server生成JWT

  1. 新建ASP.NET Core WebApi项目,我这里使用了3.1版本

  2. nuget安装

    1
    2
    // 需要根据自己的.net core版本选择相应版本
    Install-Package Microsoft.AspNetCore.Authentication.JwtBearer
  3. appsettings.json中添加上JWT相关配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    "Jwt": {
    //密钥,需要大于等于16个字符,生产中密钥当然不能如下面这么简单
    "Secret": "123456789@qwerasdf",
    //签发者
    "Iss": "https://hushitong.github.io",
    //使用者
    "Aud": "api",
    //设置过期时间
    "ExpireSeconds": 300
    }
  4. 新建JWTController,添加生成JWT相关代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    namespace JWT.Server.Controllers
    {
    [Route("api/[controller]")]
    [ApiController]
    public class JWTController : ControllerBase
    {
    readonly IConfiguration configuration;

    public JWTController(IConfiguration configuration)
    {
    this.configuration = configuration;
    }

    [HttpGet]
    public IActionResult Authenticate(string userName, string pwd)
    {
    //实际项目这里应该做登陆验证,这里就写死了
    if (userName == "admin" && pwd == "123456")
    {
    var jwtConfig = configuration.GetSection("Jwt");

    //定义签名使用的密钥,以及使用Hmacsha256签名算法
    var securityKey = new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtConfig.GetValue<string>("Secret"))), SecurityAlgorithms.HmacSha256);

    //有效载荷
    var claims = new Claim[] {
    new Claim(JwtRegisteredClaimNames.Iss,jwtConfig.GetValue<string>("Iss")),
    new Claim(JwtRegisteredClaimNames.Aud,jwtConfig.GetValue<string>("Aud")),
    new Claim(ClaimTypes.Name,"admin"),
    new Claim(ClaimTypes.NameIdentifier,"1"),
    new Claim(ClaimTypes.Role,"system"),
    new Claim(ClaimTypes.Role,"admin")
    };

    SecurityToken securityToken = new JwtSecurityToken(
    signingCredentials: securityKey,
    expires: DateTime.Now.AddSeconds(jwtConfig.GetValue<int>("ExpireSeconds")), //过期时间
    claims: claims
    );
    //生成jwt令牌
    return Content(new JwtSecurityTokenHandler().WriteToken(securityToken));
    }
    else
    {
    return BadRequest("登陆失败");
    }
    }
    }
    }
  5. 测试访问接口,可获得JWT

    1
    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2h1c2hpdG9uZy5naXRodWIuaW8iLCJhdWQiOiJhcGkiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiYWRtaW4iLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjEiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiOlsic3lzdGVtIiwiYWRtaW4iXSwiZXhwIjoxNjQ0ODA4NjU0fQ.aqdRxdA9CakK_jrz-J3jTn2Rgu_2WkriHtLCJC61IcM

JWT.DemoApi使用JWT验证

  1. 新建ASP.NET Core WebApi项目,我这里使用了3.1版本

  2. nuget安装

    1
    2
    // 需要根据自己的.net core版本选择相应版本
    Install-Package Microsoft.AspNetCore.Authentication.JwtBearer
  3. appsettings.json中添加上JWT相关配置,注意配置内容与JWT.Server里的基本一致,只是少了ExpireSeconds

    1
    2
    3
    4
    5
    6
    7
    8
    "Jwt": {
    //密钥,需要大于等于16个字符,生产中密钥当然不能如下面这么简单
    "Secret": "123456789@qwerasdf",
    //签发者
    "Iss": "https://hushitong.github.io",
    //使用者
    "Aud": "api"
    }
  4. Startup.ConfigureServices里注册JWT

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    //获得使用的密钥
    var jwtConfig = Configuration.GetSection("Jwt");
    var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtConfig.GetValue<string>("Secret")));
    //认证参数
    services.AddAuthentication("Bearer")
    .AddJwtBearer(o =>
    {
    o.TokenValidationParameters = new TokenValidationParameters
    {
    ValidateIssuerSigningKey = true,//是否验证签名,不验证的画可以篡改数据,不安全
    IssuerSigningKey = signingKey,//使用的密钥
    ValidateIssuer = true,//是否验证签发者,就是验证载荷中的Iss是否对应ValidIssuer参数
    ValidIssuer = jwtConfig.GetValue<string>("Iss"),//签发者
    ValidateAudience = true,//是否验证使用者,就是验证载荷中的Aud是否对应ValidAudience参数
    ValidAudience = jwtConfig.GetValue<string>("Aud"),//使用者
    ValidateLifetime = true,//是否验证过期时间,过期了就拒绝访问
    ClockSkew = TimeSpan.Zero,//这个是缓冲过期时间,也就是说,即使我们配置了过期时间,这里也要考虑进去,过期时间+缓冲,默认好像是7分钟,你可以直接设置为0
    };
    });
  5. 确保Startup.Configure方法中添加 了 app.UseAuthorization()app.UseAuthentication(),没有就加上

    注意:app.UseAuthentication()你不加应用也不会报错,但是后续请求需要JWT验证的接口时会一直报401错误。

  6. 新建JWTTestController,添加相关测试代码,需要JWT验证的需要在Action上加上特性[Authorize]

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    [Route("api/[controller]/[action]")]
    [ApiController]
    public class JWTTestController : ControllerBase
    {
    //不需要验证JWT
    [HttpGet]
    public ActionResult GetWithoutAuth()
    {
    return Ok("访问成功");
    }

    //只验证JWT是否通过
    [HttpGet]
    [Authorize]
    public ActionResult GetWithAuth()
    {
    //使用HttpContext.User.Claims可以获得当前用户Payload里的信息
    HttpContext.User.Claims.ToList().ForEach(x => Console.WriteLine(x));
    return Ok("访问成功");
    }

    //验证JWT是否通过同时还得验证其payload中是否由符合SuperAdmin的Role
    [HttpGet]
    [Authorize(Roles ="SuperAdmin")]
    public ActionResult GetWithRoleAuth()
    {
    return Ok("访问成功");
    }
    }

JWT测试

准备工作:运行JWT.Server,获得JWT

访问JWT.DemoApi请求头不带JWT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- 请求:
curl -X 'GET' \
'https://localhost:5001/api/JWTTest/GetWithoutAuth' \
-H 'accept: */*' \
-- 响应:
Code:200
Response body:访问成功

-- 请求:
curl -X 'GET' \
'https://localhost:5001/api/JWTTest/GetWithAuth' \
-H 'accept: */*' \
-- 响应:
Code:401
Error: response status is 401

-- 请求:
curl -X 'GET' \
'https://localhost:5001/api/JWTTest/GetWithRoleAuth' \
-H 'accept: */*' \
-- 响应:
Code:401
Error: response status is 401

可以看到添加了[Authorize]特性的接口都返回401 (401 unauthorized,表示发送的请求需要有通过 HTTP 认证的认证信息)

访问JWT.DemoApi请求头带JWT

使用Postman或其他你顺手的工具,在请求头加上Authorization: Bearer JWTString,即可使用JWT测试,我这里使用的时Swagger带JWT的办法。

结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
-- 响应:
curl -X 'GET' \
'https://localhost:5001/api/JWTTest/GetWithoutAuth' \
-H 'accept: */*' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2h1c2hpdG9uZy5naXRodWIuaW8iLCJhdWQiOiJhcGkiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiYWRtaW4iLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjEiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiOlsic3lzdGVtIiwiYWRtaW4iXSwiZXhwIjoxNjQ0ODM0NzkwfQ.-AoGFQAVjkbkEkPPrMd3f6a6K_v-QKIDJNGYb0S7xgU'
-- 响应:
Code:200
Response body:访问成功

-- 响应:
curl -X 'GET' \
'https://localhost:5001/api/JWTTest/GetWithAuth' \
-H 'accept: */*' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2h1c2hpdG9uZy5naXRodWIuaW8iLCJhdWQiOiJhcGkiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiYWRtaW4iLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjEiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiOlsic3lzdGVtIiwiYWRtaW4iXSwiZXhwIjoxNjQ0ODM0NzkwfQ.-AoGFQAVjkbkEkPPrMd3f6a6K_v-QKIDJNGYb0S7xgU'
-- 响应:
Code:200
Response body:访问成功

-- 响应:
curl -X 'GET' \
'https://localhost:5001/api/JWTTest/GetWithRoleAuth' \
-H 'accept: */*' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2h1c2hpdG9uZy5naXRodWIuaW8iLCJhdWQiOiJhcGkiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiYWRtaW4iLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjEiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiOlsic3lzdGVtIiwiYWRtaW4iXSwiZXhwIjoxNjQ0ODM0NzkwfQ.-AoGFQAVjkbkEkPPrMd3f6a6K_v-QKIDJNGYb0S7xgU'
-- 响应:
Code:403
Error: response status is 403

请求头添加了JWT参数后,GetWithAuth接口成功,但是GetWithRoleAuth却是返回403 (403 forbidden,表示对请求资源的访问被服务器拒绝) ,这是因为该接口有特性[Authorize(Roles ="SuperAdmin")]限定了需要有SuperAdmin角色才能访问,而前面JWT.Server所发放的JWT里并不包含该角色,想要测试通过,只需要在JWT.Server发放JWT时payload里添加上SuperAdmin角色,重新生成JWT再测试

1
2
3
4
5
6
7
8
9
10
11
//有效载荷
var claims = new Claim[] {
new Claim(JwtRegisteredClaimNames.Iss,jwtConfig.GetValue<string>("Iss")),
new Claim(JwtRegisteredClaimNames.Aud,jwtConfig.GetValue<string>("Aud")),
new Claim(ClaimTypes.Name,"admin"),
new Claim(ClaimTypes.NameIdentifier,"1"),
new Claim(ClaimTypes.Role,"system"),
new Claim(ClaimTypes.Role,"admin"),
//这里添加SuperAdmin角色
new Claim(ClaimTypes.Role,"SuperAdmin")
};

访问JWT.DemoApi请求头带过期的JWT

我们获得JWT后,等待300秒(前面设定的)让其过期,然后测试,结果和 访问JWT.DemoApi请求头不带JWT 一致

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- 响应:
curl -X 'GET' \
'https://localhost:5001/api/JWTTest/GetWithoutAuth' \
-H 'accept: */*' \
-- 响应:
Code:200
Response body:访问成功

-- 响应:
curl -X 'GET' \
'https://localhost:5001/api/JWTTest/GetWithAuth' \
-H 'accept: */*' \
-- 响应:
Code:401
Error: response status is 401

-- 响应:
curl -X 'GET' \
'https://localhost:5001/api/JWTTest/GetWithRoleAuth' \
-H 'accept: */*' \
-- 响应:
Code:401
Error: response status is 401

TokenValidationParameters常用内容说明

TokenValidationParameters是和token验证有关的参数配置,进行token验证时需要用到,下面是我对着该类写常用内容说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
public class TokenValidationParameters
{
// Token最大占用空间:250 KB (kilobytes).
public const int DefaultMaximumTokenSizeInBytes = 256000;

// 默认的缓冲过期时间为:300seconds (5 minutes).
// 真实过期时间 = 过期时间 + 缓冲过期时间
[DefaultValue(300)]
public TimeSpan ClockSkew;

//是否要求token必须包含过期时间。默认为true,即Jwt的Payload部分必须包含exp且具有有效值。
[DefaultValue(true)]
public bool RequireExpirationTime;

//是否验证token是否在有效期内,即验证Jwt的Payload部分的nbf和exp。
[DefaultValue(true)]
public bool ValidateLifetime

//是否必须有签名部分,默认为true
[DefaultValue(true)]
public bool RequireSignedTokens;

//用于验证Jwt签名的密钥。
//对于对称加密来说,加签和验签都是使用的同一个密钥;
//对于非对称加密来说,使用私钥加签,然后使用公钥验签。
public SecurityKey IssuerSigningKey;

#region 受众
//是否必须有受众,默认为true。
[DefaultValue(true)]
public bool RequireAudience;

//是否验证受众,默认为true。
[DefaultValue(true)]
public bool ValidateAudience

//有效的受众,默认为null。ValidateAudience为true时,对比该值与Jwt的Payload部分的aud。
public string ValidAudience;

//有效的受众列表,可以指定多个受众,,默认为null。
public IEnumerable<string> ValidAudiences;
#endregion

#region 签发者
//是否验证签发者。默认为true。
[DefaultValue(true)]
public bool ValidateIssuer;

//有效的签发者,默认为null,ValidateIssuer为true时,对比该值与Jwt的Payload部分的iss。
public string ValidIssuer;

//有效的签发者列表,可以指定多个签发者,,默认为null。
public IEnumerable<string> ValidIssuers;
#endregion

//当token验证通过后,是否保存到Microsoft.AspNetCore.Authentication.AuthenticationProperties,默认false。
//该操作发生在执行完JwtBearerEvents.TokenValidated之后。
//想在后面代码里使用HttpContext.User.Claims可以获得当前用户Payload里的信息的话,需要设置true
[DefaultValue(false)]
public bool SaveSigninToken;

//构造函数,可以看到其默认设置
public TokenValidationParameters()
{
RequireExpirationTime = true;
RequireSignedTokens = true;
RequireAudience = true;
SaveSigninToken = false;
ValidateActor = false;
ValidateAudience = true;
ValidateIssuer = true;
ValidateIssuerSigningKey = false;
ValidateLifetime = true;
ValidateTokenReplay = false;
}
}

JwtBearerEvents

在验证JWT时,Microsoft.AspNetCore.Authentication.JwtBearer同时提供了一些额外事件来提供更有力的支持。

OnMessageReceived事件

假设我们的接口会接受网页与App端请求,网页使用Cookies保存JWT信息,而App使用请求头。

那我们需要在请求头中获得不了JWT信息时,再尝试去Cookie中获取,我们可以使用OnMessageReceived事件解决该需求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//注册时添加OnMessageReceived
services.AddAuthentication("Bearer").AddJwtBearer(o =>
{
//省略一些设置

o.Events = new JwtBearerEvents()
{
//该事件会在收到请求在验证JWT前触发
OnMessageReceived = context =>
{
//假如在Header中没有包含JWT的参数,那就到Cookies去找
if (!context.Request.Headers.ContainsKey("Authorization"))
{
//获取到Cookies中的access_token,后续验证使用该token,并且在控制台输出
context.Token = context.Request.Cookies["access_token"];
}
return Task.CompletedTask;
}
};
});

//添加一个接口,在Cookies中添加access_token
[HttpGet]
public ActionResult SetCookiesToken(string access_token)
{
HttpContext.Response.Cookies.Append("access_token", access_token);
return Ok("设置Cookies成功");
}

由JWT.Server获得JWT后,先访问SetCookiesToken接口设置token,然后你就不需要再在请求头中添加JWT参数,同样可以访问需要验证接口,结果同 访问JWT.DemoApi请求头带JWT

其他事件

通过查看Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerEvents,发现除了OnMessageReceived事件外,还提供了如下几个事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
namespace Microsoft.AspNetCore.Authentication.JwtBearer
{
// Specifies events which the Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler invokes to enable developer control over the authentication process.
public class JwtBearerEvents
{
// Invoked if exceptions are thrown during request processing. The exceptions will be re-thrown after this event unless suppressed.
public Func<AuthenticationFailedContext, Task> OnAuthenticationFailed

// Invoked if Authorization fails and results in a Forbidden response
public Func<ForbiddenContext, Task> OnForbidden

// Invoked when a protocol message is first received.
public Func<MessageReceivedContext, Task> OnMessageReceived

// Invoked after the security token has passed validation and a ClaimsIdentity has been generated.
public Func<TokenValidatedContext, Task> OnTokenValidated

// Invoked before a challenge is sent back to the caller.
public Func<JwtBearerChallengeContext, Task> OnChallenge
}
}

强制JWT失效

使用JWT本身是有一个问题的,那就是JWT本身在过期时间前都是有效的,这就会导致一些问题,如:账号被封后仍然能用,账号下线后实际其JWT仍可能被其他人利用,账号权限更改后不能实时生效等。

有一些其他办法解决该问题,我这里提供其中一种办法,就是每个用户新对应一个JWTGenerations字段(int类型,从1开始,写入数据库)

  • 签发端:

    在JWT的Payload部分添加一个新字段Generations,其值为最新的JWTGenerations值。

  • 服务端:

    验证JWT时,同时拿其Generations与最新的JWTGenerations值比较,只要小于最新的JWTGenerations值,那判定该JWT失效,让其重新登陆。

每当进行使该用户JWT失效的操作(如:用户登陆、用户登出、用户被封等)时,让该用户JWTGenerations值+1。

JWTGenerations值在可以使用Redis等缓存保存以提升速度,然后每过10秒左右(时间根据实际自己定)主动去数据库拉取最新数据,也可以在进行使该用户JWT失效的操作后主动更新该信息都可以。

注意:该方案会造成只允许一个账号只能在一端登陆,其他端会被下线,需要其他办法再支持。

更多阅读

ASP.NET Core 认证与授权4:JwtBearer认证

asp.net core 集成JWT(二)token的强制失效,基于策略模式细化api权限

理解ASP.NET Core - 基于JwtBearer的身份认证(Authentication)