主要概要
这个文章主要来聊一聊Net Core中的认证与授权,以及Net Core生态中非常火热的IdentityServer4这个组件的使用,主要内容如下:
- 鉴权授权流程变化
- 源码解读鉴权
- 源码解读多授权策略
- JWT和Identity
Cookie/Session
基本流程:其实是为了解决无状态下的用户识别问题
Token校验
鉴权授权: 鉴权中心—根据账号密码颁发token
- 带着Token就可以访问API,API认可token,不需要去鉴权中心校验
- 第三方API也认可Token
- SSO: Single Sign On
- 防止抵赖-防止篡改-信息传递
细想一下,这个过程会不会有什么问题? 细思极恐!!!
- 用户拿到token之后去访问应用,这个token可靠性如何保证,验证token的过程没有返回Authorization Center进行校验?
- 说到底,这是离线式的,这里面有个信任问题!
如何解决信任问题?
- Token是不是真的?是不是Authorization Center颁发的?是不是有效的?
通过加密算法来建立信任: 通过密钥来加密解密token来实现
对称可逆加密—— 同一个秘钥用来加密解密—必须有秘钥才能加密,必须有秘钥才能解密—-如果token能解密,就能建立信任关系—再通过其他信息校验是否有效–
非对称可逆加密—— 一组秘钥对(私钥加密+公钥解密)—由私钥加密的内容,提供公钥别人获取来解密—只要能解密,就能证明来源—建立了信任关系—再通过其他信息校验是否有效—
对称速度快—秘钥不安全—–内部用
非对称速度慢—秘钥安全—-第三方
常用的两种方式:
HS256
HS256 (带有 SHA-256 的 HMAC 是一种对称算法, 双方之间仅共享一个 密钥。由于使用相同的密钥生成签名和验证签名, 因此必须注意确保密钥不被泄密。RS256
RS256 (采用SHA-256 的 RSA 签名) 是一种非对称算法, 它使用公共/私钥对: 标识提供方采用私钥生成签名, JWT 的使用方获取公钥以验证签名。由于公钥 (与私钥相比) 不需要保护, 因此大多数标识提供方使其易于使用方获取和使用 (通常通过一个元数据URL)。
JWT-Json Web Token
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,其实就是两个目的:
- 加密-解密,只要能解密,就能证明来源
- 解密后比对内容,看是否篡改
举对称加密的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中如何鉴权
- 首先,管道中使用
app.UseAuthentication();
,鉴权:解析信息–就是读取token,解密token; - 然后,在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
};
});
- 接着,需要在被访问的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();
}
}
- 请求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
授权
基于角色的授权策略
- 在鉴权授权中心,生成token时,加上角色的Claims就可以了:
new Claim(ClaimTypes.Role,userModel.Role),
- 然后在客户端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和鉴权中心之间是不进行通讯的!
问题1:改密码token过期问题
改密码肯定要通知一下,不然API如何知道密码改了?例如QQ密码改了,第三方怎么知道?
方案一:考虑通过Redis来做中间层。
- 生成token时—除了生成token(含guid)—还生成个guid+用户id—写入redis
- 验证token时—拿guid去redis校验
- 改密码—redis那一项数据—之前的给删掉/过期/无效
- 验证旧token—发现过期
- 验证新token就没事儿
细想一下,这不就代表着,客户端和鉴权中心得通信,显然不是很好的方案!
方案二:减少token有效期—降低伤害
客户端校验2个扩展点:
- 要么是JWT鉴权时的观察者-委托加入逻辑
- 要么是授权时基于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—这个滑动