搜文章
推荐 原创 视频 Java开发 iOS开发 前端开发 JavaScript开发 Android开发 PHP开发 数据库 开发工具 Python开发 Kotlin开发 Ruby开发 .NET开发 服务器运维 开放平台 架构师 大数据 云计算 人工智能 开发语言 其它开发
Lambda在线 > 秦梁的小站 > 使用jwt完成sso单点登录

使用jwt完成sso单点登录

秦梁的小站 2018-06-29

JWT

在了解jwt之前,先了解一下常用的会话管理

  • 基于 server-session的管理方式

  • cookie-based的管理方式

  • token-based的管理方式

一.基于 server-session的管理

  1. 服务端 session是用户第一次访问应用时,服务器就会创建的对象,代表用户的一次会话过程,服务器为每一个 session都分配一个唯一的 sessionid,以保证每个用户都有一个不同的 session对象。

  2. 服务器在创建完 session后,会把 sessionid通过 cookie返回给用户所在的浏览器,这样当用户第二次及以后向服务器发送请求的时候,就会通过 cookie把 sessionid传回给服务器,以便服务器能够根据 sessionid找到与该用户对应的 session对象。

  3. session通常有失效时间的设定,比如2个小时。当失效时间到,服务器会销毁之前的 session,并创建新的 session返回给用户。但是只要用户在失效时间内,有发送新的请求给服务器,通常服务器都会把他对应的 session的失效时间根据当前的请求时间再延长2个小时。

  4. session在一开始并不具备会话管理的作用。它只有在用户登录认证成功之后,并且往 session对象里面放入了用户登录成功的凭证,才能用来管理会话。管理会话的逻辑也很简单,只要拿到用户的 session对象,看它里面有没有登录成功的凭证,就能判断这个用户是否已经登录。当用户主动退出的时候,会把它的 session对象里的登录凭证清掉。所以在用户登录前或退出后或者 session对象失效时,肯定都是拿不到需要的登录凭证的。

以上过程可简单使用流程图描述如下:

 

它还有一个比较大的优点就是安全性好,因为在浏览器端与服务器端保持会话状态的媒介始终只是一个 sessionid串,只要这个串够随机,攻击者就不能轻易冒充他人的 sessionid进行操作;除非通过CSRF或http劫持的方式,才有可能冒充别人进行操作;即使冒充成功,也必须被冒充的用户 session里面包含有效的登录凭证才行。但是在真正决定用它管理会话之前,也得根据自己的应用情况考虑以下几个问题:

  1. 这种方式将会话信息存储在web服务器里面,所以在用户同时在线量比较多时,这些会话信息会占据比较多的内存;

  2. 当应用采用集群部署的时候,会遇到多台web服务器之间如何做 session共享的问题。因为 session是由单个服务器创建的,但是处理用户请求的服务器不一定是那个创建 session的服务器,这样他就拿不到之前已经放入到 session中的登录凭证之类的信息了;

  3. 多个应用要共享 session时,除了以上问题,还会遇到跨域问题,因为不同的应用可能部署的主机不一样,需要在各个应用做好 cookie跨域的处理。

针对问题1和问题2,我见过的解决方案是采用 redis这种中间服务器来管理 session的增删改查,一来减轻web服务器的负担,二来解决不同web服务器共享 session的问题。针对问题3,由于服务端的 session依赖 cookie来传递 sessionid,所以在实际项目中,只要解决各个项目里面如何实现 sessionidcookie跨域访问即可,这个是可以实现的,就是比较麻烦,前后端有可能都要做处理。

二. cookie-based的管理方式

