文章开头第一句加入:本文已参与「新人创作礼」活动, 一起开启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);
}
}
},
);
这里就是我写的一个鉴权和令牌刷新的核心代码了,希望能够帮到大家。如果有写的不好的地方,大家也可以指出来,让我好好改改,嘻嘻~