微服务架构整合Spring Cloud Gateway网关和JWT Token实现登录认证。

1、认证、授权、凭证

1.1 认证(Authentication)

认证表示你是谁。系统如何正确分辨出操作用户的真实身份,比如通过输入用户名和密码来辨别身份。

1.2 授权(Authorization)

授权表示你能干什么。系统如何控制一个用户能看到哪些数据和操作哪些功能,也就是具有哪些权限。

1.3 凭证(Credential)

表示你如何证明你的身份。系统如何保证它与用户之间的承诺是双方当时真实意图的体现,是准确、完整和不可抵赖的。

关于凭证的存储方案,业界的安全架构中有两种方案:

  1. Cookie-Session 模式
  2. JWT 方案

1.3.1、Cookie-Session 模式

如下图示:

img

优点:

状态信息都存储于服务器,只要依靠客户端的同源策略和 HTTPS 的传输层安全,保证 Cookie 中的键值不被窃取而出现被冒认身份的情况,就能完全规避掉上下文信息在传输过程中被泄漏和篡改的风险。

缺点:

在单节点的单体服务中再适合不过,但是如果需要水平扩展要部署集群就很麻烦。

如果让 session 分配到不同的的节点上,不重复地保存着一部分用户的状态,用户的请求固定分配到对应的节点上,如果某个节点崩溃了,则里面的用户状态就会完全丢失。如果让 session 复制到所有节点上,那么同步的成本又会很高。

1.3.2、JWT方案

上面说到 Cookie-Session 机制在分布式环境下会遇到一致性和同步成本的问题,而且如果在多方系统中,则更不能将 Session 共享存放在多方系统的服务端中,即使服务端之间能共享数据,Cookie 也没有办法跨域。

转换思路,服务端不保存任何状态信息,由客户端来存储,每次发送请求时携带这个状态信息发给后端服务。原理图如下所示:

img

JWT(JSON WEB TOKEN)是一种令牌格式,经常与 OAuth2.0 配合应用于分布式、多方的应用系统中。

我们先来看下 JWT 的格式长什么样:

img

左边的字符串就是 JWT 令牌,JWT 令牌是服务端生成的,客户端会拿着这个 JWT 令牌在每次发送请求时放到 HTTP header 中。

而右边是 JWT 经过 Base64 解码后展示的明文内容,而这段明文内容的最下方,又有一个签名内容,可以防止内容篡改,但是不能解决泄漏的问题。

JWT 格式

JWT 令牌是以 JSON 结构存储,用点号分割为三个部分。

img

第一部分是令牌头(Header),内容如下所示:

1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}

它描述了令牌的类型(统一为 typ:JWT)以及令牌签名的算法,示例中 HS256 为 HMAC SHA256 算法的缩写

令牌的第二部分是负载(Payload),这是令牌是真正需要向服务端传递的信息。但是服务端不会直接用这个负载,而是通过加密传过来的 Header 和 Payload 后再比对签名是否一致来判断负载是否被篡改,如果没有被篡改,才能用 Payload 中的内容。因为负载只是做了 base64 编码,并不是加密,所以是不安全的,千万别把敏感信息比如密码放到负载里面。

1
2
3
4
5
{
"sub": "passjava",
"name": "悟空聊架构",
"iat": 1516239022
}

令牌的第三部分是签名(Signature),使用在对象头中公开的特定签名算法,通过特定的密钥(Secret,由服务器进行保密,不能公开)对前面两部分内容进行加密计算,以例子里使用的 JWT 默认的 HMAC SHA256 算法为例,将通过以下公式产生签名值:

1
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload) , secret)

签名的意义:确保负载中的信息是可信的、没有被篡改的,也没有在传输过程中丢失任何信息。因为被签名的内容哪怕发生了一个字节的变动,也会导致整个签名发生显著变化。此外,由于签名这件事情只能由认证授权服务器完成(只有它知道 Secret),任何人都无法在篡改后重新计算出合法的签名值,所以服务端才能够完全信任客户端传上来的 JWT 中的负载信息。

