记录前后端分离中令牌刷新,与微服务鉴权

lxf2023-03-17 17:18:01
文章开头第一句加入:本文已参与「新人创作礼」活动, 一起开启AdminJS创作之路。

前情提要

由于自己在编写一个前后端分离的项目,后端用的是springcloud alibaba前端选用的vue3 + pinia + ts的组合,这里对权限的控制和角色的控制是我比较在意的一个环节,这里的话就选用spring security鉴权,配置jwt生成token和刷新token完成权限校验模块

记录前后端分离中令牌刷新,与微服务鉴权

逻辑介绍

当一次前端的请求打到我们的网关时,我们会去判断这次的请求是否不需要鉴权,直接交由 认证模块/业务模块 进行对应的处理。如果需要鉴权,则会通过feign调用security的鉴权服务。同时在鉴权时,如果出现了token过期的情况,会返回302状态码,前端会暂存当前的请求,并调用刷新令牌的服务,如果能够拿到token,则将token放入这些请求的请求头中,继而重新发起请求。

后端核心代码

由于自己的核心能力有限,可能有的说法不对,或者代码逻辑写的不好的地方,还请大家海涵。

记录前后端分离中令牌刷新,与微服务鉴权 那我们废话不多说,听我说,3,2,1 上代码

网关服务

这里有两个自定义的请求头:第一个是大家所熟知的 "Authorization",第二个是我自己定义的 "ignore" 。按照顺序,如果某一个请求在header中携带了ignoer:'Y',那我就会在第一个过滤器执行时,为exchange赋予一个特殊的值,这个值会在后面的过滤器检测到,判断是否执行过滤操作。具体代码如下

@Component
public class IgnoreFilter implements GlobalFilter, Ordered {

    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String ignore = exchange.getRequest().getHeaders().getFirst("ignore");
        if("Y".equals(ignore)){
            exchange.getAttributes().put(AllFilter.ATTRIBUTE_IGNORE_FILTER, true);
        }else{
            exchange.getAttributes().put(AllFilter.ATTRIBUTE_IGNORE_FILTER, false);
        }
        return chain.filter(exchange);
    }


    @Override
    public int getOrder() {
        return 0;
    }
}

如果后面的过滤器取出AllFilter.ATTRIBUTE_IGNORE_FILTER的值为true的话,则会跳过security模块的鉴权操作。具体代码如下

@Component
@Slf4j
public class AllFilter implements GlobalFilter, Ordered {

    public static final  String ATTRIBUTE_IGNORE_FILTER = "ignoreFilter";

    @Resource
    SecurityFeign securityFeign;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        if (Boolean.FALSE.equals(exchange.getAttribute(ATTRIBUTE_IGNORE_FILTER))) {
            ContextUtils.setToken(exchange.getRequest().getHeaders().getFirst("Authorization"));
            //这里调用security的权限校验接口
            CompletableFuture<ResponseResult<Boolean>> completableFuture = CompletableFuture.supplyAsync
                    (()-> securityFeign.PermissionAuthentication(request.getPath().toString()));

            try {
                ResponseResult<Boolean> result = completableFuture.get();
                if(!result.getData()){
                    exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
                    return exchange.getResponse().setComplete();
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } catch (ExecutionException e) {
            //这里的302异常则会通知到前端,进行刷新token进行操作
                if(e.getCause().toString().contains("302")){
                    exchange.getResponse().setStatusCode(HttpStatus.FOUND);
                    return exchange.getResponse().setComplete();
                }
                throw new RuntimeException(e);
            }
            log.info("测试。。");

        }
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return 10;
    }
}

security服务

security服务主要看几个配置,以及刚刚的鉴权接口吧。 这里贴上securityConfig的配置

@Configuration
@EnableWebSecurity    // 添加 security 过滤器
public class SecurityConfig{

    @Resource
    private UserDetailsServiceImpl userDetailsServiceImpl;

    @Resource
    private AccessDeniedHandler accessDeniedHandler;

    @Resource
    private AuthenticationEntryPoint authenticationEntryPoint;

    @Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    UserInfoFilter userInfoFilter(){
        return new UserInfoFilter();
    }

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        //特殊状态的返回
        httpSecurity.exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler);

        return httpSecurity.csrf().disable().cors().and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and().authorizeHttpRequests(auth->{
                    auth
                            .antMatchers("/security/user/**").permitAll()
                            //进入的所有请求都需要走认证
                            .anyRequest().authenticated();
                })
                //这里需要添加redis的过滤器
                .addFilterBefore(userInfoFilter(), UsernamePasswordAuthenticationFilter.class)
                .userDetailsService(userDetailsServiceImpl)
                .build();

    }

    @Bean
    public UserDetailsManager userDetailsManager(DataSource dataSource){
        return new JdbcUserDetailsManager(dataSource);
    }

    @Bean
    public AuthenticationManager authenticationManager(){
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        daoAuthenticationProvider.setUserDetailsService(userDetailsServiceImpl);
        return new ProviderManager(daoAuthenticationProvider);
    }

}