由于前一种方式会增加服务器的负担和架构的复杂性,所以后来就有人想出直接把用户的登录凭证直接存到客户端的方案,当用户登录成功之后,把登录凭证写到 cookie里面,并给 cookie设置有效期,后续请求直接验证存有登录凭证的 cookie是否存在以及凭证是否有效,即可判断用户的登录状态。使用它来实现会话管理的整体流程如下:

  1. 用户发起登录请求,服务端根据传入的用户密码之类的身份信息,验证用户是否满足登录条件,如果满足,就根据用户信息创建一个登录凭证,这个登录凭证简单来说就是一个对象,最简单的形式可以只包含用户 id,凭证创建时间和过期时间三个值。

  2. 服务端把上一步创建好的登录凭证,先对它做数字签名,然后再用对称加密算法做加密处理,将签名、加密后的字串,写入 cookie。 cookie的名字必须固定(如 ticket),因为后面再获取的时候,还得根据这个名字来获取 cookie值。这一步添加数字签名的目的是防止登录凭证里的信息被篡改,因为一旦信息被篡改,那么下一步做签名验证的时候肯定会失败。做加密的目的,是防止 cookie被别人截取的时候,无法轻易读到其中的用户信息。

  3. 用户登录后发起后续请求,服务端根据上一步存登录凭证的 cookie名字,获取到相关的 cookie值。然后先做解密处理,再做数字签名的认证,如果这两步都失败,说明这个登录凭证非法;如果这两步成功,接着就可以拿到原始存入的登录凭证了。然后用这个凭证的过期时间和当前时间做对比,判断凭证是否过期,如果过期,就需要用户再重新登录;如果未过期,则允许请求继续。

使用jwt完成sso单点登录

这种方式最大的优点就是实现了服务端的无状态化,彻底移除了服务端对会话的管理的逻辑,服务端只需要负责创建和验证登录 cookie即可,无需保持用户的状态信息。对于第一种方式的第二个问题,用户会话信息共享的问题,它也能很好解决:因为如果只是同一个应用做集群部署,由于验证登录凭证的代码都是一样的,所以不管是哪个服务器处理用户请求,总能拿到 cookie中的登录凭证来进行验证;如果是不同的应用,只要每个应用都包含相同的登录逻辑,那么他们也是能轻易实现会话共享的,不过这种情况下,登录逻辑里面数字签名以及加密解密要用到的密钥文件或者密钥串,需要在不同的应用里面共享,总而言之,就是需要算法完全保持一致。

这种方式由于把登录凭证直接存放客户端,并且需要 cookie传来传去,所以它的缺点也比较明显:

  1. cookie有大小限制,存储不了太多数据,所以要是登录凭证存的消息过多,导致加密签名后的串太长,就会引发别的问题,比如其它业务场景需要 cookie的时候,就有可能没那么多空间可用了;所以用的时候得谨慎,得观察实际的登录 cookie的大小;比如太长,就要考虑是非是数字签名的算法太严格,导致签名后的串太长,那就适当调整签名逻辑;比如如果一开始用4096位的RSA算法做数字签名,可以考虑换成1024、2048位;

  2. 每次传送 cookie,增加了请求的数量,对访问性能也有影响;

  3. 也有跨域问题,毕竟还是要用 cookie

前面两种会话管理方式因为都用到 cookie,不适合用在native app里面:native app不好管理 cookie,毕竟它不是浏览器。这两种方案都不适合用来做纯api服务的登录认证。要实现api服务的登录认证,就要考虑下面要介绍的第三种会话管理方式。

三. token-based的管理方式

这种方式从流程和实现上来说,跟 cookie-based的方式没有太多区别,只不过 cookie-based里面写到 cookie里面的 ticket在这种方式下称为 token,这个 token在返回给客户端之后,后续请求都必须通过url参数或者是http header的形式,主动带上 token,这样服务端接收到请求之后就能直接从http header或者url里面取到token进行验证:

使用jwt完成sso单点登录

这种方式不通过 cookie进行 token的传递,而是每次请求的时候,主动把 token加到http header里面或者url后面,所以即使在native app里面也能使用它来调用我们通过web发布的api接口。app里面还要做两件事情:

  1. 有效存储 token,得保证每次调接口的时候都能从同一个位置拿到同一个 token

看起来麻烦,其实也不麻烦,这两件事情,对于app来说,很容易做到,只要对接口调用的模块稍加封装即可。

这种方式同样适用于网页应用, token可以存于 localStorage或者 sessionStorage里面,然后每发ajax请求的时候,都把 token拿出来放到ajax请求的header里即可。不过如果是非接口的请求,比如直接通过点击链接请求一个页面这种,是无法自动带上 token的。所以这种方式也仅限于走纯接口的web应用。

这种方式用在web应用里也有跨域的问题,比如应用如果部署在a.com,api服务部署在b.com,从a.com里面发出ajax请求到b.com,默认情况下是会报跨域错误的,这种问题可以用CORS(跨域资源共享)的方式来快速解决。

