⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 网络 「网络」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注**微信公众号:【芋道源码】**有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言将得到认真回复。甚至不知道如何读源码也可以请教噢
  4. 新的源码解析文章实时收到通知。每周更新一篇左右
  5. 认真的源码交流微信群。

今天来聊聊微服务中一个重要的话题:如何设计微服务架构下的用户认证方案。今天主要涉及三个方面的内容:

  • 传统的用户认证方案;
  • JWT 与 JJWT;
  • 基于网关的统一用户认证。

传统的用户认证方案

我们直奔主题,什么是用户认证呢?对于大多数与用户相关的操作,软件系统首先要确认用户的身份,因此会提供一个用户登录功能。用户输入用户名、密码等信息,后台系统对其进行校验的操作就是用户认证。用户认证的形式有多种,最常见的有输入用户名密码、手机验证码、人脸识别、指纹识别等,但其目的都是为了确认用户的身份并与之提供服务。

用户认证

在传统的单体单点应用时代,我们会开发用户认证的服务类,从登录界面提交的用户名密码等信息通过用户认证类进行校验,然后获取该用户对象将其保存在 Tomcat 的 Session 中,如下所示:

单点应用认证方案

随着系统流量的增高,单点应用以无法支撑业务运行,应用出现高延迟、宕机等状况,此时很多公司会将应用改为 Nginx 软负载集群,通过水平扩展提高系统的性能,于是应用架构就变成了这个样子。

Java Web 应用集群

虽然改造后系统性能显著提高,但你发现了么,因为之前用户登录的会话数据都保存在本地,当 Nginx 将请求转发到其他节点后,因为其他节点没有此会话数据,系统就会认为没有登录过,请求的业务就会被拒绝。从使用者的角度会变成一刷新页面后,系统就让我重新登录,这个使用体验非常糟糕。

我们来分析下,这个问题的根本原因在于利用 Session 本地保存用户数据会让 Java Web 应用变成有状态的,在集群环境下必须保证每一个 Tomcat 节点的会话状态一致的才不会出问题。因此基于 Redis 的分布式会话存储方案应运而生,在原有架构后端增加 Redis 服务器,将用户会话统一转存至 Redis 中,因为该会话数据是集中存储的,所以不会出现数据一致性的问题。

Redis 统一存储用户会话

但是,传统方案在互联网环境下就会遇到瓶颈,Redis 充当了会话数据源,这也意味着 Redis 承担了所有的外部压力,在互联网数以亿计的庞大用户群规模下,如果出现突发流量洪峰,Redis 能否经受考验就会成为系统的关键风险,稍有差池系统就会崩溃。

那如何解决呢?其实还有一种巧妙的设计,在用户认证成功,后用户数据不再存储在后端,而改为在客户端存储,客户端每一次发送请求时附带用户数据到 Web 应用端,Java 应用读取用户数据进行业务处理,因为用户数据分散存储在客户端中,因此并不会对后端产生额外的负担,此时认证架构会变成下面的情况。

客户端存储用户信息

当用户认证成功后,在客户端的 Cookie、LocalStorage 会持有当前用户数据,在 Tomcat 接收到请求后便可获取用户数据进行业务处理。但细心的你肯定也发现,用户的敏感数据是未经过加密的,在存储与传输过程中随时都有泄密的风险,决不能使用明文,必须要对其进行加密。

那如何进行加密处理呢?当然,你可以自己写加解密类,但更通用的做法是使用 JWT 这种标准的加密方案进行数据存储与传输。

Json Web Token(JWT)介绍

无论是微服务架构,还是前后端分离应用,在客户端存储并加密数据时有一个通用的方案:Json Web Token(JWT),JWT是一个经过加密的,包含用户信息的且具有时效性的固定格式字符串。下面这是一个标准的JWT字符串。

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ7XCJ1c2VySWRcIjoyLFwidXNlcm5hbWVcIjpcImxpc2lcIixcIm5hbWVcIjpcIuadjuWbm1wiLFwiZ3JhZGVcIjpcInZpcFwifSJ9.NT8QBdoK4S-PbnhS0msJAqL0FG2aruvlsBSyG226HiU

