通过本篇文章您将Get:
Http
的一些身份验证概念AspNetCore
中实现身份验证方案JWT
等概念的基础知识Bearer Token
对WebAPI
进行保护时长为大约有十五分钟,内容丰富,建议先投币再上车观看?
本文附带了普通Bearer JwtToken
验证和微信小程序
验证登录的源代码,效果图您可以参考下方的Gif图片。
该项目的仓库地址,您可以点击这里进行跳转。
注:该项目通过uni-app
来编写,没有了解过的朋友也不用担心,本文最后会对该演示项目进行一些说明解释。
对于大多数应用系统来说,几乎都离不开身份验证。因为我们需要保护一些数据,不让“非法”用户获取。所以我们必须得根据自身项目情况来添加对身份验证的支持功能。
在这之前,我们先不要考虑什么Bearer
,JWT
,OpenId
等概念,忘掉他们,让我们从0开始。
假如我们现在有一个Web Api应用程序,采用了AspNetCore
来编写。在没有任何标准协议和框架的支持下,我们会如何对一个用户进行身份验证呢?
或许您已经想到了,既然用户是通过账号和密码来登录的,那么我就可以通过账号和密码来对他进行验证呀。让用户直接把用户名
和密码
传给我,我不就知道是他了吗?
那怎么传值呢?用Get? 比如下方的这个请求:
> http://your-address/Book/Get?user='myName'&pwd='abc123'
这样每次请求的时候我就能够得到用户名
和密码
了,然后通过和数据库校验就能够判断当前的用户是不是通过了。
但是这种方式您很快就能发现问题,每个api不都要增加一些参数吗?url是一个很普通的东西,这样很容易就把账号密码泄露了。
所以,我们改变一下方案,把用户名
和密码
放到Http的请求头(Header)里面,该项的Header Key值叫做Authorization
。
那么我们的请求可能就像这样了:
Request URL: http://your-address/Book/Get
Request Header:
:method: GET
Authorization:myName:abc123
当然,如果把用户名密码信息在加密一下就更好了。为了让服务端能够解密,所以采用了Base64
加密。所以请求就可能成为了这个样子:
Request URL: http://your-address/Book/Get
Request Header:
:method: GET
Authorization:bXlOYW1lOmFiYzEyMw==
这样服务端很容易就能够通过Header来进行用户验证。 获取header的Authorization
项 -> 进行Base64
解密 -> 根据数据库内容判断用户名和密码 -> 验证通过。
这种验证方案是不是很简单呢? 但是到这里,您可能会说,这种方案也太简陋了吧。如果我拦截到了请求的包,那不等于这个人直接把用户名
和密码
送到我的手里吗?
确实是这样的,如果我们在进行Http请求的时候受到了中间人攻击,那么账号和密码都将被泄露,“非法分子”可以拿着得到的用户名和密码登录系统进行任何操作。
所以,我们必须采用Https传输。这样,中间人得到的信息是加密的,他也无法解析出来。
而这种直接把用户名
和密码
放置在请求头中传输的方案,正是伴随Http
协议一同提出的Basic
验证方案:Wiki Basic access authentication。
当身份验证服务和咱们的业务系统粘连在一起的时候(比如传统的单体环境),基础的验证方案其实能够很好的满足咱们的需求。但是,当身份验证服务被独立出来,我们就需要使用过多的成本去进行验证:比如身份验证服务部署在服务器A,而业务服务在服务器B,如果按照上面的验证方案,我们每访问一次服务器B,那么服务器B就需要把该请求所携带的信息转发至服务器A去验证,服务器A根据转发过来的Header中的Authorization
项,从数据库中或者内存中查询对应的身份信息,进行通过
或者拒绝
操作,然后服务器B再根据服务器A所返回的信息进行处理。
而网络通信的成本是昂贵,假如不需要身份验证的话,只需要一次就能够完成业务,而现在,会被拆分成多次,时间开销是很大的。再一点,所有的访问压力都会被推到身份验证服务器,如果有B,C,D三个业务服务器,那岂不是所有的服务器都要于身份验证服务器进行交互?
所以,我们必须得使用另外的手段来应对这种身份验证方案,那就是自包含的身份信息
:当身份验证服务器验证通过时,就发一个类似于令牌的东西给客户端,与上面的那种方案较为不同的是,该令牌是一种包含了必要验证信息的加密字符串。
比如我们每次身份验证都是为了获取到userId
这一项信息。基础验证方案中,我们通过传递username
和password
来获取userId
。而现在,我们就直接让令牌来包含userId
这一项内容,而以后我们每次携带该令牌去访问API的时候,就不需要再到数据库中进行查找用户来获取Id了。这样就能大幅度够减缓服务器的查找压力。
用户传递了username
和password
到身份验证服务器,服务器通过与数据库中的用户信息
进行匹配,发现是userId = 3
的用户。此时身份验证服务器则产生一个类似于userId:3&userName:myName
的字符串返回给用户,下一次用户访问时,就携带上该字符串在请求头部进行传递,而其它的服务器看到该信息后,就认为此刻的用户是userId
为3的用户,则返回该用户对应的数据。
上方是咱们根据已有的结论来模拟的验证方案,但是您会发现,该方案其实有很大的漏洞。 比如客户端接收到了userId:3&userName:myName
的验证令牌,但是他突然起了坏心眼,既然我是id为3
的用户,那肯定在我之前就有id为2或者为1的用户,那我直接改一下这个数值,然后再进行访问,是不是就可以得到其它用户的信息了呢? 当然,答案肯定为是的!
所以我们必须要做的事情就是:“将结果加密”。当然,加密的方式有对称加密
和非对称加密
。对称加密就是加密和解密共用一个密匙,比如密码为123
,那么加密使用123
来加密,解密也需要用123
来解密,所以密匙是必须得严格保护,不然泄露之后就凉凉啦。而非对称加密
就是产生一个公钥
和私钥
,可以用私钥
来加密,然后别人可以用公钥
来进行解密验证。
在咱们传输令牌的这个案例中,对称加密
和非对称加密
咱们都可以使用。假如我们此处使用了AES
的对称加密算法,而加密的密码为12345
,那么userId:3&userName:myName
将会被我们加密为:
JX9lHmBFuhckNOP3sGG0/X0TooCjlsXBGyI3Gz1UudA=
此时,客户就没有办法再修改该内容了。而业务服务器,使用12345
来对该令牌进行解密就能够获取到信息了。
但是有些时候,身份验证服务器不愿意与其它业务服务器共享12345
这个密匙,因为知道的人越多,泄露的风险就越大,那么他就可以使用非对称加密的方案。身份验证服务器独享一个私钥
来进行加密,而业务服务器可以从身份验证服务器处获取到公钥
来进行验证。
这样我们就完成了自包含的身份信息
令牌的颁发,但是不要急,还有问题。因为这个令牌的生效区间是什么时候呢? 我们现在只是颁发了信息,但是您想啊,这样不是一发出去了之后就一发不可收拾了吗? 用户可以一直使用该令牌来进行访问,即使他已经更改了密码,但是令牌还是依旧生效的,如果令牌一泄露,那他的账号就永久的凉凉了。
所以,我们必须得给这个令牌一个过期时间,如果令牌超过了过期时间,那么该令牌就是无效的。所以我们依旧让过期时间被自包含在令牌信息中,所以原有的令牌就可能被我们改成这样:userId:3&userName:myName&expireTime:2020/02/02 12:00
。这样业务服务器进行验证的时候,就首先验证是否过期就行啦,果真爽歪歪~。
Javascript Object
大家族在看了上面介绍的基础身份验证方案之后,相信您已经对身份验证有了一点的了解和认识。其实,上面的方案也是现代身份验证的雏形,但是本质上原理是相通的。
既然是雏形,那么现在肯定有更完善的身份验证方案。所以,请抬好小板凳,准备好瓜子花生,即将进行飞升。
接下来,您将看到WebApi
最为常见的身份验证方案JWT
。在提及JWT
之前,我想您可能已经听过OAuth2.0
或者OpenID Connect
等标准验证框架,亦或是在.NET
平台下,它们的实现方案IdentityServer
。
关于OAuth2.0和OpenID的概念,由于篇幅有限,将会在下一篇文章中为大家带来介绍.
来看一看OpenID Connect
的架构图,您可以看到,JWT
是作为它的底成实现支持。所以,对于了解JWT
来说是必要的。
但是在该图中,除了JWT
您还会看到其它的类似单词,比如:JWS
、JWE
、JWK
等等。但是当您想去对他们进行了解的时候,很抱歉,百度居然不靠谱了。?
不要慌张,在有了上面基础验证方案的思路之后,这些对于您来说都不是问题。
这些JW*
的命名,其实他们都属于一种东西:Javascript Object Signing and Encryption (JOSE)
。从命名中其实就可以看出,它是负责了签名
和加密解密
的工作。而Javascript Object
对于大家来说就更不陌生了,它定义了如何组织一套数据结构的规范。
在结合我们上面讲的那个自包含的验证
,当时我们定义了一个类似于userId:3&userName:myName&expireTime:2020/02/02 12:00
的令牌,该令牌我使用了&
符号来进行拼接,虽然能符合我们的需求,但是很显然这不是一个业界通用的做法。这就导致其它系统与咱们的系统对接的时候都需要重写一次该验证的处理流程。
所以,我们需要一个更通用,大家都认可的规范
。而JOSE
则正是充当了这样的一个角色。
对于Python
用户来说,对于jose
可能不是太陌生,因为在Py
中有着很出名的jose
处理库。而在.NET
中就没有对该关键字
很出名的支持库。
好啦,回到这些规范
上来,我们先来看看他们各自的一些定义:
术语 | 说明 |
---|---|
JWS | JSON Web Signature (RFC7515) 定义了使用JSON进行数字签名的过程。 |
JWE | JSON Web Encryption (RFC7516) 定义了使用JSON加密的过程。 |
JWA | JSON Web Algorithm (RFC7518) 定义用于数字签名或加密的算法列表 |
JWK | JSON Web Key (RFC7517) 定义密码密钥和密钥集的表示方式。 |
JWA
规范了算法的简写描述,比如以下是应用于JWT
的某些算法,就好像咱们在JWT
中经常看到的alg:HS256
,该HS256
就是在该规范中被解释的术语,代表了使用HMAC对称加密后
再使用SHA-256
进行哈希摘要。
值 | 说明 |
---|---|
HS256 | HMAC w/ SHA-256 hash |
HS384 | HMAC w/ SHA-384 hash |
RS256 | RSA PKCS v1.5 w/ SHA-256 hash |
RS384 | RSA PKCS v1.5 w/ SHA-384 hash |
ES256 | ECDSA w/ P-256 curve and SHA-256 hash |
JWS规范指出了使用JSON格式来表示加密内容。JWS由三个部分所组成:JOSE Header
、JWS Payload
和JWS Signature
。
而JWS的核心在于第三个部分:JWS Signature
签名。它根据前面的两个部分来计算处第三个部分的签名,防止该信息再传递的过程中被修改。(想一想我们最初的加密自包含令牌
)。
签名的计算规则如下:
摘要算法(加密算法(ASCII(BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload))))
比如我们有这样的一个头部
和荷载
内容:
{
"typ":"JWT",
"alg":"HS256"
}
{
"iss":"joe",
"exp":1300819380,
"http://example.com/is_root":true
}
那么我们会对头部进行编码加密,通过BASE64URL
加密,则对应内容为eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9
。同样我们再使用BASE64URL
加密荷载
部分,对应内容为eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFt cGxlLmNvbS9pc19yb290Ijp0cnVlfQ
。
因为BASE64URL
加密是可逆的,所以我们还需要对这些内容进行签名,才能在传递时保护数据安全。根据头部的信息我们得知使用的是HS256
,这就对应着JWA
里面的信息,我们需要通过HMAC
来加密,然后再使用SHA-256
进行摘要。最终再使用BASE64URL
编码该签名,我们就能够得出签名的最终结果为:dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
。
最后,将三个部分通过.
链接起来,就构成了一个整体加密内容:
eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.
eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.
dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
看到这里,您可能会说,这不是JWT
吗?格式明明一模一样,是的,JWS
和JWA
等就是JWT
的基础,JWT
在这之上提供了新的规范,比如荷载
中的Claim
等信息。下面将会讲到。
JWK规范定义了如何以JSON格式表示非对称密钥,并引入了密钥集集合(JWKS),该集合为提供者发布签名和加密密钥提供了一种方法。
来看看JWK
的格式例子:
{
"kty":"RSA",
"kid":"i0wnn",
"use":"sig",
"n":"mdrLAp5GR8o5d5qbwWTYqNGuSXHTIE6w9HxV445oMACOWRuwlOGVZeKJQXHM9cs5Dm7iUfNVk4pJBttUxzcnhVCRf
9tr20LJB7xAAqnFtzD7jBHARWbgJYR0p0JYVOA5jVzT9Sc-j4Gs5m8b-am2hKF93kA4fM8oeg18V_xeZf11WWcxnW5YZwX
9kjGBwbK-1tkapIar8K1WrsAsDDZLS_y7Qp0S83fAPgubFGYdST71s-B4bvsjCgl30a2W-je9J6jg2bYxZeJf982dzHFqV
QF7KdF4n5UGFAvNMRZ3xVoV4JzHDg4xe_KJE-gOn-_wlao6R8xWcedZjTmDhqqvUw",
"e":"AQAB"
}
其中Kty
(不是Ktv
哈)表示了该算法的系列,比如RSA
或者EC
等。kid
表示了该条密匙内容的id
。而里面的n
和e
分别代表了RSA
加密中的modulus
和exponent
。
再想想最初的我们解释的自包含令牌
,对于非对称加密
,我们需要从服务端获取到公钥,那么现在问题就来了,公钥怎么表示呢? 而JWK
相当于就干了这样一件事。
什么?你问我这东西哪儿见过?您的IdentityServer4
里面是不是公开了一个节点叫做.well-known/openid-configuration/jwks
,眼熟吧?jwks
不就是这一个东西吗? 点击这里看看吧!
来吧,万众期待的JWT
。在JOSE
家族中,我们看到了这么多个JW*
的东西,其实感觉上它们都是为了最后这一项东西所服务,那就是JWT
。这也是为什么,大家仅仅听过JWT
,而对其它的概念都不是太了解的原因。
JWT是一种紧凑的、URL安全的方法,用于表示双方之间要传输的声明。JWT中的声明被编码为JSON对象,该对象用作JSON Web签名(JWS)结构的有效负载或JSON Web加密(JWE)结构的明文,从而使声明能够通过消息身份验证。
对于我们常用的JWT
,是采用了JWS
的签名式加密方案。所以结构就是 "A.B.C"的样子,用Header
来描述了签名加密所用的算法,该描述遵循了JWA
,而使用Playload
来包含咱们所需要的东西,在JWT
里面,它们叫做JWT Claims Set
,而JWT
提出了很多内置的Claim
规范,下面我们会看到。最后是Signature
,这就是基于JWS
所得到的内容。
JWT
规范定义了七个可选的、已注册的声明(Claim),并允许将公共和私人声明包括在令牌中,这七个已登记的声明是:
Claim | 描述 |
---|---|
iss (Issuer) | 确定了签发JWT的主体(发行者)。一般是STRING或者URI,比如"http://my.identityServer.com/5000" |
sub (Subject) | JWT所代表的主题。主题值必须限定为在发行者的上下文中是本地唯一的,或者是全局唯一的。所以你会在某些例子中看到它保存了用户的ID等。一般是STRING或者URI |
aud (Audience) | JWT的受众(该单词我也不知道该如何翻译比较合适)。一般是STRING或者URI,比如"http://my.clientiIp.com/5000" |
exp (expire) | JWT的过期时间 |
nbf (not-before) | JWT的生效时间 |
iat ((issued-at) | JWT的颁发时间 |
jti (expire) | JWT的唯一标识符(JWT ID) |
当然,仅仅靠这些值我们一般是无法处理完整业务逻辑的,比如我们往往需要将用户邮箱
等信息放入Token
中,所以我们可以在荷载
中放入我们自定义的一些项,只要保证不要和内置的命名冲突就行啦。
这个概念应该是好多同学经常搞晕的一个概念,可能大家都以为,Bearer Token
就等于JWT
。
当然不是啦,因为Bearer
是HTTP Authorization的类型规范
,而JWT
是一个数据结构的规范
。
还记得我们在最初的时候提到过一个Basic
验证吗? 它的格式是这样的:
Authorization : Basic xxxxxx
在HTTP 1.0中提出了Authorization: <type> <credentials>
这样的格式。 如果Basic类型的验证是Authorization : Basic
,那么你已经可以想到Bearer
是什么样子了。
大家都遵守了这样的规范,才能不乱套,所以为什么有时候我们取消掉Bearer
关键字,有些框架就会不处理该Token
。
关于Bearer
,它是伴随OAuth2.0
所提出,该规范仅仅定义了Bearer Token
的格式(也就是需要带上Bearer关键字在header中),并没有说过Token
一定要使用JWT
格式。
所以如果是Bearer
等于JWT
是不对的。
同该Bearer
所提出的概念还有access_token
和refresh_token
。它们都是同OAuth2.0
一起诞生的,同样的,它们于JWT
也并没有直接的关系,所以并非我一定要用JWT
来生成access_token
和refresh_token
,还有就是当我使用JWT
的时候,并非一定要使用refresh_token
。
但是就像我们最初设想的一样,如果不使用自包含的验证
,服务器将承受巨大的压力。所以在OAuth2.0
中,还是推荐大家使用JWT
,而该方案也同样具有一个标准规范。
有了这些基础知识之后,我们再来看看AspNetCore
中是如何实现身份验证的,在这里我们同样以WebApi
的验证方案来讲解,关于基本的Cookies
验证方案,您可以直接查阅官方文档,但是对于验证来说,原理几乎都是一样的。
在这之前,您将有很多个关键类需要了解:Claim
、ClaimsIdentity
、ClaimsPrincipal
。
Claim
,是身份表示的最小单位,它由核心的Type和Value属性构成。比如一个人会有很多标签,比如身份证号码
,邮箱号码
,手机号码
等等。当您需要验证这某一项信息时,就可以将它申明为一个Claim
,比如:
new Claim('email', "bob@gmail.com")
ClaimsIdentity
:是一组Claim的合集,一个用户或者一个事物往往有多个标签,所以我们可以将它抽象成个高级的事物,而ClaimsIdentity就是该事物。从ClaimsIdentity的构造函数您就可以看出,它接受了一个IEnumerable<Claim>
。
// 创建一个用户身份,注意需要指定AuthenticationType,否则IsAuthenticated将为false。
var claimIdentity = new ClaimsIdentity("myAuthenticationType");
// 添加几个Claim
claimIdentity.AddClaim(new Claim(ClaimTypes.Name, "bob"));
claimIdentity.AddClaim(new Claim(ClaimTypes.Email, "bob@gmail.com"));
ClaimsPrincipal
:是一组ClaimsPrincipal的合集,又进行了一次更高层次的抽象。比如一个用户有教师
的身份,里面有教师ID
、教师邮箱
的声明,但是同时他还具有拖拉机师傅
的身份,具有执业编号
等身份,所以此时我们就可以使用ClaimsPrincipal来表示该用户。
而ClaimsPrincipal就成为了表示一个用户的单位,所以在AspNetCore
的HttpContext
上下文中有一个User
的属性,而该属性就是ClaimsPrincipal
。而当我们需要验证他是不是拖拉机师傅
的时候,就通过他身上的执业编号
就可以验证啦。
AspNetCore
中的身份验证,其实就是一个判断身份正确和构建ClaimsPrincipal
的过程。所以我们就来看看它是如何处理的。
很明显,由于AspNetCore
管道的特性,所以我们一下就能猜到它是在一个较早的中间件中进行的身份验证的。这也是为什么咱们要把app.UseAuthentication();
放到前面的原因。
而关于该验证的中间件其实很简单,它的代码也只有几句:
// 判断当前是否需要进行远程验证,如果是就进行远程验证
var handlers = context.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>();
foreach (var scheme in await Schemes.GetRequestHandlerSchemesAsync())
{
var handler = await handlers.GetHandlerAsync(context, scheme.Name) as IAuthenticationRequestHandler;
if (handler != null && await handler.HandleRequestAsync())
{
return;
}
}
//获取本地的验证方案,进行验证
var defaultAuthenticate = await Schemes.GetDefaultAuthenticateSchemeAsync();
if (defaultAuthenticate != null)
{
var result = await context.AuthenticateAsync(defaultAuthenticate.Name);
if (result?.Principal != null)
{
context.User = result.Principal;
}
}
await _next(context);
AspNetCore
的验证是根据scheme
来区分的,scheme
是什么呢?其实就是咱们的验证方案。一般来说,咱们一套系统往往会有多种登录方案,比如博客园,现在就开放了多种外部登录
的方案:
而AspNetCore
为了便于扩展方便,所以使用了scheme
来作用区分方法,这样我们在不同的时候,指定不同的scheme
就能够进行对应的处理:
scheme | 对应处理方案类 |
---|---|
GoogleHandler | |
WeChatHandler | |
QQHandler | |
Cookies | CookiesHandler |
比如我们现在要进行QQ登录,那么我就需要指定scheme
的值为QQ
,然后就会找到对应的处理程序来进行处理。
所以,在很多身份验证的地方,您都可以看到方法中会带有scheme
这个参数:
HttpContext.SignInAsync(scheme, principal, properties: null);
//我可以这样调用
context.SignInAsync("QQ",...); //代表我将使用QQ身份验证方案.
那么这些Handler
类中都做了一些什么事情呢? 这就回到了我们该篇文章最初的时候,基础验证方案和自包含验证方案。 比如自包含验证的JWT
验证,那内部肯定就是将A.B.C
这种格式的字符串进行反解析,然后看当前的令牌是否过期等操作。
对于本地的验证方案,我们可以很容易了解验证过程。但是远程的验证方案是特殊的,我们往往会单独来处理它,就像上方的中间件代码,您会发现会优先判断是否为远程验证,然后再执行本地验证。
为什么呢?因为当使用远程验证方案的时候,所有的验证逻辑其实都是在外部,那么本地是如何跟它进行交互进行验证的呢? 难道每一次访问API都要去远程验证服务器进行验证一次?
当然不是啦,接下来我将用一个不严谨的远程验证例子来为大家举例。有关真正的远程验证,我会在下一篇文章中为大家介绍。
此时有远程验证服务器A,和我本地业务服务器B。B会在A处申请一个密匙
,该密匙
是用来进行验证Token
。当一个请求来到B的时候,它会进入到验证中间件,此时我已经在service中注册了对应的远程验证方案(好比services.AddQQAuthentication()
)。那么B发现该请求没有携带Cookies
,那么B将直接拒绝此次请求。
这个时候客户端会尝试进行在登录页进行登录后再访问,登录页为它展示了一个QQ
的登录按钮,毫无疑问,用户会点击该按钮进行使用QQ账号登录。而该按钮指向的地址是远程服务器A的登录地址,而地址中携带了回调的本地地址。比如像这样的URL:"https://QQService.com/sign?callback=http://localhost/sign-qq"。 远程服务器就会处理该请求,等待用户登录成功之后,他会生成一个Token
,然后重定向到本地服务器的地址,该地址是刚才传入的回调地址,比如: "http://localhost/sign-qq?token=xxxxx"。
这个时候,就证明您正在访问本地的服务器,而此时注册的远程验证Handler
会根据url的参数进行判断,是否需要进行拦截处理,比如QQHandler
看到了该url的参数为sign-qq
,那么它就会认为它要处理该请求,然后它将获取到的Token
进行验证(根据申请到的密匙),验证成功的话就会解析出该Token
所携带的Claims
,自然而然就会生成一个ClaimsPrincipal
出来。最终将该ClaimsPrincipal
传递给本地登录方案,生成一个Cookies
。这样就完成了本地的身份验证,下次访问的时候,带上该Cookies
,就会通过验证啦。
所以再来回顾中间件代码:
//1. 远程验证成功,返回到http://localhost/sign-qq?token=xxxxx
//4. 下次正常访问,携带上了Cookies。
var handlers = context.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>();
foreach (var scheme in await Schemes.GetRequestHandlerSchemesAsync())
{
//2.获取到了QQHandler,该Handler看到URL的参数为sign-qq,那么将对他进行处理。处理过程为解析Token,然后保存到本地Cookies。
//5. 发现正常访问时候的URL不在拦截范围内,则不做处理。
var handler = await handlers.GetHandlerAsync(context, scheme.Name) as IAuthenticationRequestHandler;
if (handler != null && await handler.HandleRequestAsync())
{
//3.处理成功,本次请求结束。
return;
}
}
// 6. 找到本地验证方案,比如Cookies,那么对携带的Cookies进行验证。
var defaultAuthenticate = await Schemes.GetDefaultAuthenticateSchemeAsync();
if (defaultAuthenticate != null)
{
var result = await context.AuthenticateAsync(defaultAuthenticate.Name);
if (result?.Principal != null)
{
context.User = result.Principal;
}
}
所以远程登录的本质其实就是携带某些信息,让远程服务器返回一个Token
,然后本地根据从远程服务器处申请到的密匙进行Token
解析的过程。
远程登录往往会衍生出另外一个概念就是外部登录
,比如从QQ
出登录后返回了qqUserId = 3
的用户,但是该用户是存在QQ系统的,我们的系统是没有的,所以需要处理该用户,常用的手段就是绑定该账号。让QQ的userid
与我们系统的UserId
关联起来。这也是为什么您会在一些框架中看到一些叫做"xxExternalLoginInfo"的表或者信息的原因。
这种方案您可以在该文章所携带的代码中看到,我们使用了微信小程序的用户与业务用户相关联。
接下来我们将看到如何在AspNetCore
中使用JWT Bearer
验证。如果您已经读过了上方的内容,相信您会知道为什么它叫JWT Bearer
,而不是JWT
或者Bearer
。以及为什么微软在提供该包的时候,没有涉及到refresh_token
的颁发。
>Install-Package Microsoft.AspNetCore.Authentication.JwtBearer
AspNetCore
中的JwtBearer
验证方案,是由官方所提供的Microsoft.AspNetCore.Authentication.JwtBearer
包所提供,在安装之后,我们可以在Startip.cs
中进行注册:
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
}
注册时我们需要对JWT
进行配置,因为当一个Token
过来的时候,我们需要配置的密匙来对它进行解析呀,判断它是不是有效,以及是否被篡改。
该配置项的类型为JwtBearerOptions
,里面有好些参数,但是对于JWT
来说,最最最核心的是类型为TokenValidationParameters
的TokenValidationParameters
属性。因为JwtBearerOptions有部分JWT
的配置,会受到TokenValidationParameters
的约束,比如:
/// <summary>
/// Gets or sets a single valid audience value for any received OpenIdConnect token.
/// This value is passed into TokenValidationParameters.ValidAudience if that property is empty.
/// </summary>
public string Audience { get; set; }
注意,下方的NuGet
包可能会让人有点头晕:
TokenValidationParameters
类是来自于Microsoft.IdentityModel.Tokens
,该包是由AzureAD
维护。还记得上面的JWK
吗?该包就提供了JWK
的.NET
实现,和对应的加密算法
的实现以及Token
的抽象。
假如您想创建JWT
,那么您会依赖该团队另外的包。此时您一定会在NuGet
上进行搜索,但是…………
MD,好家伙。两个包描述一模一样,开发一模一样,部分单词也一模一样。我到底选哪一个?它们又有啥区别?
这个时候就还得需要上面我们所提到的JOSE
大家庭的知识啦,在介绍JWT
的时候,我们提到了它是由JWS
或者JWE
来实现的。所以微软就使用Microsoft.IdentityModel.JsonWebTokens
来实现了底层JWS
和JWE
不同创建JWT
的方案,而System.IdentityModel.Tokens.Jwt
依赖于Microsoft.IdentityModel.JsonWebTokens
,采用更简化的方式来实现JWT
。
所以不用说,我们肯定应该安装System.IdentityModel.Tokens.Jwt
呀,毕竟下次数量也多一些。
OK,回到上面,关于TokenValidationParameters
的配置,其实都来源于您对JWT
的认识。比如下面这些配置,如果您已经阅读了上文,其实一下就能看懂:
.AddJwtBearer(jwtOptions =>
{
jwtOptions.TokenValidationParameters = new TokenValidationParameters()
{
ValidateAudience = false, //是否验证Audience
ValidateIssuer = true, //是否验证Issuer
IssuerSigningKey = new SymmetricSecurityKey(seurityKey), //签名的KEY
ValidIssuer = configuration["JwtConfig:Issuer"], //验证的Issuer信息
ValidAudience = configuration["JwtConfig:Audience"],//验证的Audience信息
};
});
这样我们就会验证每一次http请求中所携带的Bearer Token
信息。因为其实我们启用了验证Issuer,所以必须保证创建的Token的荷载
中是包含正确的Issuer的,还有就是签名的密匙一定要正确,否则是不会认证通过的。
其实您会发现,在使用Microsoft.AspNetCore.Authentication.JwtBearer
的时候,其实有一些配置是属于OpenID
,而该包只是提供了验证jwt
的功能,但是并没有创建JWT
的功能。因为对于一般的WEBAPI
应用,其实都会使用OPENID
这种单点登录的方案,对于单独的JWT Token
验证来说其实还是比较少见的,如果您是简单的单体应用,那可以使用这样的方案。但是当项目慢慢扩大的时候,还是推荐您使用IdentityServer
来实现Oidc
登录。
附件代码就使用了本地服务既创建Token又验证Token的方案
我发现有些同学经常会犯这样的错误,因为漏写或者忘记验证,导致一些用户抓包后进行更改参数就获得了一些其它信息
。这种错误风险是很大的,设想一下你根据修改id就获得了其它人的微信聊天记录。 所以我们一定要避免这种错误,在演示项目中,我们使用了[CurrentUser]
特性来处理,该特性是MiCake
为AspNetCore
所实现的自动验证方案,关于实现,您可以参考下方的Github链接。
正如您在开头看到的那个演示图片一样,该演示项目的前端是使用的uni-app
来开发的。
可能有些朋友对于纯前端开发会感到比较陌生,因为平时都是使用的Razor
这种嵌套C#代码的方式来开发,或者有些朋友已经开始尝鲜Blazor
了,但是本质上都是没有离开C#
。(说到Blazor
,推荐大家使用 ant-design-blazor )。
但是为了更容易生成小程序的方案,所以最终选择了基于Vue
的uni-app
。我知道很多人可能和我一样,一直使用着C#
的简洁语法,对于原生js
是很不习惯的。所以,该项目我将所有的代码都转换成了TypeScript
,而且全都是类似C#
写法的代码。
如果您有使用过WPF
或者Winform
,您就会感觉好像在写Web前端
版本的WPF
。因为就基础使用来说,TypeScript
对于C# er
来说,几乎没有任何切换成本。您可以来看看下面的几句代码,这是从演示项目中复制过来的:
export default class extends Vue {
public mobile: string = "";
public password: string = "";
public async login() {
if (!uniHelper.validator.isMobile(this.mobile)) {
thorUiHelper.showTips(this.$refs.toast, '貌似手机号码不正确呀~');
return;
}
if (uniHelper.validator.isNullOrEmpty(this.password)) {
thorUiHelper.showTips(this.$refs.toast, '貌似还没有输入密码哦~');
return;
}
var loginInfo = new LoginDto();
loginInfo.phone = this.mobile;
loginInfo.password = this.password;
loginInfo.code = this.code;
try {
let result = await this.$httpClient.post<MiCakeApiModel<LoginResultDto>>('/User/Login', loginInfo);
}catch{
//...
}
}
}
这也是为什么这篇文章拖了好几天的原因,因为我花了好些时间去把所有的代码全转成类似C#语法的Ts
代码,只是为了让您能够更好的阅读。
哦,对了。在前端项目里面我引用了Vuex
,这是一个全局状态管理的东西。所以搞得有些代码看起来很复杂,刚开始您其实不需要关注它,把它理解为保存一个类似于C#中的static变量就行啦。
以下是本文所提及到各个仓库的源码地址: