Netcore认证授权与IdentityServer4(2)JWT


主要概要

这个文章主要来聊一聊Net Core中的认证与授权,以及Net Core生态中非常火热的IdentityServer4这个组件的使用,主要内容如下:

  1. 鉴权授权流程变化
  2. 源码解读鉴权
  3. 源码解读多授权策略
  4. JWT和Identity

基本流程:其实是为了解决无状态下的用户识别问题

Token校验

鉴权授权: 鉴权中心—根据账号密码颁发token

  • 带着Token就可以访问API,API认可token,不需要去鉴权中心校验
  • 第三方API也认可Token
  • SSO: Single Sign On
  • 防止抵赖-防止篡改-信息传递
    Token

细想一下,这个过程会不会有什么问题? 细思极恐!!!

  • 用户拿到token之后去访问应用,这个token可靠性如何保证,验证token的过程没有返回Authorization Center进行校验?
  • 说到底,这是离线式的,这里面有个信任问题!

如何解决信任问题?

  • Token是不是真的?是不是Authorization Center颁发的?是不是有效的?

通过加密算法来建立信任: 通过密钥来加密解密token来实现

  • 对称可逆加密—— 同一个秘钥用来加密解密—必须有秘钥才能加密,必须有秘钥才能解密—-如果token能解密,就能建立信任关系—再通过其他信息校验是否有效–

  • 非对称可逆加密—— 一组秘钥对(私钥加密+公钥解密)—由私钥加密的内容,提供公钥别人获取来解密—只要能解密,就能证明来源—建立了信任关系—再通过其他信息校验是否有效—

  • 对称速度快—秘钥不安全—–内部用

  • 非对称速度慢—秘钥安全—-第三方

常用的两种方式:

  1. HS256
    HS256 (带有 SHA-256 的 HMAC 是一种对称算法, 双方之间仅共享一个 密钥。由于使用相同的密钥生成签名和验证签名, 因此必须注意确保密钥不被泄密。

  2. RS256
    RS256 (采用SHA-256 的 RSA 签名) 是一种非对称算法, 它使用公共/私钥对: 标识提供方采用私钥生成签名, JWT 的使用方获取公钥以验证签名。由于公钥 (与私钥相比) 不需要保护, 因此大多数标识提供方使其易于使用方获取和使用 (通常通过一个元数据URL)。

JWT-Json Web Token

官网:https://jwt.io/

1 授权:这是使用JWT的最常见方案。一旦用户登录,每个后续请求将包括JWT,允许用户访问该令牌允许的路由,服务和资源。Single Sign On是一种现在广泛使用JWT的功能,因为它的开销很小,并且能够在不同的域中轻松使用。

2 信息交换:JSON Web令牌是在各方之间安全传输信息的好方法。因为JWT可以签名 - 例如,使用公钥/私钥对 - 您可以确定发件人是他们所说的人。此外,由于使用标头和有效负载计算签名,您还可以验证内容是否未被篡改。

JWT令牌结构

  • Header 头
    { “alg”: “HS256”, “typ”: “JWT”}

  • Payload 有效载荷—不是加密,只是序列化,JWT 默认是不加密的,任何人都可以读到

  • Signature 签名–防止抵赖-防止篡改
    =HMACSHA256( base64UrlEncode(header) + “.” + base64UrlEncode(payload), secret)

JWT形式就是这样 xxxxx.yyyyy.zzzzz,其实就是两个目的:

  1. 加密-解密,只要能解密,就能证明来源
  2. 解密后比对内容,看是否篡改

举对称加密的HS256这个例子,通过下图可以看到,token颁发者将header和payload.data组合进行MDK5加密成一字符串,然后通过密钥加密生成签名;对于接收者,首先将签名用密钥解密,如果能解开,那就解决了信任问题,其次,解开后再验证payload.data中的验证数据是否有效,看看有没有被篡改。

JWT实践

鉴权中心颁发Token

颁发Token 的过程就是根据用户输入的用户名密码生成Token,主要的过程依靠下面的JWTHSService这个服务:

登陆的API:

[Route("Login")]
[HttpPost]
public string Login(string name, string password)
{
  if ("Chaoqiang".Equals(name) && "123456".Equals(password))//应该数据库
  {
    CurrentUserModel currentUser = new CurrentUserModel()
    {
      Id = 123,
      Account = "zhengchaoqiang,com",
      EMail="12345678@qq.com",
      Mobile="1234567890",
      Sex = 1,
      Age = 28,
      Name = "Chaoqiang",
      Role = "Admin"
    };

    string token = this._iJWTService.GetToken(currentUser);
    return JsonConvert.SerializeObject(new
    {
      result = true,
      token
    });
  }
  else
  {
  return JsonConvert.SerializeObject(new
  {
  result = false,
  token = ""
  });
  }
}

生成Token的服务:

public class JWTHSService : IJWTService
{
  #region Option注入
  private readonly JWTTokenOptions _JWTTokenOptions;
  public JWTHSService(IOptionsMonitor<JWTTokenOptions> jwtTokenOptions)
  {
    this._JWTTokenOptions = jwtTokenOptions.CurrentValue;
  }
  #endregion

  public string GetToken(CurrentUserModel userModel)
  {
    var claims = new[]
    {
      new Claim(ClaimTypes.Name, userModel.Name),
      new Claim("EMail", userModel.EMail),
      new Claim("Account", userModel.Account),
      new Claim("Age", userModel.Age.ToString()),
      new Claim("Id", userModel.Id.ToString()),
      new Claim("Mobile", userModel.Mobile),
      new Claim(ClaimTypes.Role,userModel.Role),
      //new Claim("Role", userModel.Role),//这个不能角色授权
      new Claim("Sex", userModel.Sex.ToString())//各种信息拼装
    };
    var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(this._JWTTokenOptions.SecurityKey));
    var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
    var token = new JwtSecurityToken(
        issuer: this._JWTTokenOptions.Issuer,
        audience: this._JWTTokenOptions.Audience,
        claims: claims,
        expires: DateTime.Now.AddMinutes(60),//5分钟有效期
        notBefore: DateTime.Now.AddMinutes(1),//1分钟后有效
        signingCredentials: creds);
    string returnToken = new JwtSecurityTokenHandler().WriteToken(token);
    return returnToken;
  }
}

