[Java]微服务体系下的用户身份认证方案
需求分析
网关请求处理流程
网关登录校验的核心
- 如何在网关转发之前做登录校验?
- 通过自定义过滤器, 请求到达微服务之前, 进行登录校验
- 网关如何将用户信息传递给微服务?
- 通过在请求头中携带JWT令牌, 向后传递用户信息(网关内部的请求工具)
- 如何在微服务之间传递用户信息?
- 通过在请求头中携带JWT令牌, 向后传递用户信息(请求工具OpenFeign)
自定义过滤器
网关过滤器有两种,分别是:
- GatewayFilter: 路由过滤器,作用于任意指定的路由; 默认不生效,要配置到路由后生效
- GlobalFilter: 全局过滤器,作用范围是所有路由; 声明后自动生效
- 两种过滤器的过滤方法签名完全一致:
解读全局过滤器的信息
定义全局过滤器: 两种过滤器的定义存在差异, 全局过滤器的定义简单一些, 实现GlobalFilter接口即可
@Component
public class MyGlobalFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 获取请求
ServerHttpRequest request = exchange.getRequest();
// 过滤器业务处理
System.out.println("GlobalFilter pre阶段执行了");
HttpHeaders headers = request.getHeaders();
System.out.println("headers = " + headers);
// 反放行
return chain.filter(exchange);
}
@Override
public int getOrder() {
// 过滤器执行顺序, 值越小, 优先级越高
return 0;
}
}
自定义GatewavFilter是实现 AbstractGatewayFilterFactory 工厂,示例如下:
- 优势: 可以自由的指定作用的返回, 而且在配置时可以指定自定义参数, 非常灵活
- 劣势: 定义起来比较麻烦
- 定义无参的GatewavFilter过滤器
- 定义无参的GatewavFilter过滤器, 并设置执行顺序
- 定义有参的GatewavFilter过滤器
实现登录校验
需求: 在网关中基于过滤器实现登录校验功能
- 黑马商城是基于IWT实现的登录校验,目前相关功能在hm-service模块
- 将JWT工具类拷贝到gateway模块
- 拷贝过来的AuthProperties类会报错, 表示该类没有生效, 添加@Component注解就可以了
- 将工具方法拷贝到gateway模块
- 把JWT的一些配置复制到配置文件中
hm:
jwt:
location: classpath:hmall.jks
alias: hmall
password: hmall123
tokenTTL: 30m
auth:
excludePaths:
- /search/**
- /users/login
- /items/**
- /hi
- 创建GlobalFilter过滤器, 实现登录校验
@Component
@RequiredArgsConstructor
public class AuthGlobalFilter implements GlobalFilter, Ordered {
// 注入配置文件中的属性 (排除路径)
private final AuthProperties authProperties;
// 注入jwt工具类
private final JwtTool jwtTool;
// Spring提供的专门用于路径匹配的工具
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//1. 获取request
ServerHttpRequest request = exchange.getRequest();
//2. 判断是否需要做登录拦截
String path = request.getPath().toString();
if (isExclude(path)){
return chain.filter(exchange);
}
//3. 获取token
String token = null;
List<String> headers = request.getHeaders().get("authorization");
if(headers != null && !headers.isEmpty()) {
token = headers.get(0);
}
//4. 校验并解析token
Long userId = null;
try {
userId = jwtTool.parseToken(token);
} catch (UnauthorizedException e) {
// 拦截,设置响应状态为401
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
//5. 传递用户信息
System.out.println("userId = " + userId);
//6. 放行
return chain.filter(exchange);
}
private boolean isExclude(String path) {
for (String excludePath : authProperties.getExcludePaths()) {
if (antPathMatcher.match(excludePath, path)) {
return true;
}
}
return false;
}
@Override
public int getOrder() {
return 0;
}
}
- 访问需要校验的接口
网关传递用户信息
网关传递用户信息到微服务的流程
- 网关在自定义过滤器中拿到请求头中的用户信息, 并进行校验, 没问题后, 把用户信息传递给后面的微服务
- 在微服务中定义拦截器, 拿到用户信息, 然后保存到ThreadLocal中, 后续业务就可以随时使用了
在网关的登录校验过滤器中, 把获取到的用户写入请求头
@Component
@RequiredArgsConstructor
public class AuthGlobalFilter implements GlobalFilter, Ordered {
... ...
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
... ...
//5. 传递用户信息
// System.out.println("userId = " + userId);
String userInfo = userId.toString();
ServerWebExchange swe = exchange.mutate()
.request(builder -> builder.header("user-info", userInfo))
.build();
//6. 放行
return chain.filter(swe);
}
... ...
}
- 需求: 修改gateway模块中的登录校验拦截器,在校验成功后保存用户到下游请求的请求头中
- 提示: 要修改转发到微服务的请求,需要用到ServerWebExchange类提供的API
- 使用 exchange 上下文对象的mutate 方法对请求做修改
- mutate对象返回构建器对象builder, 使用builder中的request方法对请求做修改
- request方法会返回requestBuild, 可以使用lambda表达式, 对请求头或者其他部分做处理
- 定义的请求头的名字可以随便写, 但是取值时也要用这个名字, 建议定义为常量, 请求头的内容需要是字符串
- 请求修改完成后会返回新的ServerWebExchange, 放行时把最新的请求上下文传递过去, 修改就生效了
- 测试一下: 在购物车微服务中, 修改查询购物车的controller, 拿到请求头中用户信息
@Api(tags = "购物车相关接口")
@RestController
@RequestMapping("/carts")
@RequiredArgsConstructor
public class CartController {
@ApiOperation("查询购物车列表")
@GetMapping
public List<CartVO> queryMyCarts(@RequestHeader(value = "user-info", required = false) String userInfo) {
System.out.println("userInfo =" + userInfo);
return cartService.queryMyCarts();
}
}
- 说明网关成功从token中解析出用户信息, 并且通过请求头传递给了下游微服务
- 在controller中通过注解形式获取用户信息, 比较麻烦
在hm-common中编写SpringMVC拦截器,获取登录用户
- 由于每个微服务都可能有获取登录用户的需求,因此我们直接在hm-common模块定义拦截器
- 这样微服务只需要引入依赖即可生效,无需重复编写
- 定义拦截器, 储存用户信息
public class UserInfoInterceptor implements HandlerInterceptor {
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取登录用户信息
String userInfo = request.getHeader("user-info");
// 2. 判断是否获取了用户信息(有些接口不需要登录就能访问, 所以可能获取不到)
if (StrUtil.isNotBlank(userInfo)) {
UserContext.setUser(Long.parseLong(userInfo));
}
// 3.放行
return true;
}
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// controller执行完成后清理用户
UserContext.removeUser();
}
}
- StrUtil.isNotBlan()时hutool工具包提供的判断字符串的方法, 可以判断null, 空字符串, 空格等多种情况, 比较严谨
- UserContext工具类中的getUser()使用的非常多, 可以使用ctrl + alt + f7 查看使用的地方
- 把cart-service中写死的用户id改造为动态获取
@Service
@RequiredArgsConstructor
public class CartServiceImpl extends ServiceImpl<CartMapper, Cart> implements ICartService {
@Override
public List<CartVO> queryMyCarts() {
// 1.查询我的购物车列表
// todo: UserContext.getUser() => 写死1 ,待优化
List<Cart> carts = lambdaQuery().eq(Cart::getUserId,1L ).list();
List<Cart> carts = lambdaQuery().eq(Cart::getUserId,UserContext.getUser()).list();
... ...
}
}
- 注册拦截器, 让拦截器生效
@Configuration
public class MvcConfig implements WebMvcConfigurer {
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new UserInfoInterceptor());
}
}
- 拦截器的配置是在hm-common模块下, 其他微服务默认是扫描不到的, 所以并不会生效, 所以要把配置类放到META-INF文件下的spring.factories中, 就能实现自动装配了
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.hmall.common.config.MyBatisConfig,\
com.hmall.common.config.MvcConfig,\
com.hmall.common.config.JsonConfig
- WebMvcConfigurer属于SpringConfigMvc包下, 网关底层不是SpringMvc那一套, 而是非阻塞的响应式编程技术, 基于WebFlux, 所以我们在网关中引入hm-common时, 拦截器的配置类会被加载, 就会报错
- 所以我们要让拦截器的配置类按需加载, SpringBoot的自动装配是可以带条件的, 让配置类在微服务中生效, 在网关中不生效, 判断条件就是是否存在SpringMvc, SpringMvc的核心是DispatcherServlet
@Configuration
@ConditionalOnClass(DispatcherServlet.class) // springboot自动配置的条件注解
public class MvcConfig implements WebMvcConfigurer {
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new UserInfoInterceptor());
}
}
- 测试一下: 动态拿到用户id, 查询购物车数据
OpenFeign传递用户
微服务项目中的很多业务要多个微服务共同合作完成,而这个过程中也需要传递登录用户信息,例如:
OpenFeign中提供了一个拦截器接口,所有由OpenFeign发起的请求都会先调用拦截器处理请求:
其中的RequestTemplate类中提供了一些方法可以让我们修改请求头:
解决方案: 使用OpenFeign调用其他微服务时, 在请求头中携带用户信息, 其他服务就可以拿到用户信息了
- 在hm-api引入common模块, 因为要使用ThreadLocal工具类
<!-- common-->
<dependency>
<groupId>com.heima</groupId>
<artifactId>hm-common</artifactId>
<version>1.0.0</version>
</dependency>
- 在hm-api中添加OpenFeign的拦截器接口
public class DefaultFeignConfig {
@Bean
public RequestInterceptor feignInterceptor() {
return new RequestInterceptor() {
@Override
public void apply(RequestTemplate requestTemplate) {
Long userId = UserContext.getUser();
if (userId != null) {
requestTemplate.header("user-info", userId.toString());
}
}
};
}
}
- 注意: 交易服务的请求是从网关转发过来的, 所以交易服务是一定可以拿到请求头中的用户信息的
- DefaultFeignConfig这个配置生效的前提是要加在OpenFeign的启动类上
/**
交易服务
*/
@MapperScan("com.hmall.trade.mapper")
@EnableFeignClients(basePackages = "com.hmall.api.client", defaultConfiguration = DefaultFeignConfig.class)
@SpringBootApplication
public class TradeApplication {
public static void main(String[] args) {
SpringApplication.run(TradeApplication.class, args);
}
}
- 重启交易服务, 提交订单后, 会删除购物车中已经结算的商品
小结
为了解决微服务体系下的用户登录校验和用户信息传递, 使用了几种拦截器, 现在梳理一下