JWT 的优势

  • 无状态:不需要服务端保存 JWT 令牌,也就是说不需要服务节点保留任何一点状态信息,就能在后续的请求中完成认证功能。
  • 天然的扩容便利:服务做水平扩容不用考虑 JWT 令牌,而 Cookie-Session 是需要考虑扩容后服务节点如何存储 Session 的。
  • 不依赖 Cookie:JWT 可以存放在浏览器的 LocalStorage,不一定非要存储在 Cookie 中。

JWT 的劣势

  • 令牌难以主动失效:JWT 令牌签发后,理论上和认证的服务器就没有什么关系了,到期之前始终有效。除非服务器加些特殊的逻辑处理来缓存 JWT,并来管理 JWT 的生命周期,但是这种方式又会退化成有状态服务。而这种要求有状态的需求又很常见:譬如用户退出后,需要重新输入用户名和密码才能登录;或者用户只允许在一台设备登录,登录到另外一台设备,要求强行退出。但是这种有状态的模式,降低了 JWT 本身的价值。
  • 更容易遭受重放攻击:Cookie-Session 也有重放攻击的问题,也就是客户端可以拿着这个 cookie 不断发送大量请求,对系统性能造成影响。但是因为 Session 在服务端也有一份,服务端可以控制 session 的生命周期,应对重放攻击更加主动一些。但是 JWT 的重放攻击对于服务端来说就很被动,比如通过客户端的验证码、服务端限流或者缩短令牌有效期,应用起来都会麻烦些。
  • 存在泄漏的风险:客户端存储,很有可能泄漏出去,被其他人重复利用。
  • 信息大小有限:HTTP 协议并没有强制约束 Header 的最大长度,但是服务器、浏览器会做限制。而且如果令牌很大还会消耗传输带宽。

2、认证原理图

在如下的认证时序图中,有以下几种角色:

  • 客户端:表示 APP 端或 PC 端的前端页面。
  • 网关:表示 Spring Cloud Gateway 网关服务。
  • 认证服务:用来接收客户的登录请求、登出请求、刷新令牌的操作。
  • 业务服务:和系统业务相关的微服务。

认证和校验身份的流程如下所示:

img

用户登录:客户端在登录页面输入用户名和密码,提交表单,调用登录接口。

转发请求:这里会先将登录请求发送到网关服务 passjava-gateway,网关对于登录请求会直接转发到认证服务 passjava-auth。(网关对登录请求不做 token 校验,这个可以配置不校验哪些请求 URL)

认证:认证服务会将请求参数中的用户名+密码和数据库中的用户进行比对,如果完全匹配,则认证通过。

生成令牌:生成两个令牌:access_token 和 refresh_token(刷新令牌),刷新令牌我们后面再说,这里其实也可以只用生成一个令牌 access_token。令牌里面会包含用户的身份信息,如果要做权限管控,还需要在 token 里面包含用户的权限信息,权限这一块不在本篇展开,会放到下一篇中进行讲解。

客户端缓存 token:客户端拿到两个 token 缓存到 cookie 中或者 LocalStorage 中。

携带 token 发起请求:客户端下次想调用业务服务时,将 access_token 放到请求的 header 中。

网关校验 token:请求还是先到到网关服务,然后由它校验 access_token 是否合法。如果 access_token 未过期,且能正确解析出来,就说明是合法的 access_token。

携带用户身份信息转发请求:网关将 access_token 中携带的用户的 user_id 放到请求的 header 中,转发给真正的业务服务。

处理业务逻辑:业务服务从 header 中拿到用户的 user_id,然后处理业务逻辑,处理完后将结果延原理返回给客户端。

3、实现细节

更多细节见:实战SpringCloud gateway+JWT认证

3.1、如何做登录认证

img

步骤:

  1. 提交用户名和密码
  2. 网关服务转发登录请求
  3. 数据库查找用户密码,验证成功后生成JWT令牌

3.2、如何生成令牌

生成令牌就是通过工具类 PassJavaJwtTokenUtil 生成 JWT Token。

3.3、如何携带JWT发送请求

img

客户端(浏览器或 APP)拿到 JWT 后,可以将 JWT 存放在浏览器的 Cookie 或 LocalStorage(本地存储) 或者内存中。

发送请求时在请求 Header 的 Authorization 字段中设置 JWT。

3.4、网关如何验证和转发请求

img

网关接收到前端发起的业务请求后,会先验证请求的 Header 中是否携带 Authorization 字段,以及里面的 Token 是否合法。然后解析 Token 中的 userId 和 username,放到 header 中再进行转发。

