「这是我参与2022首次更文挑战的第6天,活动详情查看:2022首次更文挑战」。
# springboot整合shiro——前后端分离模式(上篇)
在上一篇中我简单描述了一下realm的工作流程,接下来就是ShiroConfig以及前后端分离的实现。我先讲一下实现前后端分离的思路,前后端分离的模式下交互都是通过前端调用接口然后获取返回值实现的,但是传统的再配置shiroConfig的时候会有这条语句:
shiroFilterFactoryBean.setLoginUrl("/login");
这是用来设置登陆页面的,我们在配置shiroConfig的时候会设置过滤器,我看了很多相关的文章都是直接用的shiro自带的一些过滤器,但是这些自带的过滤器在认证失败的时候会重定向到登陆页面,不能满足我们的需求,所以把shiro的过滤器重写一遍满足我们自己的需求即可,我这里重写以后如果认证失败的话就会抛出异常然后再设置一下异常捕获封装一下异常信息加上状态码返回给前端就可以解决前后端分离的问题,如果登陆失败的话把错误信息加错误码返回给前端剩下的就交给前端解决了。我们先来看一下shiroConfig怎么配置。具体源码可以前往GitHub参阅->仓库地址,首先是将我们在上一篇自己定义的realm验证加入到shiro中
//将自己的验证方式加入容器
@Bean
public UserRealm myShiroRealm() {
UserRealm userRealm = new UserRealm();
return userRealm;
}
然后就是最关键的过滤部分
//对url的过滤筛选
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
//oauth过滤
Map<String, Filter> filters = new HashMap<>();
filters.put("auth", new AuthFilter());
shiroFilterFactoryBean.setFilters(filters);
Map<String, String> filterMap = new LinkedHashMap<>();
filterMap.put("/druid/**", "anon");
filterMap.put("/sysuser/**", "anon");//注册登录相关
filterMap.put("/swagger/**", "anon");
filterMap.put("/v3/api-docs", "anon");
filterMap.put("/swagger-ui/**", "anon");
filterMap.put("/swagger-resources/**", "anon");
filterMap.put("/**", "auth");
//登录
// shiroFilterFactoryBean.setLoginUrl("/sysuser/login");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterMap);
return shiroFilterFactoryBean;
}
主要的就是对接口设置过滤器,Shiro 的内置过滤器常用有
- anon: 无需认证(登录)即可访问
- auth: 必须认证才可访问
- user: 如果使用 rememberMe
- perms: 该资源必须得到资源权限才能访问
- role: 该资源必须得到角色权限才可访问 可以看到我把一些接口文档和注册登录相关的接口都设置了anon也就是无需认证,其余的都是走auth也就是其余的接口都要经过这个过滤器认证,所以我们需要重写的也是这个过滤器。我们先重写一下createToken方法:
@Override
protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {
//获取请求token(dataId)
String token = TokenUtil.getRequestToken((HttpServletRequest) request);
return new AuthToken(token);
}
在我的项目中token跟用户是一一对应的,用户登陆的时候生成token和过期时间,然后再请求的时候再头里或者请求里带上token就可以知道是谁在访问接口,拿到token以后返回一个token对象即可,AuthToken就是一个最简单的操作token的对象
public class AuthToken extends UsernamePasswordToken {
private String token;
public AuthToken(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
AuthToken是继承自UsernamePasswordToken的,我们可以看一下官方的认证流程
我们可以看到最开始就是从subject.login()开始的,先不用去管subject是怎么来的,因为shiro自己会去获取这个subject,我看到很多文章都会在登陆的部分去手动获取一个subject然后创建一个UsernamePasswordToken把账号密码放进去在执行subject.login(UsernamePasswordToken),其实把这一步省掉也是可以的,只要保证我们自己配置的realm可以通过用户校验即可。我会专门写一篇文章分析一下登陆的整个流程。所以简单来说这个token里面放的就是我们用来验证用户身份的信息,我这里放的就是前端传过来的token,因为token和用户一一对应相当于就代表了用户,我在realm中配置的也是根据这个token来校验用户,所以我们重写createToken这个方法后就会返回我们自己的token给shiro校验。然后我们现在过滤器里面拒绝所有的访问请求
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
if (((HttpServletRequest) request).getMethod().equals(RequestMethod.OPTIONS.name())) {
return true;
}
return false;
}
拒绝访问的请求,会调用onAccessDenied方法,我们再重写onAccessDenied方法
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
//获取请求token,如果token不存在,直接返回
String token = TokenUtil.getRequestToken((HttpServletRequest) request);
if (StringUtils.isBlank(token)) {
RuntimeException runtimeException = new RuntimeException("token不存在");
// 异常捕获,发送到expiredJwtException
request.setAttribute("errMsg", runtimeException);
//将异常分发到/filterException控制器
request.getRequestDispatcher("/filterException").forward(request, response);
return false;
}
return executeLogin(request, response);
}
在这里我们先判断一下token存不存在,不存在说明用户没登陆没有拿到token,直接抛出异常就可以了,因为我这里用 @RestControllerAdvice和@ExceptionHandler配置了全局异常处理,具体操作可以参考这篇文章# SpringBoot 如何统一后端返回格式
但是这个只能处理controller层的全局异常,过滤器的加载是早于controller的所以没法捕获,为了解决这个问题我写了一个ExceptionController专门用于接受filter中的异常然后再抛出,这样就可以被捕获然后封装一下返回给前端。
@RestController
public class ExceptionController {
/**
* 重新抛出异常
*/
@RequestMapping("/filterException")
public void filterException(HttpServletRequest request) throws Exception {
//返回的是java.lang.RuntimeException: 请重新登录,分割一下只输出信息
throw new RuntimeException(request.getAttribute("errMsg").toString().split(":")[1]);
}
}
如果token存在的话就会调用executeLogin(request, response)方法,这个方法中shiro就会帮我们认证这个token是否是已经登陆的用户,我们自己定义的realm也会在这个方法中被调用用以认证用户。最后在过滤器里要重写一下认证失败的方法,还是和之前一样失败的话也是把异常分发到controller再抛出。
@SneakyThrows
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e,
ServletRequest request, ServletResponse response) {
RuntimeException runtimeException = new RuntimeException("登陆状态已失效,请重新登录");
// 异常捕获,发送到expiredJwtException
request.setAttribute("errMsg", runtimeException);
//将异常分发到/filterException控制器
request.getRequestDispatcher("/filterException").forward(request, response);
return false;
}
因为这个方法原来的操作只是设置一下失败信息就返回了,不适用于前后端分离的模式,所以我把它重写成抛出异常的方式程序才可以正常运行,不然认证失败的话前端是收不到后端的返回信息的。
整个的大致流程如下,到此为止前后端分离的操作就完成了。
整个认证过程的关键点其实就是请求携带的token和realm中SimpleAuthenticationInfo这个认证信息中的accessToken是否相同,shiro认证用户就是通过比对subject.login(token)传入的token信息和SimpleAuthenticationInfo中的第二个参数值,我这个项目用的是tokenId标志用户的(我把在登录的时候生成的token叫做tokenId,是一个值,login方法里传入的token是一个对象,要区分开),所以在创建token对象的时候就只需要把tokenId传进去就可以了,常规的比如用UsernamePasswordToken创建的时候就是把用户名和密码传进去生成token对象再调用subject.login(token)方法,这时候SimpleAuthenticationInfo的第二个参数放的应该就是密码,认证就是比较这个密码和token传来的密码是否一样。第一次调用subject.login(token)方法时会经过realm的认证然后new一个SimpleAuthenticationInfo返回存放在在shiro的缓存中,然后下次再认证的时候就直接判断token里的参数和缓存中的Info信息是否匹配就可以了,这也是为什么我说不用在登陆的时候调用subject.login(token)的原因,只要我们登陆了拿到了token信息可以通过认证然后再调用别的接口的时候第一次也会new一个SimpleAuthenticationInfo在shiro的缓存,因为每次认证都会走subject.login(token)方法,区别就是第一次会去realm中拿到Info信息,剩下的就直接去缓存里拿就可以了,所以登陆的时候拿到了token确保第一次可以通过realm的认证就好了。以上只是我个人的理解如果有不对的地方欢迎指正。