值得注意的是:
Claims (Payload) Claims 部分包含了一些跟这个 token 有关的重要信息。 JWT 标准规定了一些字段,下面节选一些字段:

  • iss: The issuer of the token,token 是给谁的,颁发者身份标识,表示 Token 颁发者的唯一标识,一般是一个 http(s) url,如 https://www.baidu.com。
  • sub: The subject of the token,token 主题
  • exp: Expiration Time。 token 过期时间,Unix 时间戳格式
  • iat: Issued At。 token 创建时间, Unix 时间戳格式
  • jti: JWT ID。针对当前 token 的唯一标识
  • 除了规定的字段外,可以包含其他任何 JSON 兼容的字段。

API中如何鉴权

  1. 首先,管道中使用app.UseAuthentication();,鉴权:解析信息–就是读取token,解密token;
  2. 然后,在serviceConfigure中要指定如何来鉴权:

指定Scheme,这里的Scheme其实就是Bearer

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)//Scheme

怎么理解这个Bearer呢?我们可以通过Postman直观地来看一下:
在请求的Header中有一个Authorization,然后有一种类型叫Bearer Token,其实就是Bearer+空格+Token这种形式。

其实这也就等价于下面这样的操作,鉴权时直接在Header中对Authorization截取Bearer。

指定完Scheme之后,还需要指定校验的规则和细节:

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)//Scheme
.AddJwtBearer(options =>
{
  options.TokenValidationParameters = new TokenValidationParameters
  {
    //JWT有一些默认的属性,就是给鉴权时就可以筛选了
    ValidateIssuer = true,//是否验证Issuer
    ValidateAudience = true,//是否验证Audience
    ValidateLifetime = true,//是否验证失效时间
    ValidateIssuerSigningKey = true,//是否验证SecurityKey
    ValidAudience = tokenOptions.Audience,//
    ValidIssuer = tokenOptions.Issuer,//Issuer,这两项和前面签发jwt的设置一致
    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(tokenOptions.SecurityKey)),//拿到SecurityKey
  };
});
  1. 接着,需要在被访问的API上加上[Authorize]标签,框架自动完成鉴权操作。
public class JWTController : Controller
{
  [Authorize]
  public IActionResult Index()
  {
    foreach (var item in base.HttpContext.User.Identities.First().Claims)
    {
      Console.WriteLine($"{item.Type}:{item.Value}");
    }
    return View();
  }
}
  1. 请求Token,带上token访问API

