决定放弃 JWT 了!

开发 前端
本节内容详细介绍了码猿慢病云管理系统中完整的认证登录生成token的流程,相信你对整体的流程有了清晰的了解。

JWT相信大家都有所了解,一种无状态的认证方式,因为JWT本身就能存储一些非敏感的身份信息,这种方式目前也被广泛使用,在陈某之前的Spring Cloud Gateway整合Spring Security OAuth2中使用的就是JWT。

但是JWT虽好,使用过程中还是要依赖缓存,比如退出登录,JWT唯一的失效途径就是等待过期时间失效,因此在退出登录时必须借助外力Redis才能达到效果。这个在之前的文章中也有介绍。

既然都要用Redis,为什么不采用Redis+Spring Security+OAuth2的认证方式呢?这种方式也是企业中经常采用的方案。

今天就介绍一下码猿慢病云管理系统中是如何将利用Redis和Spring Security 整合实现分布式统一认证登录的。

在学习这节内容之前先要了解Spring Security OAuth2 各种授权模式,在知识星球中《精尽Spring Cloud Alibaba》专栏有详细的介绍和案例代码演示,有需要的先去学习。

一、实现的效果

既然是直接使用Redis+Spring Security,身份信息肯定是存储在Redis中且token也不是JWT生成的令牌,如下图:

图片图片

可以看到令牌和刷新令牌以及身份信息都存储在Redis中。其中9d22b664-8540-48d1-98ed-4df1ce90b74f就是生成的令牌,无任何特殊含义,只是随机生成的UUID,相较于JWT短小了很多。

二、登录的客户端有哪些?

码猿慢病云管理系统中需要登录的客户端如下:

  • WEB端
  • PDA端
  • PAD端
  • 患者端
  • 小程序

今天先来介绍前三种,后面的两种后文介绍。

1. WEB端

登录页面如下:

web端登录web端登录

三个参数:

  1. 用户名
  2. 密码
  3. 医院ID

请求的报文如下:

POST /auth/oauth2/token?grant_type=password&scope=server HTTP/1.1
Host: codeape-gateway:9999
Authorization: Basic dGVzdDp0ZXN0
Content-Type: application/x-www-form-urlencoded
Content-Length: 32
username=admin&password=YehdBPev&hosId=1659018792143663105

因为是多租户的模式,所以在登录中做了医院的选择,这点也是对代码改造的一部分,下文介绍如何改造。

2. PDA端

PDA是护士的手持设备,用于采集数据,因此也是需要认证才能上传、查看数据。

PDA端登录只需要护士输入如下两个参数:

  • 用户名
  • 密码

为什么呢?不需要选择医院吗?

前面的文章中也有介绍过,PDA这种手持设备只有在平台上录入了才能使用,录入的地方:设备管理->设备列表->新增

图片图片

设备SN号是设备的唯一识别号,在设备取得注册证书后颁发的,所以可以作为唯一识别标志。

这里就是根据根据SN号去唯一关联这台设备,这也就是为什么PDA登录不用选择医院的原因。

PDA在发出登录请求时只需要携带这个SN号,请求报文如下:

POST /auth/oauth2/token?grant_type=password&scope=server HTTP/1.1
Host: codeape-gateway:9999
Authorization: Basic dGVzdDp0ZXN0
Content-Type: application/x-www-form-urlencoded
Content-Length: 32
username=admin&password=YehdBPev&sn=3981293B102

3. PAD端

平板一般是医生查房时作为移动端使用,住院医生每天都需要去病房查看病人病情,需要结合测量的数据才能了解患者的病情,因此PAD也是需要医生认证登录。

PAD端登录其实有两种方案:

  • 和WEB端相同,选择医院登录
  • 通过设备MAC地址绑定登录

码猿慢病云管理系统采用的第一种方案,需要选择医院,请求报文如下:

POST /auth/oauth2/token?grant_type=password&scope=server HTTP/1.1
Host: codeape-gateway:9999
Authorization: Basic dGVzdDp0ZXN0
Content-Type: application/x-www-form-urlencoded
Content-Length: 32
username=admin&password=YehdBPev&hosId=1659018792143663105

三、 密码模式登录

上面介绍的WEB端、PDA端、PAD端都是基于密码模式改造的,在介绍认证流程之前需要将登录接口给导入接口工具,这里使用的是Apifox,下载下方密码模式脚本,直接导入Apifox。

导入成功后,你将会得到一个接口,如下图:

图片图片

点击运行,发出请求登录,返回的信息如下图:

图片图片

上述返回信息几个比较重要的属性如下:

1. access_token

这个则是认证成功生成token,后续请求资源时只需要携带这个token则能通过认证

PS:这里的token似乎很短小,其实并不是JWT生成token,而是UUID。

2. refresh_token

这个是token过期后的刷新令牌,当token过期后则拿着这个refresh_token即可重新获取新的access_token,无需再次认证登录

3. user_info

这部分是当前用户登录成功后返回一些个人信息,比如权限、医院ID、所属的科室/病区ID等,详细信息如下图:

  • username:用户名
  • authorities:权限
  • id:主键ID
  • deptId:科室/病区ID
  • hosId:医院ID
  • deptAuths:科室/病区权限
  • roleCodes:角色编码
  • phone:手机号
  • clientId:客户端ID
  • sn:登录的PDA的SN号
  • name:姓名

图片图片

4. scope

对应的资源的权限

四、密码模式登录字段加密

密码模式的登录有两个点比较重要,以WEB端登录报文为例:

POST /auth/oauth2/token?grant_type=password&scope=server HTTP/1.1
Host: codeape-gateway:9999
Authorization: Basic dGVzdDp0ZXN0
Content-Type: application/x-www-form-urlencoded
Content-Length: 32
username=admin&password=YehdBPev&hosId=1659018792143663105

从上面的报文可以看到有两处进行了加密,如下:

  1. Authorization:这里是对client_id:client_secret,这里采用的是base64编码,比如WEB端的原始Authorization为:Basic web:web
  2. password:这里也对密码进行了AES加密处理

五、服务端认证的流程

先上一张整体的流程图,如下:

图片图片

按照Apifox的密码模式登录接口发出登录请求后,将会按照上方的流程图逐一处理,流程解析如下:

1. 网关前置处理

网关的前置处理分为两个部分:

  • 验证码校验
  • 密码解密

这两个功能都是使用过滤器处理的,在网关的配置文件中可以看到对认证中心codeape-auth配置了两个过滤器,如下:

图片图片

关于网关的过滤器不理解的请看知识星球中《精尽Spring Cloud Alibaba》专栏网关的部分。

1)验证码校验

在前面文章中介绍了码猿慢病云管理系统中是对WEB端、PDA端、PAD端将验证码关闭的,但是对于院外患者端,比如患者APP端还是需要验证码的。

验证码对应的代码在com.code.ape.codeape.gateway.filter.ValidateCodeGatewayFilter中,里面的逻辑在前文介绍过,这里就不再详细说了,有一行代码需要注意一下,代码如下:

//解析请求头中的ClientId,和配置文件configProperties中的比较,忽略不需要校验clientId
boolean isIgnoreClient =configProperties.getIgnoreClients().contains(WebUtils.getClientId(request));

为什么需要注意呢?

上文说过,客户端ID和客户端秘钥是放在Authorization中经过base64编码后发送给服务端,因此后端取client_id是不是也要经过解码,WebUtils.getClientId(request)这个方法就是对Authorization解码获取client_id,代码如下:

/**
  * 从request 获取CLIENT_ID
  * com.code.ape.codeape.common.core.util.WebUtils#getClientId
  */
 @SneakyThrows
 public String getClientId(ServerHttpRequest request) {
  String header = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
  return splitClient(header)[0];
 }

 /**
  * 对请求头中的Authorization拆分且解码
  * com.code.ape.codeape.common.core.util.WebUtils#splitClient
  */
 @NotNull
 private static String[] splitClient(String header) {
  if (header == null || !header.startsWith(BASIC_)) {
   throw new CheckedException("请求头中client信息为空");
  }
  byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8);
  byte[] decoded;
  try {
            //解码
   decoded = Base64.decode(base64Token);
  }
  catch (IllegalArgumentException e) {
   throw new CheckedException("Failed to decode basic authentication token");
  }

  String token = new String(decoded, StandardCharsets.UTF_8);

  int delim = token.indexOf(":");

  if (delim == -1) {
   throw new CheckedException("Invalid basic authentication token");
  }
  return new String[] { token.substring(0, delim), token.substring(delim + 1) };
 }

2) 密码解密

密码解密对应的过滤器:com.code.ape.codeape.gateway.filter.PasswordDecoderFilter,逻辑很简单:

  • 校验是否是登录请求
  • 校验授权类型,如果是刷新令牌则直接放行
  • 解密

代码很简单,注释很清楚,这里就不再详细贴出来了。

注意:客户端和服务端的加密因子需要保持一致才能正确加解密。

2. OAuth2ClientAuthenticationFilter

这个过滤器的作用是用于 OAuth2 的客户端身份验证,主要用于处理客户端使用客户端凭证(client credentials)访问受保护资源的情况。

整体的逻辑如下图:

代码①

这个很好理解,只有登录请求/oauth2/token才会校验客户端信息,其他的请求直接放行

代码②

这行代码是将请求头中客户端信息提取出来转换为Authentication客户端认证对象,这里用到了认证转换器AuthenticationConverter,在该过滤器构造时默认传入了四个,如下图:

this.authenticationConverter.convert(request)该方法调用的是DelegatingAuthenticationConverter#convert方法,内部是循环调用上述的四个才转换器,如下:

上述四个认证转换器比较重要的是其中两个:

1. ClientSecretBasicAuthenticationConverter

这个是处理将客户端信息存放在请求头中转换器,在内部对请求头中的客户端信息进行base64解码,具体的代码逻辑如下:

这个转换器正好是码猿慢病云管理系统中的请求方式相匹配,因此走的则是这个逻辑。

2. ClientSecretPostAuthenticationConverter

这个转换器是处理POST请求,且客户端信息通过Body传输的,里面逻辑也是非常简单,直接从请求参数中获取client_id和client_secret,具体的代码就不带大家看了,有兴趣可以看一下。

代码③

这里就是执行真正的校验逻辑了,内部调用的RegisteredClientRepository#findByClientId()方法校验。

对应的则是整体的流程图的第②部分,这里调用的则是自定义的CodeapeRemoteRegisteredClientRepository#findByClientId方法,内部逻辑非常简单:查询Redis缓存,存在缓存直接取,不存在则查数据库codeape/sys_oauth_client_details(通过feign接口远程调用服务查询)。

代码如下图:

代码④

这部分是客户端认证成功的处理逻辑,是将客户端认证的信息存放到SecurityContext上下文中,方便后面流程获取,代码OAuth2ClientAuthenticationFilter#onAuthenticationSuccess如下:

代码⑤

处理客户端认证失败的结果,这里最终执行的是自定义的失败处理器CodeapeAuthenticationFailureEventHandler#onAuthenticationFailure(),这个下文会介绍。

3. RegisteredClientRepository

这个是客户端的持久层查询的类,在上文已经介绍过

4. OAuth2TokenEndpointFilter

OAuth2TokenEndpointFilter 这个过滤器的作用是用于处理 OAuth2 认证和授权请求的。它会拦截所有请求,并根据请求的 URI 判断是否是授权请求(/oauth2/token)。

如果是授权请求,则它会根据请求的参数构造一个 OAuth2AuthenticationToken 对象,并将其交给 AuthenticationManager 进行身份认证。如果认证成功,则根据请求中携带的授权类型(grant_type)决定使用哪个 OAuth2 授权提供者来生成授权令牌(access_token),并将生成的授权令牌返回给请求方。