这种方式跟 cookie-based的方式同样都还有的一个问题就是 ticket或者 token刷新的问题。有的产品里面,你肯定不希望用户登录后,操作了半个小时,结果 ticket或者 token到了过期时间,然后用户又得去重新登录的情况出现。这个时候就得考虑 tickettoken的自动刷新的问题,简单来说,可以在验证 tickettoken有效之后,自动把 tickettoken的失效时间延长,然后把它再返回给客户端;客户端如果检测到服务器有返回新的 tickettoken,就替换原来的 tickettoken

四. 安全问题

在web应用里面,会话管理的安全性始终是最重要的安全问题,这个对用户的影响极大。

首先从会话管理凭证来说,第一种方式的会话凭证仅仅是一个 sessionid,所以只要这个 sessionid足够随机,而不是一个自增的数字id值,那么其它人就不可能轻易地冒充别人的 sessionid进行操作;第二种方式的凭证 ticket以及第三种方式的凭证 token都是一个在服务端做了数字签名,和加密处理的串,所以只要密钥不泄露,别人也无法轻易地拿到这个串中的有效信息并对它进行篡改。总之,这三种会话管理方式的凭证本身是比较安全的。

然后从客户端和服务端的http过程来说,当别人截获到客户端请求中的会话凭证,就能拿这个凭证冒充原用户,做一些非法操作,而服务器也认不出来。这种安全问题,可以简单采用https来解决,虽然可能还有http劫持这种更高程度的威胁存在,但是我们从代码能做的防范,确实也就是这个层次了。


