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端登录
三个参数:
- 用户名
- 密码
- 医院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
从上面的报文可以看到有两处进行了加密,如下:
- Authorization:这里是对client_id:client_secret,这里采用的是base64编码,比如WEB端的原始Authorization为:Basic web:web
- 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)中对其进行了扩展,有如下三个类:
- OAuth2ResourceOwnerBaseAuthenticationToken:抽象类
- OAuth2ResourceOwnerPasswordAuthenticationToken:密码模式的登录认证对象
- 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中获取相关参数:
- clientId:客户端ID,由于是base64编码传输,因此需要调用的convert方法解码
- hosId:医院ID,WEB、PAD登录所需参数
- sn:设备的唯一识别SN号,用于PDA登录
代码②
从IOC容器中获取UserDetailSevice,码猿慢病云管理系统中目前实现类有三个:
- CodeapeAppUserDetailsServiceImpl:处理APP端的手机号登录
- CodeapePDAUserDetailsServiceImpl:处理PDA端登录
- CodeapeUserDetailsServiceImpl:处理PAD端和WEB端登录
代码③
调用UserDetailService中的loadUserByUsernameAndOther方法获取UserDetails
2. additionalAuthenticationChecks 密码校验
这个方法核心逻辑则是校验密码,码猿慢病云管理系统中的密码校验是通过PasswordEncoder加密。
图片
3. 用户状态校验
核心逻辑在:AbstractUserDetailsAuthenticationProvider.DefaultPreAuthenticationChecks#check方法中,代码如下:
图片
九、 UserDetailService
在第8步中说到查询用户信息是通过UserDetailService查询,码猿慢病云管理系统中目前内置三个实现类:
- CodeapeAppUserDetailsServiceImpl:处理APP端的手机号登录
- CodeapePDAUserDetailsServiceImpl:处理PDA端登录
- CodeapeUserDetailsServiceImpl:处理PAD端和WEB端登录
这里都是通过feign调用解耦,当然你也可以在auth模块嵌入数据库,从数据库查询
这里调用的方法是loadUserByUsernameAndOther,比如CodeapeUserDetailsServiceImpl实现如下:
图片
最终的组装UserDetails通过getUserDetails方法,如下:
图片
需要注意的是:码猿慢病云管理系统中的用户信息是封装在CodeapeUser中,方便后续扩展,其中的属性如下:
图片
可以看到这里和登录返回的信息中user_info是对应的:
图片
十. 生成OAuth2AccessToken
在第7步中的第③步中生成access_token,自定义的实现类为:CustomeOAuth2AccessTokenGenerator
图片
十一. OAuth2AuthorizationService 令牌持久化
在第7步中的第⑤步骤中执行了令牌的持久化,Spring Security 默认支持两种持久化方式:
- InMemoryOAuth2AuthorizationService:持久化在内存中
- JdbcOAuth2AuthorizationService:持久化在数据库中
码猿慢病云管理系统中扩展了Redis中持久化,自定义的实现类:CodeapeRedisOAuth2AuthorizationService
图片
持久化成功后将会在Redis中看到对应的信息:
图片
十二. AuthenticationSuccessHandler 登录成功处理
在第4步中的第④步骤中认证成功,则调用AuthenticationSuccessHandler 处理登录成功的逻辑,将认证信息输出返回给客户端。
码猿慢病云管理系统中自定义类:CodeapeAuthenticationSuccessEventHandler
图片
总结
本节内容详细介绍了码猿慢病云管理系统中完整的认证登录生成token的流程,相信你对整体的流程有了清晰的了解。