不带Token直接访问API,返回401未授权错误码:

先去鉴权中心获取Token:

然后将Token手动放到请求Header的Authorization中,带着Token进行请求

非对称加密
用非对称加密,先看鉴权中心这边的变化:
首先生成私钥和公钥,序列化到两个json文件中去:

public static RSAParameters GenerateAndSaveKey(string filePath, bool withPrivate = true)
{
  RSAParameters publicKeys, privateKeys;
  using (var rsa = new RSACryptoServiceProvider(2048))//即时生成
  {
    try
    {
      privateKeys = rsa.ExportParameters(true);
      publicKeys = rsa.ExportParameters(false);
    }
    finally
    {
      rsa.PersistKeyInCsp = false;
    }
  }
  File.WriteAllText(Path.Combine(filePath, "key.json"), JsonConvert.SerializeObject(privateKeys));
  File.WriteAllText(Path.Combine(filePath, "key.public.json"), JsonConvert.SerializeObject(publicKeys));
  return withPrivate ? privateKeys : publicKeys;
}

其中,生成一个秘钥组key.json和key.public.json,生成token就有一点不一样:
主要就是加密算法不一样: var credentials = new SigningCredentials(new RsaSecurityKey(keyParams), SecurityAlgorithms.RsaSha256Signature);, 生成token如下:

public string GetToken(CurrentUserModel userModel)
{
  //string jtiCustom = Guid.NewGuid().ToString();//用来标识 Token
  var claims = new[]
  {
    new Claim(ClaimTypes.Name, userModel.Name),
    new Claim("EMail", userModel.EMail),
    new Claim("Account", userModel.Account),
    new Claim("Age", userModel.Age.ToString()),
    new Claim("Id", userModel.Id.ToString()),
    new Claim("Mobile", userModel.Mobile),
    new Claim(ClaimTypes.Role,userModel.Role),
    //new Claim("Role", userModel.Role),//这个不能角色授权
    new Claim("Sex", userModel.Sex.ToString())//各种信息拼装
  };

  string keyDir = Directory.GetCurrentDirectory();
  if (RSAHelper.TryGetKeyParameters(keyDir, true, out RSAParameters keyParams) == false)
  {
    keyParams = RSAHelper.GenerateAndSaveKey(keyDir);
  }
  var credentials = new SigningCredentials(new RsaSecurityKey(keyParams), SecurityAlgorithms.RsaSha256Signature);

  var token = new JwtSecurityToken(
      issuer: this._JWTTokenOptions.Issuer,
      audience: this._JWTTokenOptions.Audience,
      claims: claims,
      expires: DateTime.Now.AddMinutes(60),//5分钟有效期
      signingCredentials: credentials);
  var handler = new JwtSecurityTokenHandler();
  string tokenString = handler.WriteToken(token);
  return tokenString;
}

用非对称加密,在API端的变化,主要IssuerSigningKey 不一样了,它是通过读取公钥key.public.json来解密,然后校验:

#region 读取公钥
string path = Path.Combine(Directory.GetCurrentDirectory(), "key.public.json");
string key = File.ReadAllText(path);//this.Configuration["SecurityKey"];
Console.WriteLine($"KeyPath:{path}");

var keyParams = JsonConvert.DeserializeObject<RSAParameters>(key);
var credentials = new SigningCredentials(new RsaSecurityKey(keyParams), SecurityAlgorithms.RsaSha256Signature);
#endregion

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
  options.TokenValidationParameters = new TokenValidationParameters
  {
    ValidateIssuer = true,//是否验证Issuer
    ValidateAudience = true,//是否验证Audience
    ValidateLifetime = true,//是否验证失效时间
    ValidateIssuerSigningKey = true,//是否验证SecurityKey
    ValidAudience = this.Configuration["JWTTokenOptions:Audience"],//Audience
    ValidIssuer = this.Configuration["JWTTokenOptions:Issuer"],//Issuer,这两项和前面签发jwt的设置一致
    IssuerSigningKey = new RsaSecurityKey(keyParams)
  };
});

小结

对JWT对鉴权中心而言,主要的步骤:

  • Core WebApi当做鉴权服务器
  • 配置基础信息(对称+不对称)
  • 接受请求,完成验证,创建token

对JWT的客户端而言,主要的步骤:

  • 客户端 Configure+ConfigureService
  • 配置基础信息(对称+不对称)
  • 配置请求接受鉴权授权

