来源
JWT
在了解 jwt 之前,先了解一下常用的会话管理
- 基于
server-session
的管理方式 cookie-based
的管理方式token-based
的管理方式
一.基于 server-session
的管理
- 服务端
session
是用户第一次访问应用时,服务器就会创建的对象,代表用户的一次会话过程,服务器为每一个session
都分配一个唯一的sessionid
,以保证每个用户都有一个不同的session
对象。 - 服务器在创建完
session
后,会把sessionid
通过cookie
返回给用户所在的浏览器,这样当用户第二次及以后向服务器发送请求的时候,就会通过cookie
把sessionid
传回给服务器,以便服务器能够根据sessionid
找到与该用户对应的session
对象。 session
通常有失效时间的设定,比如 2 个小时。当失效时间到,服务器会销毁之前的session
,并创建新的session
返回给用户。但是只要用户在失效时间内,有发送新的请求给服务器,通常服务器都会把他对应的session
的失效时间根据当前的请求时间再延长 2 个小时。session
在一开始并不具备会话管理的作用。它只有在用户登录认证成功之后,并且往session
对象里面放入了用户登录成功的凭证,才能用来管理会话。管理会话的逻辑也很简单,只要拿到用户的session
对象,看它里面有没有登录成功的凭证,就能判断这个用户是否已经登录。当用户主动退出的时候,会把它的session
对象里的登录凭证清掉。所以在用户登录前或退出后或者session
对象失效时,肯定都是拿不到需要的登录凭证的。
它还有一个比较大的优点就是安全性好,因为在浏览器端与服务器端保持会话状态的媒介始终只是一个 sessionid
串,只要这个串够随机,攻击者就不能轻易冒充他人的 sessionid
进行操作;除非通过 CSRF 或 http 劫持的方式,才有可能冒充别人进行操作;即使冒充成功,也必须被冒充的用户 session
里面包含有效的登录凭证才行。但是在真正决定用它管理会话之前,也得根据自己的应用情况考虑以下几个问题:
- 这种方式将会话信息存储在 web 服务器里面,所以在用户同时在线量比较多时,这些会话信息会占据比较多的内存;
- 当应用采用集群部署的时候,会遇到多台 web 服务器之间如何做
session
共享的问题。因为session
是由单个服务器创建的,但是处理用户请求的服务器不一定是那个创建session
的服务器,这样他就拿不到之前已经放入到session
中的登录凭证之类的信息了; - 多个应用要共享
session
时,除了以上问题,还会遇到跨域问题,因为不同的应用可能部署的主机不一样,需要在各个应用做好cookie
跨域的处理。
针对问题 1 和问题 2,我见过的解决方案是采用 redis
这种中间服务器来管理 session
的增删改查,一来减轻 web 服务器的负担,二来解决不同 web 服务器共享 session
的问题。针对问题 3,由于服务端的 session
依赖 cookie
来传递 sessionid
,所以在实际项目中,只要解决各个项目里面如何实现 sessionid
的 cookie
跨域访问即可,这个是可以实现的,就是比较麻烦,前后端有可能都要做处理。
二. cookie-based
的管理方式
由于前一种方式会增加服务器的负担和架构的复杂性,所以后来就有人想出直接把用户的登录凭证直接存到客户端的方案,当用户登录成功之后,把登录凭证写到 cookie
里面,并给 cookie
设置有效期,后续请求直接验证存有登录凭证的 cookie
是否存在以及凭证是否有效,即可判断用户的登录状态。使用它来实现会话管理的整体流程如下:
- 用户发起登录请求,服务端根据传入的用户密码之类的身份信息,验证用户是否满足登录条件,如果满足,就根据用户信息创建一个登录凭证,这个登录凭证简单来说就是一个对象,最简单的形式可以只包含用户
id
,凭证创建时间和过期时间三个值。 - 服务端把上一步创建好的登录凭证,先对它做数字签名,然后再用对称加密算法做加密处理,将签名、加密后的字串,写入
cookie
。cookie
的名字必须固定(如ticket
),因为后面再获取的时候,还得根据这个名字来获取cookie
值。这一步添加数字签名的目的是防止登录凭证里的信息被篡改,因为一旦信息被篡改,那么下一步做签名验证的时候肯定会失败。做加密的目的,是防止cookie
被别人截取的时候,无法轻易读到其中的用户信息。 - 用户登录后发起后续请求,服务端根据上一步存登录凭证的
cookie
名字,获取到相关的cookie
值。然后先做解密处理,再做数字签名的认证,如果这两步都失败,说明这个登录凭证非法;如果这两步成功,接着就可以拿到原始存入的登录凭证了。然后用这个凭证的过期时间和当前时间做对比,判断凭证是否过期,如果过期,就需要用户再重新登录;如果未过期,则允许请求继续。
这种方式最大的优点就是实现了服务端的无状态化,彻底移除了服务端对会话的管理的逻辑,服务端只需要负责创建和验证登录 cookie
即可,无需保持用户的状态信息。对于第一种方式的第二个问题,用户会话信息共享的问题,它也能很好解决:因为如果只是同一个应用做集群部署,由于验证登录凭证的代码都是一样的,所以不管是哪个服务器处理用户请求,总能拿到 cookie
中的登录凭证来进行验证;如果是不同的应用,只要每个应用都包含相同的登录逻辑,那么他们也是能轻易实现会话共享的,不过这种情况下,登录逻辑里面数字签名以及加密解密要用到的密钥文件或者密钥串,需要在不同的应用里面共享,总而言之,就是需要算法完全保持一致。
这种方式由于把登录凭证直接存放客户端,并且需要 cookie
传来传去,所以它的缺点也比较明显:
cookie
有大小限制,存储不了太多数据,所以要是登录凭证存的消息过多,导致加密签名后的串太长,就会引发别的问题,比如其它业务场景需要cookie
的时候,就有可能没那么多空间可用了;所以用的时候得谨慎,得观察实际的登录cookie
的大小;比如太长,就要考虑是非是数字签名的算法太严格,导致签名后的串太长,那就适当调整签名逻辑;比如如果一开始用 4096 位的 RSA 算法做数字签名,可以考虑换成 1024、2048 位;- 每次传送
cookie
,增加了请求的数量,对访问性能也有影响; - 也有跨域问题,毕竟还是要用
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 进行验证:
这种方式不通过 cookie
进行 token
的传递,而是每次请求的时候,主动把 token
加到 http header 里面或者 url 后面,所以即使在 native app 里面也能使用它来调用我们通过 web 发布的 api 接口。app 里面还要做两件事情:
- 有效存储
token
,得保证每次调接口的时候都能从同一个位置拿到同一个token
; - 每次调接口的的代码里都得把
token
加到 header 或者接口地址里面。
看起来麻烦,其实也不麻烦,这两件事情,对于 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
到了过期时间,然后用户又得去重新登录的情况出现。这个时候就得考虑 ticket
或 token
的自动刷新的问题,简单来说,可以在验证 ticket
或 token
有效之后,自动把 ticket
或 token
的失效时间延长,然后把它再返回给客户端;客户端如果检测到服务器有返回新的 ticket
或 token
,就替换原来的 ticket
或 token
。
四. 安全问题
在 web 应用里面,会话管理的安全性始终是最重要的安全问题,这个对用户的影响极大。
首先从会话管理凭证来说,第一种方式的会话凭证仅仅是一个 sessionid
,所以只要这个 sessionid
足够随机,而不是一个自增的数字 id 值,那么其它人就不可能轻易地冒充别人的 sessionid
进行操作;第二种方式的凭证 ticket
以及第三种方式的凭证 token
都是一个在服务端做了数字签名,和加密处理的串,所以只要密钥不泄露,别人也无法轻易地拿到这个串中的有效信息并对它进行篡改。总之,这三种会话管理方式的凭证本身是比较安全的。
然后从客户端和服务端的 http 过程来说,当别人截获到客户端请求中的会话凭证,就能拿这个凭证冒充原用户,做一些非法操作,而服务器也认不出来。这种安全问题,可以简单采用 https 来解决,虽然可能还有 http 劫持这种更高程度的威胁存在,但是我们从代码能做的防范,确实也就是这个层次了。
JWT 介绍 (https://jwt.io/)
JSON Web Token(JWT)
是一个开放标准(RFC 7519),它定义了一种紧凑和自包含的方式,用于在各方之间作为 JSON 对象安全地传输信息。作为标准,它没有提供技术实现,但是大部分的语言平台都有按照它规定的内容提供了自己的技术实现,所以实际在用的时候,只要根据自己当前项目的技术平台,到官网上选用合适的实现库即可。
使用 JWT
来传输数据,实际上传输的是一个字符串,这个字符串就是所谓的 json web token 字符串。所以广义上,JWT
是一个标准的名称;狭义上,JWT
指的就是用来传递的那个 token
字符串。这个串有两个特点:
- 紧凑:指的是这个串很小,能通过 url 参数,http 请求提交的数据以及 http header 的方式来传递;
- 自包含:这个串可以包含很多信息,比如用户的 id、角色等,别人拿到这个串,就能拿到这些关键的业务信息,从而避免再通过数据库查询等方式才能得到它们。
通常一个 JWT
是长这个样子的:
要知道一个 JWT
是怎么产生以及如何用于会话管理,只要弄清楚 JWT
的数据结构以及它签发和验证的过程即可。
一. JWT
的数据结构以及签发过程
一个 JWT
实际上是由三个部分组成:header(头部)
、payload(载荷)
和 signature(签名
)。这三个部分在 JWT
里面分别对应英文句号分隔出来的三个串:
先来看 header
部分的结构以及它的生成方法。header
部分是由下面格式的 json 结构生成出来:
这个 json 中的 typ
属性,用来标识整个 token
字符串是一个 JWT
字符串;它的 alg
属性,用来说明这个 JWT
签发的时候所使用的签名和摘要算法,常用的值以及对应的算法如下:
typ
跟 alg
属性的全称其实是 type
跟 algorithm
,分别是类型跟算法的意思。之所以都用三个字母来表示,也是基于 JWT
最终字串大小的考虑,同时也是跟 JWT
这个名称保持一致,这样就都是三个字符了…typ
跟 alg
是 JWT
中标准中规定的属性名称,虽然在签发 JWT
的时候,也可以把这两个名称换掉,但是如果随意更换了这个名称,就有可能在 JWT
验证的时候碰到问题,因为拿到 JWT
的人,默认会根据 typ
和 alg
去拿 JWT
中的 header
信息,当你改了名称之后,显然别人是拿不到 header
信息的,他又不知道你把这两个名字换成了什么。JWT
作为标准的意义在于统一各方对同一个事情的处理方式,各个使用方都按它约定好的格式和方法来签发和验证 token
,这样即使运行的平台不一样,也能够保证 token
进行正确的传递。
一般签发 JWT
的时候,header
对应的 json 结构只需要 typ
和 alg
属性就够了。JWT
的 header
部分是把前面的 json 结构,经过 Base64Url 编码之后生成出来的:
(在线 base64 编码:http://www1.tc711.com/tool/BASE64.htm)
再来看 payload
部分的结构和生成过程。payload
部分是由下面类似格式的 json 结构生成出来:
payload
的 json 结构并不像 header
那么简单,payload
用来承载要传递的数据,它的 json 结构实际上是对 JWT
要传递的数据的一组声明,这些声明被 JWT
标准称为 claims
,它的一个“属性值对”其实就是一个 claim
,每一个 claim
的都代表特定的含义和作用。比如上面结构中的 sub
代表这个 token
的所有人,存储的是所有人的 ID
;name
表示这个所有人的名字;admin
表示所有人是否管理员的角色。当后面对 JWT
进行验证的时候,这些 claim
都能发挥特定的作用。
根据 JWT
的标准,这些 claims
可以分为以下三种类型:
Reserved claims(保留)
,它的含义就像是编程语言的保留字一样,属于JWT
标准里面规定的一些claim
。JWT
标准里面定好的claim
有:
iss(Issuser)
:代表这个 JWT 的签发主体;
sub(Subject)
:代表这个 JWT 的主体,即它的所有人;
aud(Audience)
:代表这个 JWT 的接收对象;
exp(Expiration time)
:是一个时间戳,代表这个 JWT 的过期时间;
nbf(Not Before)
:是一个时间戳,代表这个 JWT 生效的开始时间,意味着在这个时间之前验证 JWT 是会失败的;
iat(Issued at)
:是一个时间戳,代表这个 JWT 的签发时间;
jti(JWT ID)
:是 JWT 的唯一标识。
-
Public claims
,略(不重要) -
Private claims
,这个指的就是自定义的claim
。比如前面那个结构举例中的admin
和name
都属于自定的claim
。这些claim
跟JWT
标准规定的claim
区别在于:JWT
规定的claim
,JWT
的接收方在拿到JWT
之后,都知道怎么对这些标准的claim
进行验证;而private claims
不会验证,除非明确告诉接收方要对这些 claim 进行验证以及规则才行。
按照 JWT
标准的说明:保留的 claims
都是可选的,在生成 payload
不强制用上面的那些 claim
,你可以完全按照自己的想法来定义 payload
的结构,不过这样搞根本没必要:第一是,如果把 JWT
用于认证, 那么 JWT
标准内规定的几个 claim
就足够用了,甚至只需要其中一两个就可以了,假如想往 JWT
里多存一些用户业务信息,比如角色和用户名等,这倒是用自定义的 claim
来添加;第二是,JWT
标准里面针对它自己规定的 claim
都提供了有详细的验证规则描述,每个实现库都会参照这个描述来提供 JWT
的验证实现,所以如果是自定义的 claim
名称,那么你用到的实现库就不会主动去验证这些 claim
。
最后也是把这个 json 结构做 base64url 编码之后,就能生成 payload
部分的串:
(在线 base64 编码:http://www1.tc711.com/tool/BASE64.htm)
最后看 signature
部分的生成过程。签名是把 header
和 payload
对应的 json 结构进行 base64url 编码之后得到的两个串用英文句点号拼接起来,然后根据 header
里面 alg
指定的签名算法生成出来的。算法不同,签名结果不同,但是不同的算法最终要解决的问题是一样的。以 alg: HS256
为例来说明前面的签名如何来得到。按照前面 alg
可用值的说明,HS256 其实包含的是两种算法:HMAC 算法和 SHA256 算法,前者用于生成摘要,后者用于对摘要进行数字签名。这两个算法也可以用 HMACSHA256 来统称。运用 HMACSHA256 实现 signature
的算法是:
正好找到一个在线工具能够测试这个签名算法的结果,比如我们拿前面的 header
和 payload
串来测试,并把“secret”这个字符串就当成密钥来测试:
最后的结果 B 其实就是 JWT 需要的 signature。不过对比我在介绍 JWT 的开始部分给出的 JWT 的举例:
会发现通过在线工具生成的 header
与 payload
都与这个举例中的对应部分相同,但是通过在线工具生成的 signature
与上面图中 的signature
有细微区别,在于最后是否有“=”字符。这个区别产生的原因在于上图中的 JWT
是通过 JWT
的实现库签发的 JWT
,这些实现库最后编码的时候都用的是 base64url 编码,而前面那些在线工具都是 bas64 编码,这两种编码方式不完全相同,导致编码结果有区别。
以上就是一个 JWT
包含的全部内容以及它的签发过程。接下来看看该如何去验证一个 JWT
是否为一个有效的 JWT
。
二.JWT
的验证过程
这个部分介绍 JWT
的验证规则,主要包括签名验证和 payload
里面各个标准 claim
的验证逻辑介绍。只有验证成功的 JWT
,才能当做有效的凭证来使用。
先说签名验证。当接收方接收到一个 JWT
的时候,首先要对这个 JWT
的完整性进行验证,这个就是签名认证。它验证的方法其实很简单,只要把 header
做 base64url 解码,就能知道 JWT
用的什么算法做的签名,然后用这个算法,再次用同样的逻辑对 header
和 payload
做一次签名,并比较这个签名是否与 JWT
本身包含的第三个部分的串是否完全相同,只要不同,就可以认为这个 JWT
是一个被篡改过的串,自然就属于验证失败了。接收方生成签名的时候必须使用跟 JWT
发送方相同的密钥,意味着要做好密钥的安全传递或共享。
再来看 payload
的 claim
验证,拿前面标准的 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(Expiration time)
:如果验证的时候超过了这个 claim
指定的时间,就属于验证失败;
nbf(Not Before)
:如果验证的时候小于这个 claim
指定的时间,就属于验证失败;
iat(Issued at)
:它可以用来做一些 maxAge 之类的验证,假如验证时间与这个 claim
指定的时间相差的时间大于通过 maxAge 指定的一个值,就属于验证失败;
jti(JWT ID)
:如果签发的时候这个 claim
的值是“1”,验证的时候如果这个 claim
的值不是“1”就属于验证失败;
需要注意的是,在验证一个 JWT
的时候,签名认证是每个实现库都会自动做的,但是 payload
的认证是由使用者来决定的。因为 JWT
里面可能不会包含任何一个标准的 claim
,所以它不会自动去验证这些 claim
。
以登录认证来说,在签发 JWT
的时候,完全可以只用 sub
跟 exp
两个 claim
,用 sub
存储用户的 id
,用 exp
存储它本次登录之后的过期时间,然后在验证的时候仅验证 exp
这个 claim
,以实现会话的有效期管理。
JWT SSO
场景一:用户发起对业务系统的第一次访问,假设他第一次访问的是系统 A 的 some/page 这个页面,它最终成功访问到这个页面的过程是:
在这个过程里面,我认为理解的关键点在于:
-
它用到了两个
cookie
(jwt
和sid
)和三次重定向来完成会话的创建和会话的传递; -
jwt
的cookie
是写在 systemA.com 这个域下的,所以每次重定向到 systemA.com 的时候,jwt
这个cookie
只要有就会带过去; -
sid
的cookie
是写在 cas.com 这个域下的,所以每次重定向到 cas.com 的时候,sid
这个cookie
只要有就会带过去; -
在验证
jwt
的时候,如何知道当前用户已经创建了 sso 的会话?
因为jwt
的payload
里面存储了之前创建的 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,它的处理过程是:
从这一步可以看出,即使登录之后,也要每次跟 CAS 校验 jwt
的有效性以及会话的有效性,其实 jwt
的有效性也可以放在业务系统里面处理的,但是会话的有效性就必须到 CAS 那边才能完成了。当 CAS 拿到 jwt
里面的 sessionid
之后,就能到 session
缓存服务器里面去验证该 sessionid
对应的 session
对象是否存在,不存在,就说明会话已经销毁了(退出)。
场景三:用户登录了系统 A 之后,再去访问其他系统如系统 B 的资源,比如系统 B 的 some/page,它最终能访问到系统 B 的 some/page 的流程是:
这个过程的关键在于第一次重定向的时候,它会把 sid
这个 cookie
带回给 CAS 服务器,所以 CAS 服务器能够判断出会话是否已经建立,如果已经建立就跳过登录页的逻辑。
场景四:用户继续访问系统 B 的其它资源,如系统 B 的 some/page2:
这个场景的逻辑跟场景二完全一致。
场景五:退出登录,假如它从系统 B 发起退出,最终的流程是:
最重要的是要清除 sid
的 cookie
,jwt
的 cookie
可能业务系统都有创建,所以不可能在退出的时候还挨个去清除那些系统的 cookie
,只要 sid
一清除,那么即使那些 jwt
的 cookie
在下次访问的时候还会被传递到业务系统的服务端,由于 jwt
里面的 sid
已经无效,所以最后还是会被重定向到 CAS 登录页进行处理。
方案总结
以上方案两个关键的前提:
- 整个会话管理其实还是基于服务端的
session
来做的,只不过这个session
只存在于 CAS 服务里面; - CAS 之所以信任业务系统的
jwt
,是因为这个jwt
是 CAS 签发的,理论上只要认证通过,就可以认为这个jwt
是合法的。
jwt
本身是不可伪造,不可篡改的,但是不代表非法用户冒充正常用法发起请求,所以常规的几个安全策略在实际项目中都应该使用:
- 使用 https
- 使用 http-only 的
cookie
,针对sid
和jwt
- 管理好密钥
- 防范 CSRF 攻击。
尤其是 CSRF 攻击形式,很多都是钻代码的漏洞发生的,所以一旦出现 CSRF 漏洞,并且被人利用,那么别人就能用获得的 jwt
,冒充正常用户访问所有业务系统,这个安全问题的后果还是很严重的。考虑到这一点,为了在即使有漏洞的情况将损害减至最小,可以在 jwt
里面加入一个系统标识,添加一个验证,只有传过来的 jwt
内的系统标识与发起 jwt
验证请求的服务一致的情况下,才允许验证通过。这样的话,一个非法用户拿到某个系统的 jwt
,就不能用来访问其它业务系统了。
在业务系统跟 CAS 发起 attach/validate 请求的时候,也可以在 CAS 端做些处理,因为这个请求,在一次 SSO 过程中,一个系统只应该发一次,所以只要之前已经给这个系统签发过 jwt 了,那么后续 同一系统的 attach/validate 请求都可以忽略掉。
总的来说,这个方案的好处有:
- 完全分布式,跨平台,CAS 以及业务系统均可采用不同的语言来开发;
- 业务系统如系统 A 和系统 B,可实现服务端无状态
- 假如是自己来实现,那么可以轻易的在 CAS 里面集成用户注册服务以及第三方登录服务,如微信登录等。
它的缺陷是:
- 第一次登录某个系统,需要三次重定向;
- 登录后的后续请求,每次都需要跟 CAS 进行会话验证,所以 CAS 的性能负载会比较大
- 登陆后的后续请求,每次都跟 CAS 交互,也会增加请求响应时间,影响用户体验。
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于