这段加密字符串由三部分组成,中间由点“.”分隔,具体含义如下。

  • 第一部分 标头(Header):标头通常由两部分组成:令牌的类型(即 JWT)和所使用的签名算法,例如 HMAC SHA256 或 RSA,下面是标头的原文:

{  
"alg": "HS256",
"typ": "JWT"
}

然后,此 JSON 被 Base64 编码以形成 JWT 的第一部分。

eyJhbGciOiJIUzI1NiJ9

  • 第二部分 载荷(Payload):载荷就是实际的用户数据以及其他自定义数据。载荷原文如下所示。

{  
"sub": "1234567890",
"name": "John Doe",
"admin": true
}

然后对原文进行 Base64 编码形成 JWT 的第二部分。

eyJzdWIiOiJ7XCJ1c2VySWRcIjoyLFwidXNlcm5hbWVcIjpcImxpc2lcIixcIm5hbWVcIjpcIuadjuWbm1wiLFwiZ3JhZGVcIjpcInZpcFwifSJ9

  • 第三部分 签名(Sign):签名就是通过前面两部分标头+载荷+私钥再配合指定的算法,生成用于校验 JWT 是否有效的特殊字符串,签名的生成规则如下。

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

生成的签名字符串为:

NT8QBdoK4S-PbnhS0msJAqL0FG2aruvlsBSyG226HiU

将以上三部分通过“.”连接在一起,就是 JWT 的标准格式了。

JWT 的创建与校验

此时,你肯定有疑问 JWT 是如何生成的,又是如何完成有效性校验呢?因为 JWT 的格式与算法是固定的,在 Java 就有非常多的优秀开源项目帮我们实现了JWT 的创建与验签,其中最具代表性的产品就是 JJWT。JJWT 是一个提供端到端的 JWT 创建和验证的 Java 库,它的官网是:https://github.com/jwtk/jjwt,有兴趣的话你可以到官网阅读它的源码。

JJWT 的使用是非常简单的,下面我们用代码进行说明,关键代码我已做好注释。

  • 第一步,pom.xml 引入 JJWT 的 Maven 依赖。

<dependency>  
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>

<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>

<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>

  • 第二步,编写创建 JWT 的测试用例,模拟真实环境 UserID 为 123 号的用户登录后的 JWT 生成过程。

@SpringBootTest  
public class JwtTestor {
/**
* 创建Token
*/
@Test
public void createJwt(){
//私钥字符串
String key = "1234567890_1234567890_1234567890";
//1.对秘钥做BASE64编码
String base64 = new BASE64Encoder().encode(key.getBytes());
//2.生成秘钥对象,会根据base64长度自动选择相应的 HMAC 算法
SecretKey secretKey = Keys.hmacShaKeyFor(base64.getBytes());
//3.利用JJWT生成Token
String data = "{\"userId\":123}"; //载荷数据
String jwt = Jwts.builder().setSubject(data).signWith(secretKey).compact();
System.out.println(jwt);
}
}

运行结果产生 JWT 字符串如下:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ7XCJ1c2VySWRcIjoxMjN9In0.1p_VTN46sukRJTYFxUg93CmfR3nJZRBm99ZK0e3d9Hw

  • 第三步,验签代码,从 JWT 中提取 123 号用户数据。这里要保证 JWT 字符串、key 私钥与生成时保持一致。否则就会抛出验签失败 JwtException。