如果认证失败,则返回相应的错误信息。该过滤器通常用于实现 OAuth2 认证和授权功能的后端服务。

这个过滤器才是真正处理登录请求逻辑

整体的逻辑如下:

5. AuthenticationConverter

这个在第4步中的第②个步骤,会根据请求中的参数和授权类型组装成对应的授权认证对象。它的几个重要的实现类如下:

先来看一下自定义的抽象类:OAuth2ResourceOwnerBaseAuthenticationConverter,三个抽象方法如下:

  • boolean support(String grantType):判断是否支持指定的授权类型
  • void checkParams(HttpServletRequest request):校验请求参数,比如密码模式下的username、password不能为空,手机验证码登录则手机号不能为空都是在这校验
  • T buildToken():这个是构建认证登录对象的方法

实现的convert()方法代码如下:

@Override
 public Authentication convert(HttpServletRequest request) {

  // grant_type (REQUIRED) ① 校验授权类型,调用抽象方法support
  String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE);
  if (!support(grantType)) {
   return null;
  }

  //② 获取请求参数,比如密码模式:username、password、hosId,scope...
  MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getParameters(request);
  // scope (OPTIONAL)  ③ 提取出scope
  String scope = parameters.getFirst(OAuth2ParameterNames.SCOPE);
  if (StringUtils.hasText(scope) && parameters.get(OAuth2ParameterNames.SCOPE).size() != 1) {
   OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.SCOPE,
     OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);
  }

  Set<String> requestedScopes = null;
  if (StringUtils.hasText(scope)) {
   requestedScopes = new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(scope, " ")));
  }

  // ④ 校验个性化参数
  checkParams(request);

  // ⑤ 获取当前已经认证的客户端信息,这个是在OAuth2ClientAuthenticationFilter认证成功客户端认证对象
  Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication();
  if (clientPrincipal == null) {
   OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ErrorCodes.INVALID_CLIENT,
     OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);
  }

  // ⑥ 扩展信息
  Map<String, Object> additionalParameters = parameters.entrySet()
   .stream()
   .filter(e -> !e.getKey().equals(OAuth2ParameterNames.GRANT_TYPE)
     && !e.getKey().equals(OAuth2ParameterNames.SCOPE))
   .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().get(0)));

  // ⑦ 创建token 调用抽象方法buildToken()
  return buildToken(clientPrincipal, requestedScopes, additionalParameters);

 }

注释非常清晰了,这里不再详细解释了。

其中密码模式认证登录的实现类是:OAuth2ResourceOwnerPasswordAuthenticationConverter,里面的逻辑非常简单,这里不介绍了。

6. AuthenticationToken

`AuthenticationToken`是登录认证对象,在第4步中的第②步组装,[码猿慢病云管理系统](https://mp.weixin.qq.com/s?__biz=MzU3MDAzNDg1MA==&mid=2247526866&idx=1&sn=3820b44ff80c46749efa1a2c0b1f8aa7&chksm=fcf7b61fcb803f090688a542cfb766f5dc06385b5e6e04c2309b4af7b07c07580fb19ffc9d0a&scene=178&cur_album_id=2989600933141807115#rd)中对其进行了扩展,有如下三个类:
  1. OAuth2ResourceOwnerBaseAuthenticationToken:抽象类
  2. OAuth2ResourceOwnerPasswordAuthenticationToken:密码模式的登录认证对象
  3. OAuth2ResourceOwnerSmsAuthenticationToken:短信验证码登录认证对象

后续如有其他授权模式,直接继承OAuth2ResourceOwnerBaseAuthenticationToken扩展

7. AuthenticationProvider

AuthenticationProvider是Spring Security提供的一种机制,用于接收和验证用户名和密码等认证信息,并返回一个已认证的Authentication对象。其作用是封装了整个认证过程,包括认证用户的来源、密码的加密和解密、对用户账户状态的判断等。

AuthenticationProvider在第4步中的第③步中被调用,用于认证;码猿慢病云管理系统中自定义了三个实现类,如下:

1)OAuth2ResourceOwnerBaseAuthenticationProvider