JWT介绍 (https://jwt.io/)

JSONWebTokenJWT是一个开放标准(RFC 7519),它定义了一种紧凑和自包含的方式,用于在各方之间作为JSON对象安全地传输信息。作为标准,它没有提供技术实现,但是大部分的语言平台都有按照它规定的内容提供了自己的技术实现,所以实际在用的时候,只要根据自己当前项目的技术平台,到官网上选用合适的实现库即可。

使用 JWT来传输数据,实际上传输的是一个字符串,这个字符串就是所谓的json web token字符串。所以广义上, JWT是一个标准的名称;狭义上, JWT指的就是用来传递的那个 token字符串。这个串有两个特点:

  1. 紧凑:指的是这个串很小,能通过url 参数,http请求提交的数据以及http header的方式来传递;

  2. 自包含:这个串可以包含很多信息,比如用户的id、角色等,别人拿到这个串,就能拿到这些关键的业务信息,从而避免再通过数据库查询等方式才能得到它们。

通常一个 JWT是长这个样子的:

使用jwt完成sso单点登录

要知道一个 JWT是怎么产生以及如何用于会话管理,只要弄清楚 JWT的数据结构以及它签发和验证的过程即可。

一. JWT的数据结构以及签发过程

一个 JWT实际上是由三个部分组成: header(头部)payload(载荷)signature(签名)。这三个部分在 JWT里面分别对应英文句号分隔出来的三个串:

使用jwt完成sso单点登录

先来看 header部分的结构以及它的生成方法。 header部分是由下面格式的json结构生成出来:

使用jwt完成sso单点登录

这个json中的 typ属性,用来标识整个 token字符串是一个 JWT字符串;它的 alg属性,用来说明这个 JWT签发的时候所使用的签名和摘要算法,常用的值以及对应的算法如下:

使用jwt完成sso单点登录

typalg属性的全称其实是 typealgorithm,分别是类型跟算法的意思。之所以都用三个字母来表示,也是基于 JWT最终字串大小的考虑,同时也是跟 JWT这个名称保持一致,这样就都是三个字符了… typalgJWT中标准中规定的属性名称,虽然在签发 JWT的时候,也可以把这两个名称换掉,但是如果随意更换了这个名称,就有可能在 JWT验证的时候碰到问题,因为拿到 JWT的人,默认会根据 typalg去拿 JWT中的 header信息,当你改了名称之后,显然别人是拿不到 header信息的,他又不知道你把这两个名字换成了什么。 JWT作为标准的意义在于统一各方对同一个事情的处理方式,各个使用方都按它约定好的格式和方法来签发和验证 token,这样即使运行的平台不一样,也能够保证 token进行正确的传递。

一般签发 JWT的时候, header对应的json结构只需要 typalg属性就够了。 JWTheader部分是把前面的json结构,经过Base64Url编码之后生成出来的:

使用jwt完成sso单点登录

(在线base64编码:http://www1.tc711.com/tool/BASE64.htm)

再来看 payload部分的结构和生成过程。 payload部分是由下面类似格式的json结构生成出来:

使用jwt完成sso单点登录

payload的json结构并不像 header那么简单, payload用来承载要传递的数据,它的json结构实际上是对 JWT要传递的数据的一组声明,这些声明被 JWT标准称为 claims,它的一个“属性值对”其实就是一个 claim,每一个 claim的都代表特定的含义和作用。比如上面结构中的 sub代表这个 token的所有人,存储的是所有人的 IDname表示这个所有人的名字; admin表示所有人是否管理员的角色。当后面对 JWT进行验证的时候,这些 claim都能发挥特定的作用。

根据 JWT的标准,这些 claims可以分为以下三种类型:

  1. Reservedclaims(保留),它的含义就像是编程语言的保留字一样,属于 JWT标准里面规定的一些 claim。 JWT标准里面定好的 claim有:

iss(Issuser):代表这个JWT的签发主体;
sub(Subject):代表这个JWT的主体,即它的所有人;
aud(Audience):代表这个JWT的接收对象;
exp(Expirationtime):是一个时间戳,代表这个JWT的过期时间;
nbf(NotBefore):是一个时间戳,代表这个JWT生效的开始时间,意味着在这个时间之前验证JWT是会失败的;
iat(Issuedat):是一个时间戳,代表这个JWT的签发时间;
jti(JWT ID):是JWT的唯一标识。

  1. Publicclaims,略(不重要)


  2. Privateclaims,这个指的就是自定义的 claim。比如前面那个结构举例中的 adminname都属于自定的 claim。这些 claimJWT标准规定的 claim区别在于: JWT规定的 claimJWT的接收方在拿到 JWT之后,都知道怎么对这些标准的 claim进行验证;而 privateclaims不会验证,除非明确告诉接收方要对这些claim进行验证以及规则才行。


按照 JWT标准的说明:保留的 claims都是可选的,在生成 payload不强制用上面的那些 claim,你可以完全按照自己的想法来定义 payload的结构,不过这样搞根本没必要:第一是,如果把 JWT用于认证, 那么 JWT标准内规定的几个 claim就足够用了,甚至只需要其中一两个就可以了,假如想往 JWT里多存一些用户业务信息,比如角色和用户名等,这倒是用自定义的 claim来添加;第二是, JWT标准里面针对它自己规定的 claim都提供了有详细的验证规则描述,每个实现库都会参照这个描述来提供 JWT的验证实现,所以如果是自定义的 claim名称,那么你用到的实现库就不会主动去验证这些 claim

最后也是把这个json结构做base64url编码之后,就能生成 payload部分的串:

使用jwt完成sso单点登录

(在线base64编码:http://www1.tc711.com/tool/BASE64.htm)

最后看 signature部分的生成过程。签名是把 headerpayload对应的json结构进行base64url编码之后得到的两个串用英文句点号拼接起来,然后根据 header里面 alg指定的签名算法生成出来的。算法不同,签名结果不同,但是不同的算法最终要解决的问题是一样的。以 alg:HS256为例来说明前面的签名如何来得到。按照前面 alg可用值的说明,HS256其实包含的是两种算法:HMAC算法和SHA256算法,前者用于生成摘要,后者用于对摘要进行数字签名。这两个算法也可以用HMACSHA256来统称。运用HMACSHA256实现 signature的算法是:

使用jwt完成sso单点登录

正好找到一个在线工具能够测试这个签名算法的结果,比如我们拿前面的 headerpayload串来测试,并把“secret”这个字符串就当成密钥来测试:

使用jwt完成sso单点登录

(https://1024tools.com/hmac)

最后的结果B其实就是JWT需要的signature。不过对比我在介绍JWT的开始部分给出的JWT的举例:

使用jwt完成sso单点登录

会发现通过在线工具生成的 headerpayload都与这个举例中的对应部分相同,但是通过在线工具生成的 signature与上面图中 signature有细微区别,在于最后是否有“=”字符。这个区别产生的原因在于上图中的 JWT是通过 JWT的实现库签发的 JWT,这些实现库最后编码的时候都用的是base64url编码,而前面那些在线工具都是bas64编码,这两种编码方式不完全相同,导致编码结果有区别。

以上就是一个 JWT包含的全部内容以及它的签发过程。接下来看看该如何去验证一个 JWT是否为一个有效的 JWT

二. JWT的验证过程

这个部分介绍 JWT的验证规则,主要包括签名验证和 payload里面各个标准 claim的验证逻辑介绍。只有验证成功的 JWT,才能当做有效的凭证来使用。

先说签名验证。当接收方接收到一个 JWT的时候,首先要对这个 JWT的完整性进行验证,这个就是签名认证。它验证的方法其实很简单,只要把 header做base64url解码,就能知道 JWT用的什么算法做的签名,然后用这个算法,再次用同样的逻辑对 headerpayload做一次签名,并比较这个签名是否与 JWT本身包含的第三个部分的串是否完全相同,只要不同,就可以认为这个 JWT是一个被篡改过的串,自然就属于验证失败了。接收方生成签名的时候必须使用跟 JWT发送方相同的密钥,意味着要做好密钥的安全传递或共享。

再来看 payloadclaim验证,拿前面标准的 claim来一一说明:

iss(Issuser):如果签发的时候这个 claim的值是“a.com”,验证的时候如果这个 claim的值不是“a.com”就属于验证失败;
sub(Subject):如果签发的时候这个 claim的值是“liuyunzhuge”,验证的时候如果这个 claim的值不是“liuyunzhuge”就属于验证失败;
(Audience):如果签发的时候这个 claim的值是“['b.com','c.com']”,验证的时候这个 claim的值至少要包含b.com,c.com的其中一个才能验证通过;
exp(Expirationtime):如果验证的时候超过了这个 claim指定的时间,就属于验证失败;
nbf(NotBefore):如果验证的时候小于这个 claim指定的时间,就属于验证失败;
iat(Issuedat):它可以用来做一些maxAge之类的验证,假如验证时间与这个 claim指定的时间相差的时间大于通过maxAge指定的一个值,就属于验证失败;
jti(JWT ID):如果签发的时候这个 claim的值是“1”,验证的时候如果这个 claim的值不是“1”就属于验证失败;
需要注意的是,在验证一个 JWT的时候,签名认证是每个实现库都会自动做的,但是 payload的认证是由使用者来决定的。因为 JWT里面可能不会包含任何一个标准的 claim,所以它不会自动去验证这些 claim

以登录认证来说,在签发 JWT的时候,完全可以只用 subexp两个 claim,用 sub存储用户的 id,用 exp存储它本次登录之后的过期时间,然后在验证的时候仅验证 exp这个 claim,以实现会话的有效期管理。

JWT SSO

场景一:用户发起对业务系统的第一次访问,假设他第一次访问的是系统A的some/page这个页面,它最终成功访问到这个页面的过程是:

使用jwt完成sso单点登录

在这个过程里面,我认为理解的关键点在于:

  1. 它用到了两个 cookie( jwtsid)和三次重定向来完成会话的创建和会话的传递;


  2. jwtcookie是写在systemA.com这个域下的,所以每次重定向到systemA.com的时候, jwt这个 cookie只要有就会带过去;


  3. sidcookie是写在cas.com这个域下的,所以每次重定向到cas.com的时候, sid这个 cookie只要有就会带过去;


  4. 在验证 jwt的时候,如何知道当前用户已经创建了sso的会话? 因为 jwtpayload里面存储了之前创建的sso会话的 sessionid,所以当cas拿到 jwt,就相当于拿到了 sessionid,然后用这个 sessionid去判断有没有的对应的 session对象即可。


还要注意的是:CAS服务里面的 session属于服务端创建的对象,所以要考虑 sessionid唯一性以及 session共享(假如CAS采用集群部署的话)的问题。 sessionid的唯一性可以通过用户名密码加随机数然后用hash算法如md5简单处理; session共享,可以用 memcached或者 redis这种专门的支持集群部署的缓存服务器管理 session来处理。

由于服务端 session具有生命周期的特点,到期需自动销毁,所以不要自己去写 session的管理,免得引发其它问题,到github里找开源的缓存管理中间件来处理即可。存储 session对象的时候,只要用 sessionid作为key, session对象本身作为 value,存入缓存即可。 session对象里面除了 sessionid,还可以存放登录之后获取的用户信息等业务数据,方便业务系统调用的时候,从 session里面返回会话数据。

场景二:用户登录之后,继续访问系统A的其它页面,如some/page2,它的处理过程是:

使用jwt完成sso单点登录

从这一步可以看出,即使登录之后,也要每次跟CAS校验 jwt的有效性以及会话的有效性,其实 jwt的有效性也可以放在业务系统里面处理的,但是会话的有效性就必须到CAS那边才能完成了。当CAS拿到 jwt里面的 sessionid之后,就能到 session缓存服务器里面去验证该 sessionid对应的 session对象是否存在,不存在,就说明会话已经销毁了(退出)。

场景三:用户登录了系统A之后,再去访问其他系统如系统B的资源,比如系统B的some/page,它最终能访问到系统B的some/page的流程是:

使用jwt完成sso单点登录

这个过程的关键在于第一次重定向的时候,它会把 sid这个 cookie带回给CAS服务器,所以CAS服务器能够判断出会话是否已经建立,如果已经建立就跳过登录页的逻辑。

场景四:用户继续访问系统B的其它资源,如系统B的some/page2:

这个场景的逻辑跟场景二完全一致。

场景五:退出登录,假如它从系统B发起退出,最终的流程是:

最重要的是要清除 sidcookiejwtcookie可能业务系统都有创建,所以不可能在退出的时候还挨个去清除那些系统的 cookie,只要 sid一清除,那么即使那些 jwtcookie在下次访问的时候还会被传递到业务系统的服务端,由于 jwt里面的 sid已经无效,所以最后还是会被重定向到CAS登录页进行处理。

方案总结 以上方案两个关键的前提:

  1. 整个会话管理其实还是基于服务端的 session来做的,只不过这个 session只存在于CAS服务里面;

  2. CAS之所以信任业务系统的 jwt,是因为这个 jwt是CAS签发的,理论上只要认证通过,就可以认为这个 jwt是合法的。

jwt本身是不可伪造,不可篡改的,但是不代表非法用户冒充正常用法发起请求,所以常规的几个安全策略在实际项目中都应该使用:

  1. 使用https

  2. 使用http-only的 cookie,针对 sid和 jwt

  3. 管理好密钥

  4. 防范CSRF攻击。

尤其是CSRF攻击形式,很多都是钻代码的漏洞发生的,所以一旦出现CSRF漏洞,并且被人利用,那么别人就能用获得的 jwt,冒充正常用户访问所有业务系统,这个安全问题的后果还是很严重的。考虑到这一点,为了在即使有漏洞的情况将损害减至最小,可以在 jwt里面加入一个系统标识,添加一个验证,只有传过来的 jwt内的系统标识与发起 jwt验证请求的服务一致的情况下,才允许验证通过。这样的话,一个非法用户拿到某个系统的 jwt,就不能用来访问其它业务系统了。

在业务系统跟CAS发起attach/validate请求的时候,也可以在CAS端做些处理,因为这个请求,在一次SSO过程中,一个系统只应该发一次,所以只要之前已经给这个系统签发过jwt了,那么后续 同一系统的attach/validate请求都可以忽略掉。

总的来说,这个方案的好处有:

  1. 完全分布式,跨平台,CAS以及业务系统均可采用不同的语言来开发;

  2. 业务系统如系统A和系统B,可实现服务端无状态

  3. 假如是自己来实现,那么可以轻易的在CAS里面集成用户注册服务以及第三方登录服务,如微信登录等。

它的缺陷是:

  1. 第一次登录某个系统,需要三次重定向;

  2. 登录后的后续请求,每次都需要跟CAS进行会话验证,所以CAS的性能负载会比较大

  3. 登陆后的后续请求,每次都跟CAS交互,也会增加请求响应时间,影响用户体验。

转载自:3种web会话管理的方式 看图理解JWT如何用于单点登录


版权声明:本站内容全部来自于腾讯微信公众号,属第三方自助推荐收录。《使用jwt完成sso单点登录》的版权归原作者「秦梁的小站」所有,文章言论观点不代表Lambda在线的观点, Lambda在线不承担任何法律责任。如需删除可联系QQ:516101458

文章来源: 阅读原文

相关阅读

关注秦梁的小站微信公众号

秦梁的小站微信公众号:gh_796f96b45e58

秦梁的小站

手机扫描上方二维码即可关注秦梁的小站微信公众号

秦梁的小站最新文章

精品公众号随机推荐