/**  
* 校验及提取JWT数据
*/
@Test
public void checkJwt(){
String jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ7XCJ1c2VySWRcIjoxMjN9In0.1p_VTN46sukRJTYFxUg93CmfR3nJZRBm99ZK0e3d9Hw";
//私钥
String key = "1234567890_1234567890_1234567890";
//1.对秘钥做BASE64编码
String base64 = new BASE64Encoder().encode(key.getBytes());
//2.生成秘钥对象,会根据base64长度自动选择相应的 HMAC 算法
SecretKey secretKey = Keys.hmacShaKeyFor(base64.getBytes());
//3.验证Token
try {
//生成JWT解析器
JwtParser parser = Jwts.parserBuilder().setSigningKey(secretKey).build();
//解析JWT
Jws<Claims> claimsJws = parser.parseClaimsJws(jwt);
//得到载荷中的用户数据
String subject = claimsJws.getBody().getSubject();
System.out.println(subject);
}catch (JwtException e){
//所有关于Jwt校验的异常都继承自JwtException
System.out.println("Jwt校验失败");
e.printStackTrace();
}
}

运行结果如下:

{"userId":123}

以上便是 JWT 的生成与校验代码,你会发现在加解密过程中,服务器私钥 key 是保障 JWT 安全的命脉。对于这个私钥在生产环境它不能写死在代码中,而是加密后保存在 Nacos 配置中心统一存储,同时定期更换私钥以防止关键信息泄露。

讲到这应该你已掌握 JWT 的基本用法,但是在微服务架构下又该如何设计用户认证体系呢?

基于网关的统一用户认证

下面我们结合场景讲解 JWT 在微服务架构下的认证过程。这里我将介绍两种方案:

  • 服务端自主验签方案;
  • API 网关统一验签方案。

服务端自主验签方案

首先咱们来看服务端验签的架构图。

图片服务端自主验签方案

首先梳理下执行流程:

  • 第一步,认证中心微服务负责用户认证任务,在启动时从 Nacos 配置中心抽取 JWT 加密用私钥;
  • 第二步,用户在登录页输入用户名密码,客户端向认证中心服务发起认证请求:

http://usercenter/login #认证中心用户认证(登录)地址

  • 第三步,认证中心服务根据输入在用户数据库中进行认证校验,如果校验成功则返回认证中心将生成用户的JSON数据并创建对应的 JWT 返回给客户端,下面是认证中心返回的数据样本;

{  
"code": "0",
"message": "success",
"data": {
"user": {
"userId": 1,
"username": "zhangsan",
},
"token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ7XCJ1c2VySWRcIjoxLFwidXNlcm5hbWVcIjpcInpoYW5nc2FuXCIsXCJuYW1lXCI6XCLlvKDkuIlcIixcImdyYWRlXCI6XCJub3JtYWxcIn0ifQ.1HtfszarTxLrqPktDkzArTEc4ah5VO7QaOOJqmSeXEM"
}
}

  • 第四步,在收到上述 JSON 数据后,客户端将其中 token 数据保存在 cookie 或者本地缓存中;
  • 第五步,随后客户端向具体某个微服务发起新的请求,这个 JWT 都会附加在请求头或者 cookie 中发往 API 网关,网关根据路由规则将请求与jwt数据转发至具体的微服务。中间过程网关不对 JWT 做任何处理;
  • 第六步,微服务接收到请求后,发现请求附带 JWT 数据,于是将 JWT 再次转发给用户认证服务,此时用户认证服务对 JWT 进行验签,验签成功提取其中用户编号,查询用户认证与授权的详细数据,数据结构如下所示:

{  
"code": "0",
"message": "success",
"data": {
"user": { #用户详细数据
"userId": 1,
"username": "zhangsan",
"name": "张三",
"grade": "normal"
"age": 18,
"idno" : 130.......,
...
},
"authorization":{ #权限数据
"role" : "admin",
"permissions" : [{"addUser","delUser","..."}]
}
}
}

  • 第七步,具体的微服务收到上述 JSON 后,对当前执行的操作进行判断,检查是否拥有执行权限,权限检查通过执行业务代码,权限检查失败返回错误响应。

到此从登录创建 JWT 到验签后执行业务代码的完整流程已经完成。

下面咱们来聊一聊第二种方案:

API 网关统一验签方案

关统一验签方案API 网

API 网关统一验签与服务端验签最大的区别是在 API 网关层面就发起 JWT 的验签请求,之后路由过程中附加的是从认证中心返回的用户与权限数据,其他的操作步骤与方案一是完全相同的。

在这你可能又会有疑惑,为什么要设计两种不同的方案呢?其实这对应了不同的应用场景:

服务端验签的时机是在业务代码执行前,控制的粒度更细。比如微服务 A 提供了“商品查询”与“创建订单”两个功能,前者不需要登录用户就可以使用,因此不需要向认证中心额外发起验签工作;而后者是登录后的功能,因此必须验签后才可执行。因为服务端验签是方法层面上的,所以可以精确控制方法是否验签。但也有不足,正是因为验签是在方法前执行,所以需要在所有业务方法上声明是否需要额外验签,尽管这个工作可以通过 Spring AOP+注解的方式无侵入实现,但这也无疑需要程序员额外关注,分散了开发业务的精力。

相应的,服务端验签的缺点反而成为 API 网关验签的优势。API 网关不关心后端的服务逻辑,只要请求附带 JWT,就自动向认证中心进行验签。这种简单粗暴的策略确实让模块耦合有所降低,处理起来也更简单,但也带来了性能问题,因为只要请求包含 JWT 就会产生认证中心的远程通信。如果前端工程师没有对 JWT 进行精确控制,很可能带来大量多余的认证操作,系统性能肯定会受到影响。

那在项目中到底如何选择呢?服务端验签控制力度更细,适合应用在低延迟、高并发的应用,例如导航、实时交易系统、军事应用。而 API 统一网关则更适合用在传统的企业应用,可以让程序员专心开发业务逻辑,同时程序也更容易维护。

全新的挑战

虽然 JWT 看似很美,在实施落地过程中也会遇到一些特有的问题,例如:

JWT 生成后失效期是固定的,很多业务中需要客户端在不改变 JWT 的前提下,实现 JWT 的“续签”功能,但这单靠 JWT 自身特性是无法做到的,因为 JWT 的设计本身就不允许生成完全相同的字符串。为了解决这个问题,很多项目在生成的 JWT 设为“永久生效”,架构师利用 Redis 的 Expire 过期特性在后端控制 JWT 的时效性。这么做虽然让 JWT 本身变得有状态,但这可能也是在各种权衡后的“最优解”。类似的,例如:强制 JWT 立即失效、动态 JWT 有效期都可以使用这个办法解决。

某个 JWT 在 3600 秒后过期

对于上面两种认证方案,还有优化的空间,比如在服务A第一次对某个 JWT 进行验签后获取用户与权限数据,那在 JWT 的有效期内便可将数据在本地内存或者 Redis 中进行缓存,这样下一次同样的 JWT 访问时直接从缓存中提取即可,可以节省大量服务间通信时间。但引入缓存后你也要时刻关注缓存与用户数据的一致性问题,是要性能还是要数据可靠,这又是一个架构师需要面对的抉择。

小结

今天主要涉及三方面内容,首先咱们回顾了基于 Session 的有状态用户认证解决方案,其次介绍了 JWT 与 JJWT 的使用,最后讲解了利用 JWT 实现微服务架构认证的两种方案,对产生的新问题也进行了梳理。

在多年的架构生涯中,我自己也在不断感慨,架构是一门取舍的艺术,没有完美的架构,只有适合的场景,希望未来同学们可以多学习一些前沿技术,兴许随着技术发展没准鱼和熊掌真的可以兼得呢。

文章目录
  1. 1. 传统的用户认证方案
  2. 2. Json Web Token(JWT)介绍
    1. 2.1. JWT 的创建与校验
  3. 3. 基于网关的统一用户认证
    1. 3.1. 服务端自主验签方案
    2. 3.2. API 网关统一验签方案
  4. 4. 全新的挑战
  5. 5. 小结