抽象类,封装了具体的执行逻辑,有三个抽象方法供子类实现,如下:

/**
  * 构建登录认证对象
  * @param reqParameters
  * @return
  */
 public abstract UsernamePasswordAuthenticationToken buildToken(Map<String, Object> reqParameters);

 /**
  * 当前provider是否支持此令牌类型
  * @param authentication
  * @return
  */
 @Override
 public abstract boolean supports(Class<?> authentication);

 /**
  * 当前的请求客户端是否支持此模式
  * @param registeredClient
  */
 public abstract void checkClient(RegisteredClient registeredClient);

具体的执行逻辑都在OAuth2ResourceOwnerBaseAuthenticationProvider#authenticate()方法中,关键逻辑如下:

//① 构建登录认证对象,交由子类实现
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = buildToken(reqParameters);

//② 交由Spring Security 认证
Authentication usernamePasswordAuthentication = authenticationManager.authenticate(usernamePasswordAuthenticationToken);

// ----- Access token ----- ③ 构建Access token
OAuth2TokenContext tokenContext = tokenContextBuilder.tokenType(OAuth2TokenType.ACCESS_TOKEN).build();
OAuth2Token generatedAccessToken = this.tokenGenerator.generate(tokenContext);

// ----- Refresh token -----  ④ 认证成功后,构建刷新令牌
tokenContext = tokenContextBuilder.tokenType(OAuth2TokenType.REFRESH_TOKEN).build();
OAuth2Token generatedRefreshToken = this.tokenGenerator.generate(tokenContext);

//⑤ 存储令牌
this.authorizationService.save(authorization);

代码①:构建认证登录对象,提供了一个buildToken抽象方法交由子类实现

剩余代码下文介绍

2)OAuth2ResourceOwnerPasswordAuthenticationProvider

密码模式的AuthenticationProvider,继承抽象类OAuth2ResourceOwnerBaseAuthenticationProvider实现三个抽象方法,逻辑很简单。

3)OAuth2ResourceOwnerSmsAuthenticationProvider

短信验证码登录模式的AuthenticationProvider,继承抽象类OAuth2ResourceOwnerBaseAuthenticationProvider实现三个抽象方法。

8. DaoAuthenticationProvider

从DaoAuthenticationProvider这里就进入真正的认证逻辑了,从名字就可以看出涉及到数据库的操作了。内部的逻辑很简单,就是通过UserDetailService调用查询用户信息封装成UserDetails

在第7步中的第②步骤中则会进入:

//② 交由Spring Security 认证
Authentication usernamePasswordAuthentication = authenticationManager.authenticate(usernamePasswordAuthenticationToken);

码猿慢病云管理系统中自定义了一个CodeapeDaoAuthenticationProvider,执行的逻辑将会在这个类中,先看下其中重载的两个重要的方法:

//方法一:feign远程调用根据username查询用户信息,组装成UserDetails
UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication);

//方法二:校验用户信息、密码
void additionalAuthenticationChecks(UserDetails userDetails,
   UsernamePasswordAuthenticationToken authentication);

1. retrieveUser 查询用户信息

retrieveUser这个方法逻辑很简单,则是调用UserDetailService查询用户信息,逻辑如下:

图片图片

代码①

从Request中获取相关参数:

  1. clientId:客户端ID,由于是base64编码传输,因此需要调用的convert方法解码
  2. hosId:医院ID,WEB、PAD登录所需参数
  3. sn:设备的唯一识别SN号,用于PDA登录

代码②

