手摸手 Spring Cloud Gateway + JWT 实现登录认证原创
你好,我是悟空。
前言
上篇我已经讲解了 Spring Cloud 的原理和实战,这次就要结合 JWT 来实现登录认证的功能了。
本文已收录至《深入剖析 Spring Cloud 底层架构原理》,已更新 17 讲。
目录
通过本文你会掌握以下知识点:
- 如何用认证服务做登录认证。
- 如何生成 JWT 令牌(Token)
- 如何用 Gateway 对 Token 验证。
- Gateway 如何从 Token 中拿到用户信息并转发给业务服务。
- 业务服务如何从请求中拿到身份信息处理业务逻辑。
- 如何刷新令牌。
本篇还是基于我的开源项目 PassJava 作为讲解。
PassJava 开源地址:https://github.com/Jackson0714/PassJava-Platform
前置知识点:
深入理解 Spring Cloud Gateway 的原理
我的师父把 「JWT 令牌」玩到了极致
在讲解之前有必要澄清下什么是认证、授权、凭证,这三个方面是一个系统中最基础的安全设计。
认证、授权、凭证
1.1 认证(Authentication)
认证表示你是谁。系统如何正确分辨出操作用户的真实身份,比如通过输入用户名和密码来辨别身份。
1.2 授权(Authorization)
授权表示你能干什么。系统如何控制一个用户能看到哪些数据和操作哪些功能,也就是具有哪些权限。
1.3 凭证(Credential)
表示你如何证明你的身份。系统如何保证它与用户之间的承诺是双方当时真实意图的体现,是准确、完整和不可抵赖的。
接下来我们看下使用 JWT 作为凭证完成认证的原理。
认证的原理
在如下的认证时序图中,有以下几种角色:
- 客户端:表示 APP 端或 PC 端的前端页面。
- 网关:表示 Spring Cloud Gateway 网关服务,这里。
- 认证服务:用来接收客户的登录请求、登出请求、刷新令牌的操作。
- 业务服务:和系统业务相关的微服务。
认证和校验身份的流程如下所示:
认证和校验身份流程
① 用户登录:客户端在登录页面输入用户名和密码,提交表单,调用登录接口。
② 转发请求:这里会先将登录请求发送到网关服务 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,然后处理业务逻辑,处理完后将结果原路返回给客户端。
接下来我们看下项目的整体架构。
项目整体结构
Github 项目地址:https://github.com/Jackson0714/PassJava-Platform
Gitee 项目地址:https://toscode.gitee.com/jayh2018/PassJava-Platform
- 认证服务:passjava-auth
- 网关服务:passjava-gateway
- JWT 公共项目:passjava-jwt,认证服务和网关服务都会引用这个公共项目。
- 业务服务:passjava-member,会员服务作为本次案例的业务服务。
Nacos 注册配置中心
认证服务:passjava-auth
核心类就是 JwtAuthController 类,里面有登录接口和刷新令牌的接口。
网关服务:passjava-gateway
核心类就是 JwtAuthCheckFilter 全局过滤器。
如果不需要在服务端保存刷新令牌,可以不需要 redis 配置。
JWT 公共项目
核心类就是 PassJavaJWTTokenUtil 工具类。认证服务引入 JWT 项目后用来生成 token,网关服务引入 JWT 项目后用来校验 token 合法性。
业务服务
这里我选择了会员微服务作为本次演示的业务微服务。
它从网关转发的请求 Header 中拿到 userId, 根据 userId 查询 member 信息。
核心文件是 MemberController 类、MemberEntity实体类、MemberService服务类、MemberDao 类和 mapper 文件。
启动的服务
Nacos 注册配置中心
首先启动 Nacos 服务。和 PassJava 项目配套使用的 Nacos 工具已经上传到网盘,下载后直接运行启动脚本就可以将 Nacos 在本地启动。
启动教程:
www.passjava.cn/#/01.项目简介/7.本地部署项目Mac版
网关、会员、认证服务
启动以下三个微服务,分别为网关、会员、认证服务。
检查下 nacos 注册中心上是否注册了这三个服务:可以看到确实有上面的三个微服务。
如何做登录认证
登录认证就是校验下用户提交的账户名和密码与本地数据库中的是否完全匹配,如果匹配,就认证通过。就是下方这个流程的 1、2、3 步。
第一步:提交用户名和密码
这里用 Postman 工具模拟前端发起登录请求,请求的 URL 如下:
http://localhost:8060/api/auth/login
image-20220814161920022
请求是向网关服务 passjava-gateway 发起的,所以可以看到上面的 URL 中 localhost 和 8060 是网关的 host 和 port。
然后 API 地址为 /api/auth/login,这个地址经过网关的路由匹配后会转发到 passjava-auth 服务的登录 API。
http://localhost:10001/auth/login
关于网关转发的原理可以参考这篇:深入理解 Spring Cloud Gateway 的原理
请求参数如下:
{
"userId": "wukong",
"password": "123456"
}
账号和密码都是密文的,转发到认证服务后,会根据 userId 查询出系统用户,然后将 password 参数加密后对比系统用户的密码。
所以为了让用户登录成功,还需要在数据库插入一条系统用户,用户 id 为 wukong,密码是对 123456 加密后的密码。
在线加密工具地址:
https://www.bejson.com/encrypt/bcrpyt_encode/
第二步:转发登录请求
转发登录请求是网关服务做的,所以我们来看下做了哪些事情。
在 Gateway 项目的 application-routers.yml 中配置路由规则:
spring:
cloud:
gateway:
routes:
- id: route_auth # 认证微服务路由规则
uri: lb://passjava-auth # 负载均衡,将请求转发到注册中心注册的 passjava-auth 服务
predicates: # 断言
- Path=/api/auth/** # 如果前端请求路径包含 api/auth,则应用这条路由规则
filters: #过滤器
- RewritePath=/api/(?<segment>.*),/$\{segment} # 将跳转路径中包含的api替换成空
在 application.properties 引入 application-routers.yml
spring:
profiles:
include: routers, jwt
第三步:验证用户账号和密码
这一步是认证服务的登录 API 里面做的。在 AuthController 中定义 login 接口,核心步骤就是查找系统用户和比对密码。
登录 API
用户名和密码匹配成功后,就会生成 JWT 令牌。
如何生成令牌
生成令牌就是通过工具类 PassJavaJwtTokenUtil 生成 JWT Token,也就是流程图中的第四步。
生成令牌的核心代码如下:
使用这个工具类的前提是我们需要先引入 jjwt 依赖。这个在 passjava-jwt 项目的 pom 文件中引入。
用 Postman 工具调用后,可以看到生成的令牌如下:
用 base64 解码后,可以看到 token 中的 PAYLOAD 里面包含了用户 id 和用户名。
生成 JWT 的加密密钥一般都是写到配置文件中。这里我是配置在 passjava-jwt 项目的 application-jwt.yml 配置文件中的。
然后认证服务就会将 JWT 令牌返回给客户端了。当客户端想要查询这个 userId 对应的会员信息时,就可以在请求的 Header 中带上 JWT 令牌。
如何携带 JWT 发送请求
客户端(浏览器或 APP)拿到 JWT 后,可以将 JWT 存放在浏览器的 Cookie 或 LocalStorage(本地存储) 或者内存中。
发送请求时在请求 Header 的 Authorization 字段中设置 JWT,这个字段其实可以自定义,但是我建议用 Authorization,因为这是一种业界标准。
另外告诉大家一个小技巧,在 Postman 工具中有个地方专门配置 Authorization,然后自动加到 Header 中,不用自己手动加 Header。
还有一个点需要注意,这里配置的 Authorization 的认证类型为 Bearer Token。它表示令牌可以是任意字符串格式的令牌。然后会在 Authorization 字段中加上一个前缀 Bearer。所以我们在网关服务解析 Header 中的 Authorization 时,需要去掉这个前缀 Bearer,代码如下所示:
网关如何验证 JWT 和转发请求
网关接收到前端发起的业务请求后,会先验证请求的 Header 中是否携带 Authorization 字段,以及里面的 Token 是否合法。然后解析 Token 中的 userId 和 username,放到 header 中再进行转发,也就是流程图中的第七步和第八步。
网关是通过多个过滤器 Filter对请求进行串行拦截处理的,所以我们可以自定义一个全局过滤器,对所有请求进行校验,当然对于一些特殊请求比如登录请求就不需要校验了,因为调用登录请求的时候还没有生成 Token。
网关的全局过滤器 JwtAuthCheckFilter 的核心代码如下所示:
会员服务处理业务逻辑
会员服务接收到网关转发的请求后,就从 Header 中拿到用户身份信息,然后通过 userId 获取会员信息。
注意:有的时候业务逻辑并不需要身份信息,更多的时候是需要检验用户的操作权限是否足够。其实 Token 里面也是可以携带权限信息的,不过这是下一篇讲解授权的部分。
获取 userId 的方式其实可以通过加一个拦截器,由拦截器将 Header 中的 userId 和 username 放到线程中,后续的 controller,service,dao 类都可以从线程里面拿到 userId 和 username,不用通过传参的方式。
获取 userId 的方式:
- 方式一:从 request 的 Header 中拿到 userId。代码简单,但是如果其他地方也要用到 userId,则需要通过方法传参的方式传递 userId。
- 方式二:从线程变量里面拿到 userId。代码复杂,使用简单。好处是所有地方统一从一个地方获取。
Request 中获取 userId 方式
代码示例如下:
下面介绍如何使用拦截器方式将 userId 存入线程变量的方式。
拦截器方式
在 passjava-common 模块中新增一个拦截器,获取请求头中的身份信息,加入到线程变量中。文件名为 HeaderInterceptor。
将拦截器注册到 WebMvcConfigurer。文件名为 WebMvcConfig.java。图片
配置文件中需要定义一个配置项:
文件名;org.springframework.boot.autoconfigure.AutoConfiguration.imports
配置项:com.jackson0714.passjava.common.config.WebMvcConfig
然后 passjava-member 服务引入这个拦截器配置。
@Import({WebMvcConfig.class})
通过上面两种方式中的任意一种拿到 userId 后,通过 userId 查询会员的详情。这里需要注意的是这个 user 既是系统用户也是系统中的会员。关于查询会员的数据库操作就不在此展开了。
执行结果如下图所示:
如何刷新令牌
还有一个内容是关于如何刷新令牌的。当认证服务返回给客户端的 JWT 也就是 access_token 过期后,客户端是通过发送登录请求重新拿到 access_token 吗?
这种重新登录的操作如果很频繁(因 JWT 过期时间较短),对于用户来说体验就很差了。客户端需要跳转到登录页面,让用户重新提交用户名和密码,即使客户端有记住用户名和密码,但是这种跳转到登录页的操作会大幅度降低用户的体验,甚至导致用户不想再用第二次。
有没有一种比较优雅的方式让客户端重新拿到 access_token 或者说延长 access_token 有效期呢?
我们知道 JWT 生成后是不能篡改里面的内容,即使是 JWT 的有效期也不行。所以延长 access_token 有效期的做法并不适合,而且如果长期保持一个 access_token 有效,也是不安全的。
那就只能重新生成 access_token 了。方案其实挺简单,客户端拿之前生成的 JWT 调用后端一个接口,然后后端校验这个 JWT 是否合法,如果是合法的就重新生成一个新的返回给客户端。客户端自行替换掉之前本地保存的 access_token 就可以了。
这里有一个巧妙的设计,就是生成 JWT 时,返回了两个 JWT token,一个 access_token,一个 refresh_token,这两个 token 其实都可以用来刷新 token,但是我们把 refresh_token 设置的过期时间稍微长一点,比如两倍于 access_token,当 access_token 过期后,refresh_token 如果还没有过期,就可以利用两者的过期时间差进行重新生成令牌的操作,也就是刷新令牌,这里的刷新指的是客户端重置本地保存的令牌,以后都用新的令牌。
饥饿模式和懒模式
当然,在 access_token 过期之前,客户端提前刷新令牌也是可以的,我称这种提前刷新的模式为饥饿模式(单例模式中也有这种叫法),而过期后再刷新令牌的模式我称之为懒模式。两种模式都可以用,前者需要客户端定期检查过期时间,增加了复杂性;后者则会出现短暂的请求失败的情况,得拿到新的令牌后才会成功。
刷新令牌的操作完全是通过客户端自己控制的,而且客户端也不仅限于浏览器,还有可能是第三方服务。
一次性
通常情况下,我们会将刷新令牌 refresh_token 设置为只能用一次,来保证刷新令牌的安全性。而这种就需要服务端来缓存刷新令牌了,当用过一次后,就从缓存里面主动剔除掉。但这样就违背了 JWT 无状态的特性,这个完全看业务需求来决定是否使用这种缓存方式。
如下图所示,生成令牌时我将刷新令牌缓存到了 Redis 里面。当我用 refresh_token 调用刷新 API 时,会主动剔除掉这个 key,下次再用相同的 refresh_token 刷新令牌时,因 Redis 中不存在这个 key,就会提示刷新刷新失败了。
缓存令牌
留两个小问题:
- 有没有办法让 access_token 主动失效?
- 场景题:如何保证同一个用户只能登录一台设备?
总结
虽然本篇是讲实战内容的,但是里面又涉及了很多原理性内容,比如网关、JWT 的原理。
结合实战讲解,相信大家对如何使用 Spring Cloud Gateway + JWT 实现登录认证有了充分的理解。
本篇只讲解了认证和凭证,授权部分还没有触及,所以这也是下篇要讲解的内容,来追更吧~
最后再说一句,别白嫖,点赞转发下哦~
- END -
关于我
8 年互联网开发经验,擅长微服务、分布式、架构设计。目前在一家大型上市公司从事基础架构和性能优化工作。
InfoQ 签约作者、蓝桥签约作者、阿里云专家博主、51CTO 红人。
欢迎加我好友,提供技术解答、简历修改、500人技术交流群。