对称加密校验流程:

  • 直接访问无权限要求地址—200
  • 访问有权限要求地址—401
  • 登录后获取token
  • 拿着token登录需要权限认证地址—200
  • Authorization: bearer token

非对称可逆加密校验流程:

  • 接访问无权限要求地址—200
  • 访问有权限要求地址—401
  • 登录后获取token
  • 配置最新的public key
  • 拿着token登录需要权限认证地址—200
  • Authorization: bearer token

授权

基于角色的授权策略

  1. 在鉴权授权中心,生成token时,加上角色的Claims就可以了:
    new Claim(ClaimTypes.Role,userModel.Role),
  2. 然后在客户端API上,加上基于Role的Authorize即可:
    [Authorize(Roles = "Admin")]

基于Policy(策略)的授权策略

基于现有的规则进行叠加,在API的startup service中中增加Authorization的检验:

services.AddAuthorization(options =>
{
  options.AddPolicy("AdminPolicy",
    policyBuilder => policyBuilder
    .RequireRole("Admin")//Claim的Role是Admin
    .RequireUserName("Chaoqiang")//Claim的Name是Eleven
    .RequireClaim("EMail")//必须有某个Cliam
    .RequireClaim("Account")
  );//内置
});

在API上增加授权的验证:
[Authorize(Policy = "AdminPolicy")]

下面进行一些升级,自定义策略:

 services.AddAuthorization(options =>
{
  options.AddPolicy("AdminPolicy",
  policyBuilder => policyBuilder
    .RequireRole("Admin")//Claim的Role是Admin
    .RequireUserName("Eleven")//Claim的Name是Eleven
    .RequireClaim("EMail")//必须有某个Cliam
    .RequireClaim("Account")
    //.Combine(qqEmailPolicy)
    .AddRequirements(new CustomExtendRequirement())
  );//内置

  options.AddPolicy("QQEmail", policyBuilder => policyBuilder.Requirements.Add(new QQEmailRequirement()));
  options.AddPolicy("DoubleEmail", policyBuilder => policyBuilder
  .AddRequirements(new CustomExtendRequirement())
  .Requirements.Add(new DoubleEmailRequirement()));
});

services.AddSingleton<IAuthorizationHandler, ZhaoxiMailHandler>();
services.AddSingleton<IAuthorizationHandler, QQMailHandler>();
services.AddSingleton<IAuthorizationHandler, CustomExtendRequirementHandler>();

这些定制化的Requirement继承自IAuthorizationRequirement,需要有对应的AuthorizationHandler来实现,代码如下:

public class CustomExtendRequirement : IAuthorizationRequirement
{
}

public class CustomExtendRequirementHandler : AuthorizationHandler<CustomExtendRequirement>
{
  protected override Task HandleRequirementAsync(AuthorizationHandlerContext       context, CustomExtendRequirement requirement)
  {
    //context.User.Identities.First().Claims

    //var jti = context.User.FindFirst("jti")?.Value;// 检查 Jti 是否存在
    bool tokenExists = false;
    if (tokenExists)
    {
      context.Fail();
    }
    else
    {
      context.Succeed(requirement); // 显式的声明验证成功
    }
    return Task.CompletedTask;
  }
}
/// <summary>
/// 两种邮箱都能支持 
/// 
/// </summary>
public class DoubleEmailRequirement : IAuthorizationRequirement
{
}

public class QQMailHandler : AuthorizationHandler<DoubleEmailRequirement>
{
  protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DoubleEmailRequirement requirement)
  {
    if (context.User != null && context.User.HasClaim(c => c.Type == "EMail"))
    {
      var email = context.User.FindFirst(c => c.Type == "EMail").Value;
      Console.WriteLine(email);
      if (email.EndsWith("@qq.com", StringComparison.OrdinalIgnoreCase))
      {
        context.Succeed(requirement);
      }
      else
      {
        //context.Fail();//不设置失败
      }
    }
    return Task.CompletedTask;
  }
}

public class ZhaoxiMailHandler : AuthorizationHandler<DoubleEmailRequirement>
{
  protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DoubleEmailRequirement requirement)
  {
    if (context.User != null && context.User.HasClaim(c => c.Type == "EMail"))
    {
      var email = context.User.FindFirst(c => c.Type == "EMail").Value;
      Console.WriteLine(email);
      if (email.EndsWith("@ZhaoxiEdu.Net", StringComparison.OrdinalIgnoreCase))
      {
        context.Succeed(requirement);
      }
      else
      {
        //context.Fail();
      }
    }
    return Task.CompletedTask;
  }
}