从IOC容器中获取UserDetailSevice,码猿慢病云管理系统中目前实现类有三个:

  1. CodeapeAppUserDetailsServiceImpl:处理APP端的手机号登录
  2. CodeapePDAUserDetailsServiceImpl:处理PDA端登录
  3. CodeapeUserDetailsServiceImpl:处理PAD端和WEB端登录

代码③

调用UserDetailService中的loadUserByUsernameAndOther方法获取UserDetails

2. additionalAuthenticationChecks 密码校验

这个方法核心逻辑则是校验密码,码猿慢病云管理系统中的密码校验是通过PasswordEncoder加密。

图片图片

3. 用户状态校验

核心逻辑在:AbstractUserDetailsAuthenticationProvider.DefaultPreAuthenticationChecks#check方法中,代码如下:

图片图片

九、 UserDetailService

在第8步中说到查询用户信息是通过UserDetailService查询,码猿慢病云管理系统中目前内置三个实现类:

  1. CodeapeAppUserDetailsServiceImpl:处理APP端的手机号登录
  2. CodeapePDAUserDetailsServiceImpl:处理PDA端登录
  3. CodeapeUserDetailsServiceImpl:处理PAD端和WEB端登录

这里都是通过feign调用解耦,当然你也可以在auth模块嵌入数据库,从数据库查询

这里调用的方法是loadUserByUsernameAndOther,比如CodeapeUserDetailsServiceImpl实现如下:

图片图片

最终的组装UserDetails通过getUserDetails方法,如下:

图片图片

需要注意的是:码猿慢病云管理系统中的用户信息是封装在CodeapeUser中,方便后续扩展,其中的属性如下:

图片图片

可以看到这里和登录返回的信息中user_info是对应的:

图片图片

十. 生成OAuth2AccessToken

在第7步中的第③步中生成access_token,自定义的实现类为:CustomeOAuth2AccessTokenGenerator

图片图片

十一. OAuth2AuthorizationService 令牌持久化

在第7步中的第⑤步骤中执行了令牌的持久化,Spring Security 默认支持两种持久化方式:

  1. InMemoryOAuth2AuthorizationService:持久化在内存中
  2. JdbcOAuth2AuthorizationService:持久化在数据库中

码猿慢病云管理系统中扩展了Redis中持久化,自定义的实现类:CodeapeRedisOAuth2AuthorizationService

图片图片

持久化成功后将会在Redis中看到对应的信息:

图片图片

十二. AuthenticationSuccessHandler 登录成功处理

在第4步中的第④步骤中认证成功,则调用AuthenticationSuccessHandler 处理登录成功的逻辑,将认证信息输出返回给客户端。

码猿慢病云管理系统中自定义类:CodeapeAuthenticationSuccessEventHandler

图片图片

总结

本节内容详细介绍了码猿慢病云管理系统中完整的认证登录生成token的流程,相信你对整体的流程有了清晰的了解。

责任编辑:武晓燕 来源: 码猿技术专栏
相关推荐

2023-10-10 19:25:44

VSCodePython网站

2010-01-15 10:36:59

MozillaFirefox 3.7

2010-02-22 13:01:54

HTML 5谷歌

2022-02-19 23:40:31

iOS苹果Face ID

2024-10-17 14:14:29

2022-12-19 07:38:02

Rust项目代码

2020-08-18 10:35:18

JWTredis认证

2015-12-03 15:33:06

2024-06-24 07:58:00

2023-07-23 17:19:34

人工智能系统

2017-08-16 09:55:36

2023-02-27 16:24:17

架构开发数字化

2020-07-07 09:19:01

LombokJava IDE

2023-10-20 17:37:38

MariaDB数据库MySQL

2023-02-08 07:05:44

2013-05-24 15:24:31

戴尔OpenStackVMware

2012-05-27 20:47:16

Cius

2013-09-11 09:57:41

苹果iPhone5S售价iPhone5C售价

2015-07-17 09:59:18

2018-12-21 11:26:49

MySQLMongoDB数据库
点赞
收藏

51CTO技术栈公众号