客官,来看看AspNetCore的身份验证吧

开篇

这段时间潜水了太久,终于有时间可以更新一篇文章了。

xx

通过本篇文章您将Get:

  • Http的一些身份验证概念
  • AspNetCore中实现身份验证方案
  • JWT等概念的基础知识
  • 使用Bearer TokenWebAPI进行保护
  • 一些验证中的小细节
  • 微信小程序验证的源代码

时长为大约有十五分钟,内容丰富,建议先投币再上车观看😜

本文附带了普通Bearer JwtToken验证和微信小程序验证登录的源代码,效果图您可以参考下方的Gif图片。

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这一项信息。基础验证方案中,我们通过传递usernamepassword来获取userId。而现在,我们就直接让令牌来包含userId这一项内容,而以后我们每次携带该令牌去访问API的时候,就不需要再到数据库中进行查找用户来获取Id了。这样就能大幅度够减缓服务器的查找压力。

用户传递了usernamepassword到身份验证服务器,服务器通过与数据库中的用户信息进行匹配,发现是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来说是必要的。

OPENID

但是在该图中,除了JWT您还会看到其它的类似单词,比如:JWSJWEJWK等等。但是当您想去对他们进行了解的时候,很抱歉,百度居然不靠谱了。😭

不要慌张,在有了上面基础验证方案的思路之后,这些对于您来说都不是问题。

这些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

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

JWS规范指出了使用JSON格式来表示加密内容。JWS由三个部分所组成:JOSE HeaderJWS PayloadJWS 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吗?格式明明一模一样,是的,JWSJWA等就是JWT的基础,JWT在这之上提供了新的规范,比如荷载中的Claim等信息。下面将会讲到。

JWK

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。而里面的ne分别代表了RSA加密中的modulusexponent

再想想最初的我们解释的自包含令牌,对于非对称加密,我们需要从服务端获取到公钥,那么现在问题就来了,公钥怎么表示呢? 而JWK相当于就干了这样一件事。

什么?你问我这东西哪儿见过?您的IdentityServer4里面是不是公开了一个节点叫做.well-known/openid-configuration/jwks,眼熟吧?jwks不就是这一个东西吗? 点击这里看看吧!

JWT

来吧,万众期待的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

这个应该是好多同学经常搞晕的一个概念,可能大家都以为,Bearer Token就等于JWT

当然不是啦,因为BearerHTTP 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_tokenrefresh_token。它们都是同OAuth2.0一起诞生的,同样的,它们与JWT也并没有直接的关系,所以并非我一定要用JWT来生成access_tokenrefresh_token,还有就是当我使用JWT的时候,也并非一定要使用refresh_token

但是就像我们最初设想的一样,如果不使用自包含的验证,服务器将承受巨大的压力。所以在OAuth2.0中,还是推荐大家使用JWT,而该方案也同样具有一个标准规范

AspNet Core中的身份验证

有了这些基础知识之后,我们再来看看AspNetCore中是如何实现身份验证的,在这里我们同样以WebApi的验证方案来讲解,关于基本的Cookies验证方案,您可以直接查阅官方文档,但是对于验证来说,原理几乎都是一样的。

在这之前,您将有很多个关键类需要了解:ClaimClaimsIdentityClaimsPrincipal

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:是一组ClaimsIdentity的合集,又进行了一次更高层次的抽象。比如一个用户有教师的身份,里面有教师ID教师邮箱的声明,但是同时他还具有拖拉机师傅的身份,具有执业编号等声明,所以此时我们就可以使用ClaimsPrincipal来表示该用户。

而ClaimsPrincipal就成为了表示一个用户的单位,所以在AspNetCoreHttpContext上下文中有一个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是什么呢?其实就是咱们的验证方案。一般来说,咱们一套系统往往会有多种登录方案,比如博客园,现在就开放了多种外部登录的方案:

x

AspNetCore为了便于扩展方便,所以使用了scheme来作用区分方法,这样我们在不同的时候,指定不同的scheme就能够进行对应的处理:

scheme 对应处理方案类
Google GoogleHandler
WeChat WeChatHandler
QQ 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验证

接下来我们将看到如何在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来说,最最最核心的是类型为TokenValidationParametersTokenValidationParameters属性。因为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上进行搜索,但是…………

x

MD,好家伙。两个包描述一模一样,开发一模一样,部分单词也一模一样。我到底选哪一个?它们又有啥区别?

这个时候就还得需要上面我们所提到的JOSE大家庭的知识啦,在介绍JWT的时候,我们提到了它是由JWS或者JWE来实现的。所以微软就使用Microsoft.IdentityModel.JsonWebTokens来实现了底层JWSJWE不同创建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的方案

一些你需要注意的小细节

  • 当API根据传入的UserID来获取对应资源的时候,一定要确保当前验证的用户和传入的ID匹配。

我发现有些同学经常会犯这样的错误,因为漏写或者忘记验证,导致一些用户抓包后进行更改参数就获得了一些其它信息。这种错误风险是很大的,设想一下你根据修改id就获得了其它人的微信聊天记录。 所以我们一定要避免这种错误,在演示项目中,我们使用了[CurrentUser]特性来处理,该特性是MiCakeAspNetCore所实现的自动验证方案,关于实现,您可以参考下方的Github链接。

案例说明

正如您在开头看到的那个演示图片一样,该演示项目的前端是使用的uni-app来开发的。

可能有些朋友对于纯前端开发会感到比较陌生,因为平时都是使用的Razor这种嵌套C#代码的方式来开发,或者有些朋友已经开始尝鲜Blazor了,但是本质上都是没有离开C#。(说到Blazor,推荐大家使用 ant-design-blazor )。

但是为了更容易生成小程序的方案,所以最终选择了基于Vueuni-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变量就行啦。

附录

以下是本文所提及到各个仓库的源码地址:

最后,点个推荐吧😭

posted @ 2020-06-29 18:44  句幽  阅读(7370)  评论(26编辑  收藏  举报