以及用户权限过滤的filter,这里会判断token的有效期,并且能够返回302状态码给到网关层,由网关返回给前端

public class UserInfoFilter extends OncePerRequestFilter {

    @Resource
    private RedisCache redisCache;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String token = request.getHeader("Authorization");
        if (!StringUtils.hasText(token)) {
            //401 这里就是需要权限但是没有token,则需要登录给一个token
            filterChain.doFilter(request,response);
            return;
//            return response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        }
        //解析token
        Claims claims;
        try {
            claims = JwtUtil.parseJWT(token);
        } catch (ExpiredJwtException e) {
            //这里的code是自己定义的,状态码也是302,用来做刷新操作
            response.setStatus(ResponseStatusCode.FOUND);
            return;
        }catch (Exception e){
            throw new CustomizeException("token解析异常");
        }
        String uuid = claims.getId();
        if (uuid == null) {
            throw new CustomizeException("从token中无法解析ID");
        }
        //从redis拿到userInfo
        LoginUser loginUser = redisCache.getCacheObject(RedisConstant.TOKEN_PRE+uuid);
        Collection<? extends GrantedAuthority> authorities = loginUser.getAuthorities();

        //存入context方便后面来进行权限的校验
        SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(loginUser,null,authorities));
        filterChain.doFilter(request,response);
    }
}

其次就是一个鉴权的接口了,这里的接口使用的是数据库里的配置项进行配置,读取数据库的配置,查看用户是否拥有对应URL的权限,如果有就返回true,由网关放行,没有的话就返回false,网关进行拦截操作。 Tips:controller层没有什么特殊的就不看了

@Slf4j
@Service
public class AuthenticationServiceImpl implements AuthenticationService {

    @Resource
    private MenuMapper menuMapper;
    @Override
    public Boolean hasAuthentication(String url) {

        //获取对于url所需要的权限
        Menu menu = menuMapper.selectOne(new QueryWrapper<Menu>().lambda().eq(Menu::getPath, url));
        //这里如果没有配置的话,那就都不可以进入
        if(menu== null||!StringUtils.hasText(menu.getPerms())){
            log.info("当前url暂未配置");
            return false;
        }
        String perms = menu.getPerms();
        SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(perms);
        //跟现在所拥有的权限做对比,查看是否有交集,如果有,则通过
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        return authentication.getAuthorities().contains(simpleGrantedAuthority);
    }
}

前端代码

前端这里就主要是响应拦截器的配置操作,这里如果捕获到了302的状态码,则会根据pinia中存储的reFlushToken发起刷新token。并且将这期间的请求进行一个队列的保存,等返回了token之后,会再将这些请求进行发出。


// 避免其他接口同时请求(只请求一次token接口)
let isRefreshToken = false;

// 请求队列
let requests: ((token: string) => void)[] = [];
// 响应拦截器
axiosInstance.interceptors.response.use(
  (response: AxiosResponse) => {
    if (response.status === 200) {
      return response.data;
    }
    ElMessage.info(JSON.stringify(response.status));
    return response;
  },
  (error: AxiosError) => {
    const { response } = error;
    if (response) {
      const { config } = response;
      if (response.status === 302) {
        //这里捕获到302的异常,进行刷新token
        if (!isRefreshToken) {
          isRefreshToken = true;
          UserCommonSupport.reflushToken(
            {},
            {
              params: {
                reFlushToken: userInfo.reFlushToken,
              },
              //这里需要配置过滤请求头
              headers: { ignore: 'Y' },
            },
          )
            .then((res) => {
              const { code, message, data } = res;
              if (code === 200) {
                console.log(message);
                userInfo.token = data;
                requests.forEach((cb) => cb(data));
                requests = [];
                config.headers.Authorization = data;
                axiosInstance(config);
              }
            })
            .catch(() => {
              console.log('刷新token时出现错误,重新登陆');
              router.push('/');
            })
            .finally(() => {
              isRefreshToken = false;
            });
        } else {
          // 这里的请求需要处于等待状态
          return new Promise((resolve) => {
            // 将resolve放进队列,用一个函数形式来保存,等token刷新后直接执行
            console.log('获取到了token,准备刷新token');

            requests.push((token: string) => {
              config.baseURL = '';
              config.headers.Authorization = token;
              resolve(axiosInstance(config));
            });
          });
        }
      } else {
        console.log('error', error);
        ElMessage.warning('网络连接异常,请稍后再试!');
        return Promise.reject(error);
      }
    }
  },
);

记录前后端分离中令牌刷新,与微服务鉴权 这里就是我写的一个鉴权和令牌刷新的核心代码了,希望能够帮到大家。如果有写的不好的地方,大家也可以指出来,让我好好改改,嘻嘻~

已知问题

  • 网关发送请求要鉴权模块时会产生阻塞,主要原因是我的 认证鉴权服务的security模块选用的是servlet的网络模型,而不是基于,后期学废了会考虑进行更换。
  • 有一个手机验证码登录的方式还没有加上,也会加上的