小结:
自定义策略完成授权:

  • Requirement + Handler
  • +注册实例+组装Policy
  • +标识

JWT局限性

基于Token式的传递,鉴权授权,有着天生的缺陷,还欠缺什么?

  • Token泄漏了—重放攻击

  • 改密码了,希望之前的token失效

  • 滑动过期—token在用,就别过期

问题很多很多,但是大部分解决不了,是由本质决定的:API和鉴权中心之间是不进行通讯的!
Token

问题1:改密码token过期问题

改密码肯定要通知一下,不然API如何知道密码改了?例如QQ密码改了,第三方怎么知道?
方案一:考虑通过Redis来做中间层。

  • 生成token时—除了生成token(含guid)—还生成个guid+用户id—写入redis
  • 验证token时—拿guid去redis校验
  • 改密码—redis那一项数据—之前的给删掉/过期/无效
  • 验证旧token—发现过期
  • 验证新token就没事儿

细想一下,这不就代表着,客户端和鉴权中心得通信,显然不是很好的方案!

方案二:减少token有效期—降低伤害

客户端校验2个扩展点:

  1. 要么是JWT鉴权时的观察者-委托加入逻辑
  2. 要么是授权时基于requirement完成扩展

颁发Token时,可以指定有效期和生效时间:

var token = new JwtSecurityToken(
  issuer: this._JWTTokenOptions.Issuer,
  audience: this._JWTTokenOptions.Audience,
  claims: claims,
  expires: DateTime.Now.AddMinutes(60),//5分钟有效期
  notBefore: DateTime.Now.AddMinutes(1),//1分钟后有效
  signingCredentials: creds);

在鉴权环节里去进行扩展:

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)//Scheme
.AddJwtBearer(options =>
{
  options.TokenValidationParameters = new TokenValidationParameters
  {
    //JWT有一些默认的属性,就是给鉴权时就可以筛选了
    ValidateIssuer = true,//是否验证Issuer
    ValidateAudience = true,//是否验证Audience
    ValidateLifetime = true,//是否验证失效时间
    ValidateIssuerSigningKey = true,//是否验证SecurityKey
    ValidAudience = tokenOptions.Audience,//
    ValidIssuer = tokenOptions.Issuer,//Issuer,这两项和前面签发jwt的设置一致
    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(tokenOptions.SecurityKey)),//拿到SecurityKey
    AudienceValidator = (m, n, z) =>
    {
      //等同于去扩展了下Audience的校验规则---鉴权
      return m != null && m.FirstOrDefault().Equals(this.Configuration["audience"]);
    },
    LifetimeValidator = (notBefore, expires, securityToken, validationParameters) =>
    {
      return notBefore <= DateTime.Now && expires >= DateTime.Now;
      //&& validationParameters
    }//自定义校验规则
  };
});

在授权环节里进行扩展:
通过Requirement叠加规则,拿到context.User.Identities.First().Claims之后就可以随便操作。

问题2:重放攻击

  • 重复请求—-请求带个随机数—随机数搞个Redis—执行前先redis一下
  • 随机数不是在token

问题3:滑动过期

  • Token是不会变的,而且只能鉴权中心发的
  • 不能默认检测有效期—可以扩展校验—当然也可以放在requirement
  • 还是写入Redis—这个滑动

文章作者: Chaoqiang
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Chaoqiang !
评论
 上一篇
Netcore认证授权与IdentityServer4(3)IdentityServer4详解 Netcore认证授权与IdentityServer4(3)IdentityServer4详解
主要内容 OAuth2.0&Token IdentityServer4-四模式实现 授权策略和扩展 OIDC-Hybrid OAuth 2.0什么是OAuth 2.0? 授权机制,是一种规范/委托协议,制定了授权流程;它可以让那
下一篇 
Netcore认证授权与IdentityServer4(1)鉴权授权源码 Netcore认证授权与IdentityServer4(1)鉴权授权源码
主要概要这个文章主要来聊一聊Net Core中的认证与授权,以及Net Core生态中非常火热的IdentityServer4这个组件的使用,主要内容如下: 鉴权授权流程变化 源码解读鉴权 源码解读多授权策略 JWT和Identity
  目录