网关是通过多个过滤器 Filter对请求进行串行拦截处理的,所以我们可以自定义一个全局过滤器,对所有请求进行校验,当然对于一些特殊请求比如登录请求就不需要校验了,因为调用登录请求的时候还没有生成 Token。

img

3.5、会员业务逻辑处理

会员服务接收到网关转发的请求后,就从 Header 中拿到用户身份信息,然后通过 userId 获取会员信息。

获取 userId 的方式其实可以通过加一个拦截器,由拦截器将 Header 中的 userId 和 username 放到线程中,后续的 controller,service,dao 类都可以从线程里面拿到 userId 和 username,不用通过传参的方式。

获取 userId 的方式:

  • 方式一:从 request 的 Header 中拿到 userId。代码简单,但是如果其他地方也要用到 userId,则需要通过方法传参的方式传递 userId。
  • 方式二:从线程变量里面拿到 userId。代码复杂,使用简单。好处是所有地方统一从一个地方获取。

3.6、如何刷新令牌

当认证服务返回给客户端的 JWT 也就是 access_token 过期后,客户端是通过发送登录请求重新拿到 access_token 吗?

这种重新登录的操作如果很频繁(因 JWT 过期时间较短),对于用户来说体验就很差了。客户端需要跳转到登录页面,让用户重新提交用户名和密码,即使客户端有记住用户名和密码,但是这种跳转的到登录页的操作会大幅度降低用户的体验。

有没有一种比较优雅的方式让客户端重新拿到 access_token 或者说延长 access_token 有效期呢?

我们知道 JWT 生成后是不能篡改里面的内容,即使是 JWT 的有效期也不行。所以延长 access_token 有效期的做法并不适合,而且如果长期保持一个 access_token 有效,也是不安全的。

那就只能重新生成 access_token 了。方案其实挺简单,客户端拿之前生成的 JWT 调用后端一个接口,然后后端校验这个 JWT 是否合法,如果是合法的就重新生成一个新的返回给客户端。客户端自行替换掉之前本地保存的 access_token 就可以了。

img

这里有一个巧妙的设计,就是生成 JWT 时,返回了两个 JWT token,一个 access_token,一个 refresh_token,这两个 token 其实都可以用来刷新 token,但是我们把 refresh_token 设置的过期时间稍微长一点,比如两倍于 access_token,当 access_token 过期后,refresh_token 如果还没有过期,就可以利用两者的过期时间差进行重新生成令牌的操作,也就是刷新令牌,这里的刷新指的是客户端重置本地保存的令牌,以后都用新的令牌。

总结:

在登录认证中,刷新令牌和设置过期重登是常见的安全机制,用于保证用户的登录状态和防止令牌被滥用。下面是一种常见的实现方式:

  1. 生成令牌和刷新令牌:

    - 在用户登录成功后,生成一个访问令牌(access token)和一个刷新令牌(refresh token)。

    - 访问令牌用于验证用户的身份和访问权限,通常具有较短的有效期。

    - 刷新令牌用于在访问令牌过期时获取新的访问令牌,通常具有较长的有效期。

  2. 访问令牌的验证:

    - 在每个请求中,服务端需要验证访问令牌的有效性。

    - 可以通过在请求头或请求参数中携带访问令牌,并在服务端进行解析和验证。

    - 如果访问令牌已过期或无效,服务端会返回相应的错误响应。

  3. 刷新令牌的使用:

    - 当访问令牌过期时,客户端可以使用刷新令牌来获取新的访问令牌。

    - 客户端发送一个特殊的请求,携带刷新令牌。

    - 服务端验证刷新令牌的有效性,并根据刷新令牌颁发一个新的访问令牌。

  4. 设置过期重登:

    - 如果用户长时间不活动或注销登录,访问令牌会过期失效。

    - 当用户再次访问需要登录的接口时,服务端会返回一个需要重新登录的错误响应。

    - 客户端收到该错误响应后,需要引导用户重新进行登录认证。

通过刷新令牌和设置过期重登的机制,可以在保证用户登录状态的同时,提高系统的安全性。请注意,具体的实现方式可能会根据实际情况和框架的不同而有所差异。



本站总访问量